nekrochan/src/web/actions/create_post.rs

325 řádky
8.8 KiB
Rust

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<String>,
pub thread: Option<Text<i64>>,
#[multipart(rename = "post_name")]
pub name: Text<String>,
pub email: Text<String>,
pub content: Text<String>,
#[multipart(rename = "files[]")]
pub files: Vec<TempFile>,
pub spoiler_files: Option<Text<String>>,
#[multipart(rename = "post_password")]
pub password: Text<String>,
pub captcha_id: Option<Text<String>>,
pub captcha_solution: Option<Text<String>>,
}
#[post("/actions/create-post")]
pub async fn create_post(
ctx: Data<Ctx>,
req: HttpRequest,
MultipartForm(form): MultipartForm<PostForm>,
) -> Result<HttpResponse, NekrochanError> {
let perms = match account_from_auth_opt(&ctx, &req).await? {
Some(account) => account.perms(),
None => PermissionWrapper::new(0, false),
};
let (ip, country) = 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.default_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<String> = 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.default_noko && email_lower == "noko" {
noko = true
}
if ctx.cfg.site.default_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 content_nomarkup = form.content.0.trim().to_owned();
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 = markup(
&ctx,
&perms,
Some(board.id.clone()),
thread.as_ref().map(|t| t.id),
&content_nomarkup,
)
.await?;
if board.config.antispam {
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
}
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);
}
if content_nomarkup.is_empty() && files.is_empty() {
return Err(NekrochanError::EmptyPostError);
}
let password_raw = form.password.trim();
if password_raw.len() < 8 {
return Err(NekrochanError::PasswordFormatError);
}
let password = hash(password_raw)?;
let thread_id = thread.as_ref().map(|t| t.id);
let post = Post::create(
&ctx,
&board,
thread_id,
name,
tripcode,
capcode,
email,
content,
content_nomarkup,
files,
password,
country,
ip,
bump,
)
.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();
res.cookie(name_cookie);
res.cookie(password_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<String> = ctx
.cache()
.zrangebyscore(&ip_key, antispam_ip, "+inf")
.await?;
let content_posts: HashSet<String> = ctx
.cache()
.zrangebyscore(&content_key, antispam_content, "+inf")
.await?;
let ip_posts2: HashSet<String> = ctx
.cache()
.zrangebyscore(&ip_key, antispam_both, "+inf")
.await?;
let content_posts2: HashSet<String> = 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);
}
Ok(())
}