use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm}; use actix_web::{ cookie::Cookie, http::StatusCode, post, web::Data, HttpRequest, HttpResponse, HttpResponseBuilder, }; use chrono::{Duration, Utc}; use pwhash::bcrypt::hash; use redis::AsyncCommands; use sha256::digest; use std::{collections::HashSet, net::IpAddr}; use crate::{ ctx::Ctx, db::models::{Ban, Board, File, Post}, error::NekrochanError, markup::{markup, parse_name}, perms::PermissionWrapper, web::{ ban_response, tcx::{account_from_auth_opt, ip_from_req}, }, }; #[derive(MultipartForm)] pub struct PostForm { pub board: Text, pub thread: Option>, #[multipart(rename = "post_name")] pub name: Text, pub email: Text, pub content: Text, #[multipart(rename = "files[]")] pub files: Vec, pub spoiler_files: Option>, #[multipart(rename = "post_password")] pub password: Text, pub captcha_id: Option>, pub captcha_solution: Option>, } #[post("/actions/create-post")] pub async fn create_post( ctx: Data, req: HttpRequest, MultipartForm(form): MultipartForm, ) -> Result { let perms = match account_from_auth_opt(&ctx, &req).await? { Some(account) => account.perms(), None => PermissionWrapper::new(0, false), }; let (ip, country, region) = ip_from_req(&req)?; let board = form.board.0; let board = Board::read(&ctx, board.clone()) .await? .ok_or(NekrochanError::BoardNotFound(board))?; if let Some(ban) = Ban::read(&ctx, board.id.clone(), ip).await? { if !(perms.owner() || perms.bypass_bans()) { return ban_response(&ctx, &req, ban).await; } } if board.config.0.locked && !(perms.owner() || perms.bypass_board_lock()) { return Err(NekrochanError::BoardLockError(board.id.clone())); } let mut bump = true; let mut noko = ctx.cfg.site.noko; let thread = match form.thread { Some(Text(thread)) => { let thread = Post::read(&ctx, board.id.clone(), thread) .await? .ok_or(NekrochanError::PostNotFound(board.id.clone(), thread))?; if thread.thread.is_some() { return Err(NekrochanError::IsReplyError); } if thread.locked && !(perms.owner() || perms.bypass_thread_lock()) { return Err(NekrochanError::ThreadLockError); } if thread.replies >= board.config.0.reply_limit { return Err(NekrochanError::ReplyLimitError); } if thread.bumps >= board.config.0.bump_limit { bump = false; } Some(thread) } None => None, }; if !(perms.owner() || perms.bypass_captcha()) && ((thread.is_none() && board.config.0.thread_captcha != "off") || (thread.is_some() && board.config.0.reply_captcha != "off")) { let board = board.id.clone(); let id = form .captcha_id .ok_or(NekrochanError::RequiredCaptchaError)? .0; if id.is_empty() { return Err(NekrochanError::RequiredCaptchaError); } let key = format!("captcha:{board}:{id}"); let solution = form .captcha_solution .ok_or(NekrochanError::RequiredCaptchaError)?; let actual_solution: Option = ctx.cache().get_del(key).await?; let actual_solution = actual_solution.ok_or(NekrochanError::InvalidCaptchaError)?; if solution.trim() != actual_solution { return Err(NekrochanError::IncorrectCaptchaError); } } let name_raw = form.name.trim(); let (name, tripcode, capcode) = parse_name(&ctx, &perms, &board.config.0.anon_name, name_raw)?; let email_raw = form.email.trim(); let email = if email_raw.is_empty() { None } else { if email_raw.len() > 256 { return Err(NekrochanError::EmailFormatError); } let email_lower = email_raw.to_lowercase(); if email_lower == "sage" { bump = false; } if !ctx.cfg.site.noko && email_lower == "noko" { noko = true } if ctx.cfg.site.noko { if email_lower == "nonoko" { noko = false; } if email_lower == "nonokosage" { noko = false; bump = false; } } else { if email_lower == "noko" { noko = true; } if email_lower == "nokosage" { noko = true; bump = false; } } Some(email_raw.into()) }; let password_raw = form.password.trim(); if password_raw.len() < 8 { return Err(NekrochanError::PasswordFormatError); } let password = hash(password_raw)?; if form.files.len() > board.config.0.file_limit { return Err(NekrochanError::FileLimitError(board.config.0.file_limit)); } let mut files = Vec::new(); for file in form.files { if file.size == 0 { continue; } let spoiler = form.spoiler_files.is_some(); let file = File::new(&ctx.cfg, file, spoiler, true).await?; files.push(file); } let thread_id = thread.as_ref().map(|t| t.id); let content_nomarkup = form.content.0.trim().to_owned(); if board.config.antispam && !(perms.owner() || perms.bypass_antispam()) { check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?; } if content_nomarkup.is_empty() && files.is_empty() { return Err(NekrochanError::EmptyPostError); } if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content) || (thread.is_some() && board.config.0.require_reply_content) { return Err(NekrochanError::NoContentError); } if content_nomarkup.len() > 10000 { return Err(NekrochanError::ContentFormatError); } let (content, quoted_posts) = markup( &ctx, &perms, Some(board.id.clone()), thread.as_ref().map(|t| t.id), &content_nomarkup, ) .await?; let post = Post::create( &ctx, &board, thread_id, name, tripcode, capcode, email, content, content_nomarkup, files, password, country, region, ip, bump, ) .await?; for quoted_post in quoted_posts { quoted_post.update_quotes(&ctx, post.id).await?; } let ts = thread.as_ref().map_or_else( || post.created.timestamp_micros(), |thread| thread.created.timestamp_micros(), ); let hash_input = format!("{}:{}:{}", ip, ts, ctx.cfg.secrets.user_id); let user_hash = digest(hash_input); let user_id = user_hash[..6].to_owned(); post.update_user_id(&ctx, user_id).await?; let mut res = HttpResponseBuilder::new(StatusCode::SEE_OTHER); let name_cookie = Cookie::build("name", name_raw).path("/").finish(); let password_cookie = Cookie::build("password", password_raw).path("/").finish(); let email_cookie = Cookie::build("email", email_raw).path("/").finish(); res.cookie(name_cookie); res.cookie(password_cookie); res.cookie(email_cookie); let res = if noko { res.append_header(("Location", post.post_url().as_str())) .finish() } else { res.append_header(("Location", format!("/boards/{}", post.board).as_str())) .finish() }; Ok(res) } pub async fn check_spam( ctx: &Ctx, board: &Board, ip: IpAddr, content_nomarkup: String, ) -> Result<(), NekrochanError> { let ip_key = format!("by_ip:{ip}"); let content_key = format!("by_content:{}", digest(content_nomarkup)); let antispam_ip = (Utc::now() - Duration::seconds(board.config.antispam_ip)).timestamp_micros(); let antispam_content = (Utc::now() - Duration::seconds(board.config.antispam_content)).timestamp_micros(); let antispam_both = (Utc::now() - Duration::seconds(board.config.antispam_both)).timestamp_micros(); let ip_posts: HashSet = ctx .cache() .zrangebyscore(&ip_key, antispam_ip, "+inf") .await?; let content_posts: HashSet = ctx .cache() .zrangebyscore(&content_key, antispam_content, "+inf") .await?; let ip_posts2: HashSet = ctx .cache() .zrangebyscore(&ip_key, antispam_both, "+inf") .await?; let content_posts2: HashSet = ctx .cache() .zrangebyscore(&content_key, antispam_both, "+inf") .await?; let both_posts = ip_posts2.intersection(&content_posts2); if !ip_posts.is_empty() { return Err(NekrochanError::FloodError); } if !content_posts.is_empty() { return Err(NekrochanError::FloodError); } if both_posts.count() != 0 { return Err(NekrochanError::FloodError); } let last_thread: Option = ctx.cache().get(format!("last_thread:{ip}")).await?; if let Some(last_thread) = last_thread { 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(()) }