diff --git a/.gitignore b/.gitignore index 70b0c94..0c845ce 100755 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ +/pages/*.html /target /templates_min /uploads Nekrochan.toml .env - -cloud-run.sh diff --git a/Nekrochan.toml.template b/Nekrochan.toml.template new file mode 100755 index 0000000..72b1eae --- /dev/null +++ b/Nekrochan.toml.template @@ -0,0 +1,47 @@ +[server] +port = ${PORT} +database_url = "postgres://${DB_HOST}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" +cache_url = "redis://${REDIS_HOST}/${REDIS_DB}" + +[site] +name = "${SITE_NAME}" +description = "${SITE_DESCRIPTION}" +theme = "yotsuba.css" +links = [] +noko = true + +[secrets] +auth_token = "${AUTH_SECRET}" +secure_trip = "${TRIP_SECRET}" +user_id = "${UID_SECRET}" + +[files] +videos = true +thumb_size = 150 +max_size_mb = 50 +max_height = 10000 +max_width = 10000 +cleanup_interval = 3600 + +[board_defaults] +anon_name = "Anonym" +page_size = 10 +page_count = 20 +file_limit = 1 +bump_limit = 500 +reply_limit = 1000 +locked = false +user_ids = false +flags = false +thread_captcha = "off" +reply_captcha = "off" +board_theme = "yotsuba.css" +require_thread_content = true +require_thread_file = true +require_reply_content = false +require_reply_file = false +antispam = true +antispam_ip = 5 +antispam_content = 10 +antispam_both = 60 +thread_cooldown = 60 diff --git a/README.md b/README.md index 43dc014..d5f3927 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,102 @@ 100% český imidžbórdový skript ->100% český přestože je kód anglicky... +> 100% český přestože je kód anglicky... Brzy dostupný na https://czchan.org/. + +## Tutoriál nebo něco + +Pravděpodobně to běží jenom na Linuxu, ale nikdo na serverech Windows stejně nepoužívá. Tutoriál počítá se systémem Ubuntu a je možné, že je nekompletní. + +### Nainstaluj Rust + +Ne, nejsem transka (zatím). + +``` +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +### Nainstaluj ostatní požadavky + +``` +# Potřebné ke kompilaci +sudo apt install binutils build-essential libssl-dev libpq-dev postgresql +# Potřebné k funkci +sudo apt install imagemagick ffmpeg +``` + +### Vytvoř databázi + +``` +sudo adduser nekrochan --system --disabled-login --no-create-home --group +sudo passwd nekrochan # Nastavíme heslo pro systémového uživatele +sudo -iu postgres psql -c "CREATE USER nekrochan WITH PASSWORD 'password';" +sudo -iu postgres psql -c "CREATE DATABASE nekrochan WITH OWNER nekrochan;" +``` + +### Automatická konfigurace + +``` +chmod +x ./configure.sh +./configure.sh +``` + +### Nastartuj server + +``` +cargo run --release +``` + +### Vytvoř nástěnku + +Po kompilaci by se měl spustit server na https://localhost:7000/. Stránka ti pravděpodobně řekne, že ještě nebyla inicializována domovní stránka. Je potřeba vytvořit nástěnku. Nejdříve je ale potřeba vytvořit administátorský účet. + +Heslo v příkladu je "password", můžeš použít příklad a heslo změnit potom v administrátorském rozhraní. + +``` +sudo -iu nekrochan psql -c "INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$jHl27pbYNvgpQxksmF.N/O0IHrfFBDY1Tg/qBX/UwrMa3j7owkiQm', true, '131072'::jsonb);" +``` + +Po příkazu budeš muset restartovat server, aby se změna projevila v mezipaměti. + +Nástěnka lze vytvořit po přihlášení na https://localhost:7000/login na stránce https://localhost:7000/staff/boards + +### Automatický start + +Nejprve vytvoříme složku pro nekrochan a zkopírujeme tam potřebné soubory. + +``` +sudo mkdir -p /srv/nekrochan +sudo chown nekrochan:nekrochan /srv/nekrochan +sudo cp -r ./target/release/nekrochan Nekrochan.toml ./pages ./static ./uploads /srv/nekrochan/ +``` + +Nyní vytvoříme skript pro systemd, aby server automaticky nastartovat po zapnutí počítače. Uložíme ho jako `/etc/systemd/system/nekrochan.service`. + +``` +[Unit] +Description=Nekrochan +After=network.target + +[Service] +User=nekrochan +ExecStart=/srv/nekrochan/nekrochan +WorkingDirectory=/srv/nekrochan +Environment=RUST_LOG="info" +Restart=on-failure + +ProtectSystem=yes +PrivateTmp=true +MemoryDenyWriteExecute=true +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +``` + +### Další konfigurace + +Většinu možností najdeš v souboru `Nekrochan.toml`. Vlastní stránky (např. pravidla, faq apod.) můžeš nahrávat do složky `pages`. + +Také budeš pravděpodobně chtít nastavit reverzní proxy, např. NGINX. IP adresu posílej serveru v hlavičce `X-Forwarded-For` a kód země (potřebný pro vlajky) v hlavičce `X-Country-Code`. diff --git a/configure.sh b/configure.sh new file mode 100755 index 0000000..23e6bc6 --- /dev/null +++ b/configure.sh @@ -0,0 +1,68 @@ +set -e + +echo "# Výběr portu" +read -p "Port serveru [7000]: " port + +echo "# Konfigurace databáze" +read -p "Host databáze [localhost]: " db_host +read -p "Port databáze [5432]: " db_port + +read -p "Uživatelské jméno: " db_user +if [ -z ${db_user} ] +then + echo "Uživatelské jméno je povinné" + exit 1 +fi + +read -p "Heslo: " db_password +if [ -z ${db_password} ] +then + echo "Heslo je povinné" + exit 1 +fi + +read -p "Jméno databáze: " db_name +if [ -z ${db_name} ] +then + echo "Jméno databáze je povinné" + exit 1 +fi + +echo "# Konfigurace redisu" +read -p "Host redisu [localhost]: " redis_host +read -p "Číslo databáze [0]: " redis_db + +echo "# Konfigurace stránky" + +read -p "Jméno stránky: " site_name +if [ -z ${site_name} ] +then + echo "Jméno stránky je povinné" + exit 1 +fi + +read -p "Popis stránky: " site_description +if [ -z ${site_description} ] +then + echo "Popis stránky je povinný" + exit 1 +fi + +export PORT=${port:-7000} +export DB_HOST=${db_host:-localhost} +export DB_PORT=${db_host:-5432} +export DB_USER=${db_user} +export DB_PASSWORD=${db_password} +export DB_NAME=${db_name} +export REDIS_HOST=${redis_host:-localhost} +export REDIS_DB=${redis_db:-0} +export REDIS_HOST=${redis_host:-localhost} +export SITE_NAME=${site_name} +export SITE_DESCRIPTION=${site_description} + +export AUTH_SECRET=`tr -dc A-Za-z0-9 Nekrochan.toml +mkdir -p ./uploads/thumb diff --git a/migrations/20230710121446_create_tables.sql b/migrations/20230710121446_create_tables.sql index cccea3b..66e2632 100755 --- a/migrations/20230710121446_create_tables.sql +++ b/migrations/20230710121446_create_tables.sql @@ -26,5 +26,3 @@ CREATE TABLE bans ( expires TIMESTAMPTZ DEFAULT NULL, created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); - -INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$XcxAe19B1eWC15sfnDRyiuiNLZIhdL7PMTnTmtTfglJIz0zOpN3oa', true, '16383'::jsonb); diff --git a/src/cfg.rs b/src/cfg.rs index 382fb00..f049f72 100755 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -31,7 +31,9 @@ pub struct ServerCfg { pub struct SiteCfg { pub name: String, pub description: String, - pub default_noko: bool, + pub theme: String, + pub links: Vec>, + pub noko: bool, } #[derive(Deserialize, Debug, Clone)] @@ -73,4 +75,5 @@ pub struct BoardCfg { pub antispam_ip: i64, pub antispam_content: i64, pub antispam_both: i64, + pub thread_cooldown: i64, } diff --git a/src/db/cache.rs b/src/db/cache.rs index bf1a332..b84caff 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -68,13 +68,31 @@ pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> { for post in posts { let ip_key = format!("by_ip:{}", post.ip); - let content_key = format!("by_content:{}", digest(post.content_nomarkup)); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); let member = format!("{}/{}", post.board, post.id); let score = post.created.timestamp_micros(); ctx.cache().zadd(ip_key, &member, score).await?; ctx.cache().zadd(content_key, &member, score).await?; + + if post.thread.is_none() { + let key = format!("last_thread:{}", post.ip); + let last_thread = ctx + .cache() + .get::<_, Option>(&key) + .await? + .unwrap_or_default(); + + let timestamp = post.created.timestamp_micros(); + + if timestamp > last_thread { + ctx.cache().set(key, timestamp).await?; + } + } } } diff --git a/src/db/post.rs b/src/db/post.rs index ab078dc..49bf0a9 100755 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -78,13 +78,23 @@ impl Post { } let ip_key = format!("by_ip:{ip}"); - let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); + let member = format!("{}/{}", board.id, post.id); let score = post.created.timestamp_micros(); ctx.cache().zadd(ip_key, &member, score).await?; ctx.cache().zadd(content_key, &member, score).await?; + if thread.is_none() { + ctx.cache() + .set(format!("last_thread:{ip}"), post.created.timestamp_micros()) + .await?; + } + Ok(post) } @@ -116,13 +126,10 @@ impl Post { } pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result, NekrochanError> { - let post = query_as(&format!( - "SELECT * FROM posts_{} WHERE id = $1", - board - )) - .bind(id) - .fetch_optional(ctx.db()) - .await?; + let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board)) + .bind(id) + .fetch_optional(ctx.db()) + .await?; Ok(post) } @@ -329,8 +336,11 @@ impl Post { .fetch_one(ctx.db()) .await?; - let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes())); - let new_key = format!("by_content:{}", digest(content_nomarkup.as_bytes())); + let old_key = format!( + "by_content:{}", + digest(self.content_nomarkup.to_lowercase()) + ); + let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase())); let member = format!("{}/{}", self.board, self.id); let score = Utc::now().timestamp_micros(); @@ -423,7 +433,10 @@ impl Post { } let ip_key = format!("by_ip:{}", post.ip); - let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); + let content_key = format!( + "by_content:{}", + digest(post.content_nomarkup.to_lowercase()) + ); let member = format!("{}/{}", post.board, post.id); diff --git a/src/error.rs b/src/error.rs index 7f07c96..6ec536d 100755 --- a/src/error.rs +++ b/src/error.rs @@ -76,6 +76,8 @@ pub enum NekrochanError { OverboardError, #[error("Účet vlastníka nemůže být vymazán.")] OwnerDeletionError, + #[error("Stránka {} neexistuje", .0)] + PageNotFound(String), #[error("Heslo musí mít alespoň 8 znaků.")] PasswordFormatError, #[error("Jméno nesmí mít více než 32 znaků.")] @@ -249,6 +251,7 @@ impl ResponseError for NekrochanError { NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED, NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR, NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN, + NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND, NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST, NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST, NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND, diff --git a/src/lib.rs b/src/lib.rs index 10004b8..8d45eb7 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ use db::models::{Board, Post}; use error::NekrochanError; use web::tcx::TemplateCtx; -const GENERIC_PAGE_SIZE: i64 = 15; +const GENERIC_PAGE_SIZE: i64 = 10; pub mod auth; pub mod cfg; diff --git a/src/main.rs b/src/main.rs index b98dda2..5bba90a 100755 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ async fn run() -> Result<(), Error> { .service(web::news::news) .service(web::overboard::overboard) .service(web::overboard_catalog::overboard_catalog) + .service(web::page::page) .service(web::thread_json::thread_json) .service(web::thread::thread) .service(web::actions::appeal_ban::appeal_ban) diff --git a/src/web/actions/create_post.rs b/src/web/actions/create_post.rs index a86735b..d9253c0 100644 --- a/src/web/actions/create_post.rs +++ b/src/web/actions/create_post.rs @@ -65,7 +65,7 @@ pub async fn create_post( } let mut bump = true; - let mut noko = ctx.cfg.site.default_noko; + let mut noko = ctx.cfg.site.noko; let thread = match form.thread { Some(Text(thread)) => { @@ -141,11 +141,11 @@ pub async fn create_post( bump = false; } - if !ctx.cfg.site.default_noko && email_lower == "noko" { + if !ctx.cfg.site.noko && email_lower == "noko" { noko = true } - if ctx.cfg.site.default_noko { + if ctx.cfg.site.noko { if email_lower == "nonoko" { noko = false; } @@ -322,5 +322,14 @@ pub async fn check_spam( return Err(NekrochanError::FloodError); } + let last_thread: i64 = ctx.cache().get(format!("last_thread:{ip}")).await?; + + let since_last_thread = Utc::now().timestamp_micros() - last_thread; + let since_last_thread = Duration::microseconds(since_last_thread); + + if since_last_thread.num_seconds() < board.config.thread_cooldown { + return Err(NekrochanError::FloodError); + } + Ok(()) } diff --git a/src/web/mod.rs b/src/web/mod.rs index dcc8cbe..88586ad 100755 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -11,10 +11,11 @@ pub mod logout; pub mod news; pub mod overboard; pub mod overboard_catalog; -pub mod thread_json; +pub mod page; pub mod staff; pub mod tcx; pub mod thread; +pub mod thread_json; use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder}; use askama::Template; diff --git a/src/web/page.rs b/src/web/page.rs new file mode 100644 index 0000000..06d3f6b --- /dev/null +++ b/src/web/page.rs @@ -0,0 +1,36 @@ +use actix_web::{ + get, + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use askama::Template; +use tokio::fs::read_to_string; + +use crate::{ctx::Ctx, error::NekrochanError, web::template_response}; + +use super::tcx::TemplateCtx; + +#[derive(Template)] +#[template(path = "page.html")] +struct PageTemplate { + pub tcx: TemplateCtx, + pub name: String, + pub content: String, +} + +#[get("/page/{name}")] +pub async fn page( + ctx: Data, + req: HttpRequest, + name: Path, +) -> Result { + let tcx = TemplateCtx::new(&ctx, &req).await?; + let name = name.into_inner(); + let content = read_to_string(format!("./pages/{name}.html")) + .await + .map_err(|_| NekrochanError::PageNotFound(name.clone()))?; + + let template = PageTemplate { tcx, name, content }; + + template_response(&template) +} diff --git a/src/web/staff/actions/create_board.rs b/src/web/staff/actions/create_board.rs index 66381c5..d24ec98 100755 --- a/src/web/staff/actions/create_board.rs +++ b/src/web/staff/actions/create_board.rs @@ -8,7 +8,7 @@ use crate::{ }; lazy_static! { - static ref ID_REGEX: Regex = Regex::new(r#"^\w{1,16}$"#).unwrap(); + static ref ID_REGEX: Regex = Regex::new(r"^\w{1,16}$").unwrap(); } #[derive(Deserialize)] diff --git a/src/web/staff/actions/edit_news.rs b/src/web/staff/actions/edit_news.rs index dca31dc..a38ce56 100644 --- a/src/web/staff/actions/edit_news.rs +++ b/src/web/staff/actions/edit_news.rs @@ -57,7 +57,7 @@ pub async fn edit_news( newspost .update(&ctx, content, content_nomarkup.into()) .await?; - + news_edited += 1; } diff --git a/src/web/staff/actions/update_board_config.rs b/src/web/staff/actions/update_board_config.rs index f1cd630..bce3864 100755 --- a/src/web/staff/actions/update_board_config.rs +++ b/src/web/staff/actions/update_board_config.rs @@ -29,6 +29,7 @@ pub struct UpdateBoardConfigForm { antispam_ip: i64, antispam_content: i64, antispam_both: i64, + thread_cooldown: i64, } #[post("/staff/actions/update-board-config")] @@ -68,6 +69,7 @@ pub async fn update_board_config( let antispam_ip = form.antispam_ip; let antispam_content = form.antispam_content; let antispam_both = form.antispam_both; + let thread_cooldown = form.thread_cooldown; let config = BoardCfg { anon_name, @@ -90,6 +92,7 @@ pub async fn update_board_config( antispam_ip, antispam_content, antispam_both, + thread_cooldown, }; board.update_config(&ctx, config).await?; diff --git a/src/web/thread_json.rs b/src/web/thread_json.rs index 4846eaf..277022f 100644 --- a/src/web/thread_json.rs +++ b/src/web/thread_json.rs @@ -39,7 +39,7 @@ pub async fn thread_json( let mut res = HashMap::new(); let replies = thread.read_replies(&ctx).await?; - let posts = vec![vec![thread], replies].concat(); + let posts = [vec![thread], replies].concat(); for post in posts { let id = post.id; diff --git a/templates/base.html b/templates/base.html index abe570f..bf0b901 100755 --- a/templates/base.html +++ b/templates/base.html @@ -7,7 +7,7 @@ {% block title %}{% endblock %} - + @@ -26,8 +26,18 @@ nadnástěnka - novinky + novinky + {% for group in tcx.cfg.site.links %} + + {% for (link, href) in group %} + {{ link }} + {% if !loop.last %} + + {% endif %} + {% endfor %} + + {% endfor %} {% if tcx.account.is_some() %} diff --git a/templates/error.html b/templates/error.html index 10caf3d..c372fd7 100755 --- a/templates/error.html +++ b/templates/error.html @@ -4,7 +4,9 @@ Chyba - + {# Error pages are yotsuba (they just are, okay?) #} + {# Go ahead and edit this manually if you actually care #} + diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..beb6595 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}{{ tcx.cfg.site.name }} ({{ name }}){% endblock %} +{% block content %}{{ content|safe }}{% endblock %} diff --git a/templates/staff/board-config.html b/templates/staff/board-config.html index 7c3a243..6946050 100755 --- a/templates/staff/board-config.html +++ b/templates/staff/board-config.html @@ -174,6 +174,11 @@ + + Interval mezi vlákny (IP) + + + diff --git a/theme.txt b/theme.txt deleted file mode 100644 index 78512c0..0000000 --- a/theme.txt +++ /dev/null @@ -1 +0,0 @@ -yotsuba.css \ No newline at end of file