Nahrát existující kód

Tento commit je obsažen v:
sneedmaster 2023-12-11 16:18:43 +01:00
revize 293dbb5ad1
341 změnil soubory, kde provedl 10256 přidání a 0 odebrání

3354
Cargo.lock vygenerováno Spustitelný soubor

Rozdílový obsah nebyl zobrazen, protože je příliš veliký Načíst rozdílové porovnání

50
Cargo.toml Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,50 @@
[package]
name = "nekrochan"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-files = "0.6.2"
actix-multipart = "0.6.0"
actix-web = { version = "4.3.1", features = ["cookies"] }
askama = "0.12.0"
anyhow = "1.0.71"
captcha = "0.0.9"
chrono = { version = "0.4.31", features = ["serde", "unstable-locales"] }
dotenv = "0.15.0"
enumflags2 = "0.7.7"
encoding = "0.2.33"
env_logger = "0.10.0"
fancy-regex = "0.12.0"
glob = "0.3.1"
image = "0.24.7"
ipnetwork = "0.20.0"
jsonwebtoken = "9.1.0"
lazy_static = "1.4.0"
log = "0.4.19"
num-traits = "0.2.16"
pwhash = "1.0.0"
rand = "0.8.5"
redis = { version = "0.24.0", features = ["aio", "json", "tokio-comp"] }
serde = "1.0.166"
serde_json = "1.0.100"
serde_qs = "0.12.0"
sha256 = "1.1.4"
sqlx = { version = "0.7.0", features = [
"runtime-tokio",
"postgres",
"json",
"chrono",
"ipnetwork",
] }
thiserror = "1.0.41"
tokio = { version = "1.29.1", features = ["rt-multi-thread", "macros"] }
toml = "0.8.6"
[build-dependencies]
anyhow = "1.0.74"
glob = "0.3.1"
html-minifier = "4.0.0"
[profile.dev]
opt-level = 1

2
askama.toml Normální soubor
Zobrazit soubor

@ -0,0 +1,2 @@
[general]
dirs = ["templates_min"]

37
build.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,37 @@
use anyhow::Error;
use glob::glob;
use html_minifier::minify;
use std::{
fs::{read_to_string, File},
io::Write,
process::Command,
};
fn main() -> Result<(), Error> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=templates");
Command::new("rm").args(["-rf", "templates_min"]).output()?;
Command::new("cp")
.args(["-r", "templates", "templates_min"])
.output()?;
let templates = glob("templates_min/**/*.html")?;
for path in templates {
let path = path?;
if !path.is_file() {
continue;
}
let html = read_to_string(&path)?;
let minified = minify(html)?.replace('\n', "");
File::create(path)?.write_all(minified.as_bytes())?;
}
Ok(())
}

Zobrazit soubor

@ -0,0 +1 @@
DROP TABLE accounts, boards, bans;

Zobrazit soubor

@ -0,0 +1,30 @@
CREATE TABLE accounts (
username VARCHAR(32) NOT NULL PRIMARY KEY,
password VARCHAR(64) NOT NULL,
owner BOOLEAN NOT NULL DEFAULT false,
permissions JSONB NOT NULL DEFAULT '0'::jsonb,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE boards (
id VARCHAR(16) NOT NULL PRIMARY KEY,
name VARCHAR(32) NOT NULL,
description VARCHAR(128) NOT NULL,
banners JSONB NOT NULL,
config JSONB NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE bans (
id SERIAL NOT NULL PRIMARY KEY,
ip_range INET NOT NULL,
reason TEXT NOT NULL,
board VARCHAR(16) DEFAULT NULL REFERENCES boards(id),
issued_by VARCHAR(32) NOT NULL REFERENCES accounts(username),
appealable BOOLEAN NOT NULL DEFAULT true,
appeal TEXT DEFAULT NULL,
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);

39
src/auth.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,39 @@
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::{ctx::Ctx, error::NekrochanError};
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
}
impl Claims {
pub fn new(sub: String) -> Self {
Self { sub }
}
pub fn encode(&self, ctx: &Ctx) -> Result<String, NekrochanError> {
let header = Header::default();
let key = EncodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes());
let auth = encode(&header, &self, &key)?;
Ok(auth)
}
pub fn decode(ctx: &Ctx, auth: &str) -> Result<Self, NekrochanError> {
let key = DecodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes());
let mut validation = Validation::default();
validation.required_spec_claims = HashSet::from_iter(["sub".to_owned()]);
validation.validate_exp = false;
let claims = decode(auth, &key, &validation)
.map_err(|_| NekrochanError::InvalidAuthError)?
.claims;
Ok(claims)
}
}

71
src/cfg.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,71 @@
use anyhow::Error;
use serde::{Deserialize, Serialize};
use tokio::fs::read_to_string;
#[derive(Deserialize, Clone)]
pub struct Cfg {
pub server: ServerCfg,
pub site: SiteCfg,
pub secrets: SecretsCfg,
pub files: FilesCfg,
pub board_defaults: BoardCfg,
}
impl Cfg {
pub async fn load(path: &str) -> Result<Self, Error> {
let cfg_string = read_to_string(path).await?;
let cfg: Cfg = toml::from_str(&cfg_string)?;
Ok(cfg)
}
}
#[derive(Deserialize, Clone)]
pub struct ServerCfg {
pub port: u16,
pub database_url: String,
pub cache_url: String,
}
#[derive(Deserialize, Clone)]
pub struct SiteCfg {
pub name: String,
pub description: String,
pub site_banner: Option<String>,
}
#[derive(Deserialize, Clone)]
pub struct SecretsCfg {
pub auth_token: String,
pub secure_trip: String,
pub user_id: String,
}
#[derive(Deserialize, Clone)]
pub struct FilesCfg {
pub videos: bool,
pub thumb_size: u32,
pub max_size_mb: usize,
pub max_height: u32,
pub max_width: u32,
pub cleanup_interval: u64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct BoardCfg {
pub anon_name: String,
pub page_size: i64,
pub page_count: i64,
pub file_limit: usize,
pub bump_limit: i32,
pub reply_limit: i32,
pub locked: bool,
pub user_ids: bool,
pub flags: bool,
pub thread_captcha: String,
pub reply_captcha: String,
pub require_thread_content: bool,
pub require_thread_file: bool,
pub require_reply_content: bool,
pub require_reply_file: bool,
}

36
src/ctx.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,36 @@
use anyhow::Error;
use redis::{aio::MultiplexedConnection, Client};
use sqlx::PgPool;
use std::net::SocketAddr;
use crate::cfg::Cfg;
#[derive(Clone)]
pub struct Ctx {
pub cfg: Cfg,
db: PgPool,
cache: MultiplexedConnection,
}
impl Ctx {
pub async fn new(cfg: Cfg) -> Result<Self, Error> {
let db = PgPool::connect(&cfg.server.database_url).await?;
let cache = Client::open(cfg.server.cache_url.as_str())?
.get_multiplexed_async_connection()
.await?;
Ok(Self { cfg, db, cache })
}
pub fn bind_addr(&self) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], self.cfg.server.port))
}
pub fn db(&self) -> &PgPool {
&self.db
}
pub fn cache(&self) -> MultiplexedConnection {
self.cache.clone()
}
}

117
src/db/account.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,117 @@
use redis::{AsyncCommands, JsonAsyncCommands};
use sqlx::{query, query_as, types::Json};
use super::models::Account;
use crate::{ctx::Ctx, error::NekrochanError, perms::PermissionWrapper};
impl Account {
pub async fn create(
ctx: &Ctx,
username: String,
password: String,
) -> Result<Self, NekrochanError> {
let account =
query_as("INSERT INTO accounts (username, password) VALUES ($1, $2) RETURNING *")
.bind(&username)
.bind(password)
.fetch_one(ctx.db())
.await?;
ctx.cache()
.json_set(format!("accounts:{}", username), ".", &account)
.await?;
Ok(account)
}
pub async fn read(ctx: &Ctx, username: String) -> Result<Option<Self>, NekrochanError> {
let account: Option<String> = ctx
.cache()
.json_get(format!("accounts:{}", username), ".")
.await?;
let account = match account {
Some(json) => Some(serde_json::from_str(&json)?),
None => None,
};
Ok(account)
}
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let accounts = query_as("SELECT * FROM accounts ORDER BY owner DESC, created DESC")
.fetch_all(ctx.db())
.await?;
Ok(accounts)
}
pub async fn update_password(&self, ctx: &Ctx, password: String) -> Result<(), NekrochanError> {
query("UPDATE accounts SET password = $1 WHERE username = $2")
.bind(&password)
.bind(&self.username)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("accounts:{}", self.username), "password", &password)
.await?;
Ok(())
}
pub async fn update_permissions(
&self,
ctx: &Ctx,
permissions: u64,
) -> Result<(), NekrochanError> {
query("UPDATE accounts SET permissions = $1 WHERE username = $2")
.bind(Json(permissions))
.bind(&self.username)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(
format!("accounts:{}", self.username),
"permissions",
&permissions,
)
.await?;
Ok(())
}
pub async fn update_owner(&self, ctx: &Ctx, owner: bool) -> Result<(), NekrochanError> {
query("UPDATE accounts SET owner = $1 WHERE username = $2")
.bind(owner)
.bind(&self.username)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("accounts:{}", self.username), "owner", &owner)
.await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query("DELETE FROM accounts WHERE username = $1")
.bind(&self.username)
.execute(ctx.db())
.await?;
ctx.cache()
.del(format!("accounts:{}", self.username))
.await?;
Ok(())
}
}
impl Account {
pub fn perms(&self) -> PermissionWrapper {
PermissionWrapper::new(self.permissions.0, self.owner)
}
}

107
src/db/ban.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,107 @@
use chrono::{DateTime, Utc};
use ipnetwork::IpNetwork;
use sqlx::{query, query_as};
use std::{collections::HashMap, net::IpAddr};
use super::models::Ban;
use crate::{ctx::Ctx, error::NekrochanError};
impl Ban {
pub async fn create(
ctx: &Ctx,
account: String,
board: Option<String>,
ip_range: IpNetwork,
reason: String,
appealable: bool,
expires: Option<DateTime<Utc>>,
) -> Result<Self, NekrochanError> {
let ban = query_as("INSERT INTO bans (ip_range, reason, board, issued_by, appealable, expires) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *")
.bind(ip_range)
.bind(reason)
.bind(board)
.bind(account)
.bind(appealable)
.bind(expires)
.fetch_one(ctx.db())
.await?;
Ok(ban)
}
pub async fn read(ctx: &Ctx, board: String, ip: IpAddr) -> Result<Option<Ban>, NekrochanError> {
let ban = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND (board = $1 OR board IS NULL) AND (ip_range >> $2 OR ip_range = $2)")
.bind(board)
.bind(ip)
.fetch_optional(ctx.db())
.await?;
Ok(ban)
}
pub async fn read_global(ctx: &Ctx, ip: IpNetwork) -> Result<Option<Ban>, NekrochanError> {
let ban = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND board IS NULL AND (ip_range >> $1 OR ip_range = $1)")
.bind(ip)
.fetch_optional(ctx.db())
.await?;
Ok(ban)
}
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Ban>, NekrochanError> {
let bans =
query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) ORDER BY created DESC")
.fetch_all(ctx.db())
.await?;
Ok(bans)
}
pub async fn read_by_id(ctx: &Ctx, id: i32) -> Result<Option<Ban>, NekrochanError> {
let ban = query_as("SELECT * FROM bans WHERE id = $1")
.bind(id)
.fetch_optional(ctx.db())
.await?;
Ok(ban)
}
pub async fn read_by_ip(
ctx: &Ctx,
ip: IpAddr,
) -> Result<HashMap<Option<String>, Ban>, NekrochanError> {
let bans: Vec<Ban> = query_as("SELECT * FROM bans WHERE (expires > CURRENT_TIMESTAMP OR expires IS NULL) AND (ip_range >> $1 OR ip_range = $1)")
.bind(ip)
.fetch_all(ctx.db())
.await?;
let mut ban_map = HashMap::new();
for ban in bans.into_iter() {
let board = ban.board.clone();
ban_map.insert(board, ban);
}
Ok(ban_map)
}
pub async fn update_appeal(&self, ctx: &Ctx, appeal: String) -> Result<(), NekrochanError> {
query("UPDATE bans SET appeal = $1 WHERE id = $2")
.bind(appeal)
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query("DELETE FROM bans WHERE id = $1")
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
}

281
src/db/board.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,281 @@
use captcha::{gen, Difficulty};
use rand::{seq::SliceRandom, thread_rng};
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
use sha256::digest;
use sqlx::{query, query_as, types::Json};
use std::collections::HashMap;
use super::models::{Board, File};
use crate::{cfg::BoardCfg, ctx::Ctx, error::NekrochanError, CAPTCHA};
impl Board {
pub async fn create(
ctx: &Ctx,
id: String,
name: String,
description: String,
) -> Result<Self, NekrochanError> {
let banners = Json(Vec::<File>::new());
let config = Json(ctx.cfg.board_defaults.clone());
let board: Board = query_as("INSERT INTO boards (id, name, description, banners, config) VALUES ($1, $2, $3, $4, $5) RETURNING *")
.bind(id)
.bind(name)
.bind(description)
.bind(banners)
.bind(config)
.fetch_one(ctx.db())
.await?;
query(&format!(
r#"CREATE TABLE posts_{} (
id SERIAL NOT NULL PRIMARY KEY,
board VARCHAR(16) NOT NULL DEFAULT '{}' REFERENCES boards(id),
thread INT DEFAULT NULL REFERENCES posts_{}(id),
name VARCHAR(32) NOT NULL,
user_id VARCHAR(6) NOT NULL DEFAULT '000000',
tripcode VARCHAR(12) DEFAULT NULL,
capcode VARCHAR(32) DEFAULT NULL,
email VARCHAR(256) DEFAULT NULL,
content TEXT NOT NULL,
content_nomarkup TEXT NOT NULL,
files JSONB NOT NULL,
password VARCHAR(64) DEFAULT NULL,
country VARCHAR(2) NOT NULL,
ip INET NOT NULL,
bumps INT NOT NULL DEFAULT 0,
replies INT NOT NULL DEFAULT 0,
sticky BOOLEAN NOT NULL DEFAULT false,
locked BOOLEAN NOT NULL DEFAULT false,
reported TIMESTAMPTZ DEFAULT NULL,
reports JSONB NOT NULL DEFAULT '[]'::json,
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)"#,
board.id, board.id, board.id
))
.execute(ctx.db())
.await?;
ctx.cache()
.set(format!("board_threads:{}", board.id), 0)
.await?;
ctx.cache().lpush("board_ids", &board.id).await?;
ctx.cache()
.json_set(format!("boards:{}", board.id), ".", &board)
.await?;
cmd("SORT")
.arg("board_ids")
.arg("ALPHA")
.arg("STORE")
.arg("board_ids")
.query_async(&mut ctx.cache())
.await?;
update_overboard(ctx, Self::read_all(ctx).await?).await?;
Ok(board)
}
pub async fn read(ctx: &Ctx, id: String) -> Result<Option<Self>, NekrochanError> {
let board: Option<String> = ctx.cache().json_get(format!("boards:{}", id), ".").await?;
let board = match board {
Some(json) => Some(serde_json::from_str(&json)?),
None => None,
};
Ok(board)
}
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let mut boards = Vec::new();
let ids: Vec<String> = ctx.cache().lrange("board_ids", 0, -1).await?;
for id in ids.into_iter() {
if let Some(board) = Self::read(ctx, id).await? {
boards.push(board);
}
}
Ok(boards)
}
pub async fn read_all_map(ctx: &Ctx) -> Result<HashMap<String, Self>, NekrochanError> {
let mut boards = HashMap::new();
let ids: Vec<String> = ctx.cache().lrange("board_ids", 0, -1).await?;
for id in ids.into_iter() {
if let Some(board) = Self::read(ctx, id.clone()).await? {
boards.insert(id, board);
}
}
Ok(boards)
}
pub async fn update_name(&self, ctx: &Ctx, name: String) -> Result<(), NekrochanError> {
query("UPDATE boards SET name = $1 WHERE id = $2")
.bind(&name)
.bind(&self.id)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("boards:{}", self.id), "name", &name)
.await?;
Ok(())
}
pub async fn update_description(
&self,
ctx: &Ctx,
description: String,
) -> Result<(), NekrochanError> {
query("UPDATE boards SET description = $1 WHERE id = $2")
.bind(&description)
.bind(&self.id)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("boards:{}", self.id), "description", &description)
.await?;
Ok(())
}
pub async fn update_banners(
&self,
ctx: &Ctx,
banners: Vec<File>,
) -> Result<(), NekrochanError> {
query("UPDATE boards SET banners = $1 WHERE id = $2")
.bind(Json(&banners))
.bind(&self.id)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("boards:{}", self.id), "banners", &banners)
.await?;
Ok(())
}
pub async fn update_config(&self, ctx: &Ctx, config: BoardCfg) -> Result<(), NekrochanError> {
query("UPDATE boards SET config = $1 WHERE id = $2")
.bind(Json(&config))
.bind(&self.id)
.execute(ctx.db())
.await?;
ctx.cache()
.json_set(format!("boards:{}", self.id), "config", &config)
.await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let boards = Self::read_all(ctx)
.await?
.into_iter()
.filter(|board| board.id != self.id)
.collect();
update_overboard(ctx, boards).await?;
query(&format!("DROP TABLE posts_{}", self.id))
.execute(ctx.db())
.await?;
query("DELETE FROM boards WHERE id = $1")
.bind(&self.id)
.execute(ctx.db())
.await?;
ctx.cache().del(format!("boards:{}", self.id)).await?;
ctx.cache().lrem("board_ids", 0, &self.id).await?;
Ok(())
}
}
impl Board {
pub fn random_banner(&self) -> Option<File> {
self.banners
.choose(&mut thread_rng())
.map(|banner| banner.to_owned())
}
pub fn thread_captcha(&self) -> Option<(String, String)> {
let captcha = match self.config.thread_captcha.as_str() {
"easy" => gen(Difficulty::Easy),
"medium" => gen(Difficulty::Medium),
"hard" => gen(Difficulty::Hard),
_ => return None,
};
let base64 = captcha.as_base64()?;
let board = self.id.clone();
let difficulty = self.config.thread_captcha.clone();
let id = digest(base64.as_bytes());
let key = (board, difficulty, id.clone());
let solution = captcha.chars_as_string();
CAPTCHA.write().ok()?.insert(key, solution);
Some((id, base64))
}
pub fn reply_captcha(&self) -> Option<(String, String)> {
let captcha = match self.config.reply_captcha.as_str() {
"easy" => gen(Difficulty::Easy),
"medium" => gen(Difficulty::Medium),
"hard" => gen(Difficulty::Hard),
_ => return None,
};
let base64 = captcha.as_base64()?;
let board = self.id.clone();
let difficulty = self.config.thread_captcha.clone();
let id = digest(base64.as_bytes());
let key = (board, difficulty, id.clone());
let solution = captcha.chars_as_string();
CAPTCHA.write().ok()?.insert(key, solution);
Some((id, base64))
}
}
async fn update_overboard(ctx: &Ctx, boards: Vec<Board>) -> Result<(), NekrochanError> {
query("DROP VIEW IF EXISTS overboard")
.execute(ctx.db())
.await?;
if boards.is_empty() {
return Ok(());
}
let unions = boards
.into_iter()
.map(|board| format!("SELECT * FROM posts_{}", board.id))
.collect::<Vec<_>>()
.join(" UNION ");
query(&format!("CREATE VIEW overboard AS {unions}"))
.execute(ctx.db())
.await?;
Ok(())
}

72
src/db/cache.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,72 @@
use anyhow::Error;
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
use sha256::digest;
use sqlx::query_as;
use super::models::{Account, Board, Post};
use crate::ctx::Ctx;
pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> {
cmd("FLUSHDB").query_async(&mut ctx.cache()).await?;
let accounts: Vec<Account> = query_as("SELECT * FROM accounts")
.fetch_all(ctx.db())
.await?;
for account in accounts.iter() {
ctx.cache()
.json_set(format!("accounts:{}", account.username), ".", &account)
.await?;
}
let boards: Vec<Board> = query_as("SELECT * FROM boards").fetch_all(ctx.db()).await?;
for board in boards.iter() {
ctx.cache()
.json_set(format!("boards:{}", board.id), ".", board)
.await?;
ctx.cache().lpush("board_ids", &board.id).await?;
}
cmd("SORT")
.arg("board_ids")
.arg("ALPHA")
.arg("STORE")
.arg("board_ids")
.query_async(&mut ctx.cache())
.await?;
ctx.cache().set("total_threads", 0).await?;
for board in boards.iter() {
let (thread_count,): (i64,) = query_as(&format!(
"SELECT COUNT(id) FROM posts_{} WHERE thread IS NULL",
board.id
))
.fetch_one(ctx.db())
.await?;
ctx.cache().incr("total_threads", thread_count).await?;
ctx.cache()
.set(format!("board_threads:{}", board.id), thread_count)
.await?;
}
for board in boards.iter() {
let posts = Post::read_all(ctx, board.id.clone()).await?;
for post in posts.into_iter() {
let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup));
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?;
}
}
Ok(())
}

7
src/db/mod.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,7 @@
pub mod cache;
pub mod models;
mod account;
mod ban;
mod board;
mod post;

92
src/db/models.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,92 @@
use std::net::IpAddr;
use chrono::{DateTime, Utc};
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow};
use crate::cfg::BoardCfg;
#[derive(FromRow, Clone, Serialize, Deserialize)]
pub struct Account {
pub username: String,
pub password: String,
pub owner: bool,
pub permissions: Json<u64>,
pub created: DateTime<Utc>,
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct Board {
pub id: String,
pub name: String,
pub description: String,
pub banners: Json<Vec<File>>,
pub config: Json<BoardCfg>,
pub created: DateTime<Utc>,
}
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
pub struct Ban {
pub id: i32,
pub ip_range: IpNetwork,
pub reason: String,
pub board: Option<String>,
pub issued_by: String,
pub appealable: bool,
pub appeal: Option<String>,
pub expires: Option<DateTime<Utc>>,
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Report {
pub reason: String,
pub reporter_country: String,
pub reporter_ip: IpAddr,
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct LogRecord {
pub id: i32,
pub message: String,
pub created: DateTime<Utc>,
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct Post {
pub id: i32,
pub board: String,
pub thread: Option<i32>,
pub name: String,
pub user_id: String,
pub tripcode: Option<String>,
pub capcode: Option<String>,
pub email: Option<String>,
pub content: String,
pub content_nomarkup: String,
pub files: Json<Vec<File>>,
pub password: String,
pub country: String,
pub ip: IpAddr,
pub bumps: i32,
pub replies: i32,
pub sticky: bool,
pub locked: bool,
pub reported: Option<DateTime<Utc>>,
pub reports: Json<Vec<Report>>,
pub bumped: DateTime<Utc>,
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct File {
pub original_name: String,
pub format: String,
pub thumb_format: Option<String>,
pub spoiler: bool,
pub width: u32,
pub height: u32,
pub timestamp: i64,
pub size: usize,
}

451
src/db/post.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,451 @@
use chrono::Utc;
use redis::AsyncCommands;
use sha256::digest;
use sqlx::{query, query_as, types::Json};
use std::net::IpAddr;
use super::models::{Board, File, Post, Report};
use crate::{ctx::Ctx, error::NekrochanError};
impl Post {
#[allow(clippy::too_many_arguments)]
pub async fn create(
ctx: &Ctx,
board: &Board,
thread: Option<i32>,
name: String,
tripcode: Option<String>,
capcode: Option<String>,
email: Option<String>,
content: String,
content_nomarkup: String,
files: Vec<File>,
password: String,
country: String,
ip: IpAddr,
bumpy_bump: bool,
) -> Result<Self, NekrochanError> {
let post: Post = query_as(&format!(
r#"INSERT INTO posts_{}
(thread, name, tripcode, capcode, email, content, content_nomarkup, files, password, country, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"#, board.id)
)
.bind(thread)
.bind(name)
.bind(tripcode)
.bind(capcode)
.bind(email)
.bind(content)
.bind(content_nomarkup)
.bind(Json(files))
.bind(password)
.bind(country)
.bind(ip)
.fetch_one(ctx.db())
.await?;
if let Some(thread) = thread {
query(&format!(
"UPDATE posts_{} SET replies = replies + 1 WHERE id = $1",
board.id
))
.bind(thread)
.execute(ctx.db())
.await?;
if bumpy_bump {
query(&format!(
"UPDATE posts_{} SET bumps = bumps + 1, bumped = CURRENT_TIMESTAMP WHERE id = $1",
board.id
))
.bind(thread)
.execute(ctx.db())
.await?;
}
} else {
delete_old_threads(ctx, board).await?;
ctx.cache().incr("total_threads", 1).await?;
ctx.cache()
.incr(format!("board_threads:{}", board.id), 1)
.await?;
}
let ip_key = format!("by_ip:{}", ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
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?;
Ok(post)
}
pub async fn create_report(
&self,
ctx: &Ctx,
reason: String,
reporter_country: String,
reporter_ip: IpAddr,
) -> Result<(), NekrochanError> {
let mut reports = self.reports.clone();
reports.push(Report {
reason,
reporter_country,
reporter_ip,
});
query(&format!(
"UPDATE posts_{} SET reported = CURRENT_TIMESTAMP, reports = $1 WHERE id = $2",
self.board
))
.bind(reports)
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn read(ctx: &Ctx, board: String, id: i32) -> Result<Option<Self>, NekrochanError> {
let post = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
.bind(board)
.bind(id)
.fetch_optional(ctx.db())
.await?;
Ok(post)
}
pub async fn read_board_page(
ctx: &Ctx,
board: &Board,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
r#"SELECT * FROM posts_{}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC
LIMIT $1
OFFSET $2"#,
board.id
))
.bind(board.config.0.page_size)
.bind((page - 1) * board.config.0.page_size)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_board_catalog(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
r#"SELECT * FROM posts_{}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC"#,
board
))
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_overboard_page(ctx: &Ctx, page: i64) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE thread IS NULL
ORDER BY bumped DESC
LIMIT $1
OFFSET $2"#,
)
.bind(15)
.bind((page - 1) * 15)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_overboard_catalog(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE thread IS NULL
ORDER BY bumped DESC"#,
)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_latest(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
ORDER BY created DESC
LIMIT $1"#,
)
.bind(15)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_reports(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE reports != '[]'::jsonb
ORDER BY jsonb_array_length(reports), reported DESC"#,
)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_files(ctx: &Ctx) -> Result<Vec<Post>, NekrochanError> {
let posts = query_as(
r#"SELECT *
FROM overboard
WHERE files != '[]'::jsonb
ORDER BY created DESC
LIMIT 3"#,
)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_replies(&self, ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let replies = query_as(&format!(
"SELECT * FROM posts_{} WHERE thread = $1 ORDER BY created ASC",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
Ok(replies)
}
pub async fn read_all(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!("SELECT * FROM posts_{}", board))
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET user_id = $1 WHERE id = $2",
self.board,
))
.bind(user_id)
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn update_content(
&self,
ctx: &Ctx,
content: String,
content_nomarkup: String,
) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3",
self.board
))
.bind(content)
.bind(&content_nomarkup)
.bind(self.id)
.execute(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 member = format!("{}/{}", self.board, self.id);
let score = Utc::now().timestamp_micros();
ctx.cache().zrem(old_key, &member).await?;
ctx.cache().zadd(new_key, &member, score).await?;
Ok(())
}
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let mut files = self.files.clone();
for file in files.iter_mut() {
file.spoiler = !file.spoiler;
}
query(&format!(
"UPDATE posts_{} SET files = $1 WHERE id = $2",
self.board
))
.bind(Json(files))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let to_be_deleted: Vec<Post> = query_as(&format!(
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
for post in to_be_deleted.iter() {
let id = post.id;
let url = post.post_url();
let live_quote = format!("<a class=\"quote\" href=\"{url}\">&gt;&gt;{id}</a>");
let dead_quote = format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>");
query(&format!(
"UPDATE posts_{} SET content = REPLACE(content, $1, $2)",
self.board
))
.bind(live_quote)
.bind(dead_quote)
.execute(ctx.db())
.await?;
let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
let member = format!("{}/{}", post.board, post.id);
ctx.cache().zrem(ip_key, &member).await?;
ctx.cache().zrem(content_key, &member).await?;
}
let in_list = to_be_deleted
.iter()
.map(|post| (post.id))
.collect::<Vec<_>>();
query(&format!(
"DELETE FROM posts_{} WHERE id = ANY($1)",
self.board
))
.bind(&in_list)
.execute(ctx.db())
.await?;
if self.thread.is_none() {
ctx.cache().decr("total_threads", 1).await?;
ctx.cache()
.decr(format!("board_threads:{}", self.board), 1)
.await?;
}
Ok(())
}
pub async fn delete_files(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
for file in self.files.iter() {
file.delete().await;
}
query(&format!(
"UPDATE posts_{} SET files = '[]'::jsonb WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn delete_reports(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET reported = NULL, reports = '[]'::jsonb WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
}
impl Post {
pub fn post_url(&self) -> String {
if let Some(thread) = self.thread {
format!("/boards/{}/{}#{}", self.board, thread, self.id)
} else {
format!("/boards/{}/{}#{}", self.board, self.id, self.id)
}
}
pub fn post_url_notarget(&self) -> String {
format!("/boards/{}/{}", self.board, self.id)
}
}
async fn delete_old_threads(ctx: &Ctx, board: &Board) -> Result<(), NekrochanError> {
let old_threads: Vec<Post> = query_as(&format!(
r#"SELECT * FROM posts_{}
WHERE thread IS NULL AND id NOT IN (
SELECT id
FROM (
SELECT id
FROM posts_{}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC
LIMIT $1
) catty
)"#,
board.id, board.id
))
.bind(board.config.0.page_size * board.config.0.page_count)
.fetch_all(ctx.db())
.await?;
for thread in old_threads.iter() {
thread.delete(ctx).await?;
}
Ok(())
}

233
src/error.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,233 @@
use actix_web::{http::StatusCode, ResponseError};
use log::error;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NekrochanError {
#[error("Chyba při zpracovávání souboru '{}': {}", .0, .1)]
FileError(String, &'static str),
#[error("Uživatelské jméno musí mít 1-32 znaků.")]
UsernameFormatError,
#[error("Heslo musí mít alespoň 8 znaků.")]
PasswordFormatError,
#[error("ID musí mít 1-16 znaků.")]
IdFormatError,
#[error("Jméno nástěnky musí mít 1-32 znaků.")]
BoardNameFormatError,
#[error("Popis musí mít 1-128 znaků.")]
DescriptionFormatError,
#[error("Jméno nesmí mít více než 32 znaků.")]
PostNameFormatError,
#[error("Capcode nesmí mít více než 32 znaků.")]
CapcodeFormatError,
#[error("E-mail nesmí mít více než 256 znaků.")]
EmailFormatError,
#[error("Obsah nesmí mít více než 4000 znaků")]
ContentFormatError,
#[error("Nástěnka /{}/ neexistuje.", .0)]
BoardNotFound(String),
#[error("Účet '{}' neexistuje.", .0)]
AccountNotFound(String),
#[error("Příspěvek /{}/{} neexistuje.", .0, .1)]
PostNotFound(String, i32),
#[error("Nedostatečná oprávnění.")]
InsufficientPermissionError,
#[error("Nesprávné přihlašovací údaje.")]
IncorrectCredentialError,
#[error("Neplatná strana.")]
InvalidPageError,
#[error("Neplatný autentizační token. Vymaž soubory cookie.")]
InvalidAuthError,
#[error("Pro přístup se musíš přihlásit.")]
NotLoggedInError,
#[error("Účet vlastníka nemůže být vymazán.")]
OwnerDeletionError,
#[error("Reverzní proxy nevrátilo vyžadovanou hlavičku '{}'.", .0)]
HeaderError(&'static str),
#[error("Nástěnka /{}/ je uzamčená.", .0)]
BoardLockError(String),
#[error("Toto vlákno je uzamčené.")]
ThreadLockError,
#[error("Nelze vytvořit odpověď na odpověď.")]
ReplyReplyError,
#[error("Vlákno dosáhlo limitu odpovědí.")]
ReplyLimitError,
#[error("Příspěvek musí mít obsah.")]
NoContentError,
#[error("Příspěvek musí mít soubor.")]
NoFileError,
#[error("Příspěvek musí mít obsah nebo soubor.")]
EmptyPostError,
#[error("Na této nástěnce se musí vyplnit CAPTCHA.")]
RequiredCaptchaError,
#[error("Nesprávné řešení CAPTCHA.")]
IncorrectCaptchaError,
#[error("Tato CAPTCHA neexistuje nebo už byla vyřešena.")]
SolvedCaptchaError,
#[error("Nebyly vybrány žádné příspěvky.")]
NoPostsError,
#[error("Maximální počet souborů na této nástěnce je {}.", .0)]
FileLimitError(usize),
#[error("Nesprávné heslo pro příspěvek #{}.", .0)]
IncorrectPasswordError(i32),
// 500
#[error("Nadnástěnka nebyla inicializována.")]
OverboardError,
#[error("Server se připojil k 41 procentům.")]
InternalError,
}
impl From<askama::Error> for NekrochanError {
fn from(e: askama::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<ipnetwork::IpNetworkError> for NekrochanError {
fn from(e: ipnetwork::IpNetworkError) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<jsonwebtoken::errors::Error> for NekrochanError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<pwhash::error::Error> for NekrochanError {
fn from(e: pwhash::error::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<fancy_regex::Error> for NekrochanError {
fn from(e: fancy_regex::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<redis::RedisError> for NekrochanError {
fn from(e: redis::RedisError) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<serde_json::Error> for NekrochanError {
fn from(e: serde_json::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<serde_qs::Error> for NekrochanError {
fn from(e: serde_qs::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<sqlx::Error> for NekrochanError {
fn from(e: sqlx::Error) -> Self {
let overboard_err = match e.as_database_error() {
Some(e) => e.message() == "relation \"overboard\" does not exist",
None => false,
};
if !overboard_err {
error!("{e:#?}");
Self::InternalError
} else {
Self::OverboardError
}
}
}
impl From<std::io::Error> for NekrochanError {
fn from(e: std::io::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<std::net::AddrParseError> for NekrochanError {
fn from(e: std::net::AddrParseError) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl<T> From<std::sync::PoisonError<T>> for NekrochanError {
fn from(_: std::sync::PoisonError<T>) -> Self {
error!("CAPTCHA RwLock got poisoned or something.");
Self::InternalError
}
}
impl From<tokio::task::JoinError> for NekrochanError {
fn from(e: tokio::task::JoinError) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl ResponseError for NekrochanError {
fn status_code(&self) -> StatusCode {
match self {
NekrochanError::FileError(_, _) => StatusCode::BAD_REQUEST,
NekrochanError::UsernameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
NekrochanError::BoardNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::DescriptionFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::CapcodeFormatError => StatusCode::BAD_REQUEST,
NekrochanError::EmailFormatError => StatusCode::BAD_REQUEST,
NekrochanError::ContentFormatError => StatusCode::BAD_REQUEST,
NekrochanError::BoardNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::AccountNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
NekrochanError::InsufficientPermissionError => StatusCode::FORBIDDEN,
NekrochanError::IncorrectCredentialError => StatusCode::UNAUTHORIZED,
NekrochanError::InvalidPageError => StatusCode::NOT_FOUND,
NekrochanError::InvalidAuthError => StatusCode::NOT_FOUND,
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
NekrochanError::HeaderError(_) => StatusCode::BAD_GATEWAY,
NekrochanError::BoardLockError(_) => StatusCode::FORBIDDEN,
NekrochanError::ThreadLockError => StatusCode::FORBIDDEN,
NekrochanError::ReplyReplyError => StatusCode::BAD_REQUEST,
NekrochanError::ReplyLimitError => StatusCode::FORBIDDEN,
NekrochanError::NoContentError => StatusCode::BAD_REQUEST,
NekrochanError::NoFileError => StatusCode::BAD_REQUEST,
NekrochanError::EmptyPostError => StatusCode::BAD_REQUEST,
NekrochanError::RequiredCaptchaError => StatusCode::BAD_REQUEST,
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
NekrochanError::SolvedCaptchaError => StatusCode::BAD_REQUEST,
NekrochanError::NoPostsError => StatusCode::BAD_REQUEST,
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
NekrochanError::IncorrectPasswordError(_) => StatusCode::UNAUTHORIZED,
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
NekrochanError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

350
src/files.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,350 @@
use actix_multipart::form::tempfile::TempFile;
use anyhow::Error;
use chrono::Utc;
use glob::glob;
use image::io::Reader as ImageReader;
use std::{collections::HashSet, process::Command};
use tokio::{
fs::{remove_file, rename},
task::spawn_blocking,
};
use crate::{
cfg::Cfg,
ctx::Ctx,
db::models::{Board, File, Post},
error::NekrochanError,
};
impl File {
pub async fn new(
cfg: &Cfg,
temp_file: TempFile,
spoiler: bool,
thumb: bool,
) -> Result<Self, NekrochanError> {
let original_name = temp_file.file_name.unwrap_or_else(|| "unknown".into());
let mime = temp_file
.content_type
.ok_or(NekrochanError::FileError(
original_name.clone(),
"žádný mime typ",
))?
.to_string();
let (video, format) = match mime.as_str() {
"image/jpeg" => (false, "jpg"),
"image/pjpeg" => (false, "jpg"),
"image/png" => (false, "png"),
"image/bmp" => (false, "bmp"),
"image/gif" => (false, "gif"),
"image/webp" => (false, "webp"),
"image/apng" => (false, "apng"),
"video/mpeg" => (true, "mpeg"),
"video/quicktime" => (true, "mov"),
"video/mp4" => (true, "mp4"),
"video/webm" => (true, "webm"),
"video/x-matroska" => (true, "mkv"),
"video/ogg" => (true, "ogg"),
_ => {
return Err(NekrochanError::FileError(
original_name,
"nepodporovaný formát",
))
}
};
if video && !cfg.files.videos {
return Err(NekrochanError::FileError(
original_name,
"videa nejsou podporovaná",
));
}
let size = temp_file.size;
if size / 1_000_000 > cfg.files.max_size_mb {
return Err(NekrochanError::FileError(
original_name,
"soubor je příliš velký",
));
}
let timestamp = Utc::now().timestamp_micros();
let format = format.to_owned();
let new_name = format!("{timestamp}.{format}");
let (thumb_format, thumb_name) = if thumb {
let format = if video { "png".into() } else { format.clone() };
(Some(format.clone()), Some(format!("{timestamp}.{format}")))
} else {
(None, None)
};
rename(temp_file.file.path(), format!("/tmp/{new_name}")).await?;
let (width, height) = if video {
process_video(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
} else {
process_image(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
};
rename(format!("/tmp/{new_name}"), format!("uploads/{new_name}")).await?;
let file = File {
original_name,
format,
thumb_format,
spoiler,
width,
height,
timestamp,
size,
};
Ok(file)
}
pub async fn delete(&self) {
remove_file(format!("./uploads/{}.{}", self.timestamp, self.format))
.await
.ok();
if let Some(thumb_format) = &self.thumb_format {
remove_file(format!(
"./uploads/thumb/{}.{}",
self.timestamp, thumb_format
))
.await
.ok();
}
}
pub fn file_url(&self) -> String {
format!("/uploads/{}.{}", self.timestamp, self.format)
}
pub fn thumb_url(&self) -> String {
if self.spoiler {
"/static/spoiler.png".into()
} else if let Some(thumb_format) = &self.thumb_format {
format!("/uploads/thumb/{}.{}", self.timestamp, thumb_format)
} else {
self.file_url()
}
}
}
async fn process_image(
cfg: &Cfg,
original_name: String,
new_name: String,
thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> {
let original_name_ = original_name.clone();
let img = spawn_blocking(move || {
ImageReader::open(format!("/tmp/{new_name}"))?
.decode()
.map_err(|_| {
NekrochanError::FileError(original_name_, "nepodařilo se dekódovat obrázek")
})
})
.await??;
let (width, height) = (img.width(), img.height());
if width > cfg.files.max_width || height > cfg.files.max_height {
return Err(NekrochanError::FileError(
original_name,
"rozměry obrázku jsou příliš velké",
));
}
let thumb_name = match thumb_name {
Some(thumb_name) => thumb_name,
None => return Ok((width, height)),
};
let thumb_w = if width > cfg.files.thumb_size {
cfg.files.thumb_size
} else {
width
};
let thumb_h = if height > cfg.files.thumb_size {
cfg.files.thumb_size
} else {
height
};
spawn_blocking(move || {
let thumb = img.thumbnail(thumb_w, thumb_h);
thumb
.save(format!("./uploads/thumb/{thumb_name}"))
.map_err(|_| {
NekrochanError::FileError(original_name, "nepodařilo se vytvořit náhled obrázku")
})
})
.await??;
Ok((width, height))
}
async fn process_video(
cfg: &Cfg,
original_name: String,
new_name: String,
thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> {
let new_name_ = new_name.clone();
let ffprobe_out = spawn_blocking(move || {
Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=s=x:p=0",
&format!("/tmp/{new_name_}"),
])
.output()
})
.await??;
if !ffprobe_out.status.success() {
return Err(NekrochanError::FileError(
original_name,
"nepodařilo se získat rozměry videa",
));
}
let invalid_dimensions = "ffprobe vrátil neplatné rozměry";
let out_string = String::from_utf8_lossy(&ffprobe_out.stdout);
let (width, height) = out_string
.trim()
.split_once('x')
.ok_or(NekrochanError::FileError(
original_name.clone(),
invalid_dimensions,
))?;
let (width, height) = (
width
.parse()
.map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?,
height
.parse()
.map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?,
);
if width > cfg.files.max_width || height > cfg.files.max_height {
return Err(NekrochanError::FileError(
original_name,
"rozměry videa jsou příliš velké",
));
}
let thumb_name = match thumb_name {
Some(thumb_name) => thumb_name,
None => return Ok((width, height)),
};
let thumb_size = cfg.files.thumb_size;
let output = spawn_blocking(move || {
Command::new("ffmpeg")
.args([
"-i",
&format!("/tmp/{new_name}"),
"-ss",
"00:00:00.50",
"-vframes",
"1",
"-vf",
&format!(
"scale={}",
if width > height {
format!("{thumb_size}:-2")
} else {
format!("-2:{thumb_size}")
}
),
&format!("./uploads/thumb/{thumb_name}"),
])
.output()
})
.await??;
if !output.status.success() {
return Err(NekrochanError::FileError(
original_name,
"nepodařilo se vytvořit náhled videa",
));
}
Ok((width, height))
}
pub async fn cleanup_files(ctx: &Ctx) -> Result<(), Error> {
let mut keep = HashSet::new();
let mut keep_thumbs = HashSet::new();
let boards = Board::read_all(ctx).await?;
for board in boards.into_iter() {
for file in board.banners.0 {
keep.insert(format!("{}.{}", file.timestamp, file.format));
}
let posts = Post::read_all(ctx, board.id.clone()).await?;
for post in posts.into_iter() {
for file in post.files.0 {
keep.insert(format!("{}.{}", file.timestamp, file.format));
if let Some(thumb_format) = file.thumb_format {
keep_thumbs.insert(format!("{}.{}", file.timestamp, thumb_format));
}
}
}
}
for file in glob("uploads/*.*")? {
let file = file?;
let file_name = file.file_name();
if let Some(file_name) = file_name {
let check = file_name.to_string_lossy().to_string();
if !keep.contains(&check) {
remove_file(file).await?;
}
}
}
for file in glob("uploads/thumb/*.*")? {
let file = file?;
let file_name = file.file_name();
if let Some(file_name) = file_name {
let check = file_name.to_string_lossy().to_string();
if !keep_thumbs.contains(&check) {
remove_file(file).await?;
}
}
}
Ok(())
}

131
src/filters.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,131 @@
use chrono::{DateTime, Locale, Utc};
use fancy_regex::{Captures, Regex};
use lazy_static::lazy_static;
use std::{collections::HashSet, fmt::Display};
use crate::markup::SPOILER_REGEX;
lazy_static! {
static ref MARKUP_QUOTE_REGEX: Regex =
Regex::new(r#"<a class="quote" href=".+">&gt;&gt;(\d+)<\/a>"#).unwrap();
}
pub fn czech_humantime(time: &DateTime<Utc>) -> askama::Result<String> {
let duration = (Utc::now() - *time).abs();
let minutes = duration.num_minutes();
let hours = duration.num_hours();
let days = duration.num_days();
let weeks = duration.num_weeks();
let months = duration.num_days() / 30;
let years = duration.num_days() / 365;
let mut time = "Teď".into();
if minutes > 0 {
time = format!(
"{} {}",
minutes,
czech_plural("minuta|minuty|minut", minutes)?
);
}
if hours > 0 {
time = format!("{} {}", hours, czech_plural("hodina|hodiny|hodin", hours)?);
}
if days > 0 {
time = format!("{} {}", days, czech_plural("den|dny|dnů", days)?);
}
if weeks > 0 {
time = format!("{} {}", weeks, czech_plural("týden|týdny|týdnů", weeks)?);
}
if months > 0 {
time = format!(
"{} {}",
months,
czech_plural("měsíc|měsíce|měsíců", months)?
);
}
if years > 0 {
time = format!("{} {}", years, czech_plural("rok|roky|let", years)?);
}
Ok(time)
}
pub fn czech_datetime(time: &DateTime<Utc>) -> askama::Result<String> {
let time = time
.format_localized("%d.%m.%Y (%a) %H:%M:%S UTC", Locale::cs_CZ)
.to_string();
Ok(time)
}
pub fn czech_plural(plurals: &str, count: impl Display) -> askama::Result<String> {
let plurals = plurals.split('|').collect::<Vec<_>>();
let count = count.to_string().parse::<i64>().unwrap();
let one = plurals[0];
let few = plurals[1];
let other = plurals[2];
if count == 1 {
Ok(one.into())
} else if count < 5 {
Ok(few.into())
} else {
Ok(other.into())
}
}
pub fn inline_post(input: impl Display) -> askama::Result<String> {
let input = input.to_string();
if input.is_empty() {
return Ok("(bez obsahu)".into());
}
let collapsed = input.split_whitespace().collect::<Vec<_>>().join(" ");
let spoilered = SPOILER_REGEX
.replace_all(&collapsed, "(spoiler)")
.to_string();
let truncated = askama::filters::truncate(spoilered, 64)?;
Ok(truncated)
}
pub fn get_page(input: &usize, page_size: &i64) -> askama::Result<i64> {
let page = crate::paginate(*page_size, *input as i64);
Ok(page)
}
pub fn add_yous(
input: impl Display,
board: &String,
yous: &HashSet<String>,
) -> askama::Result<String> {
let input = input.to_string();
let output = MARKUP_QUOTE_REGEX.replace_all(&input, |captures: &Captures| {
let quote = &captures[0];
let id = &captures[1];
format!(
"{}{}",
quote,
if yous.contains(&format!("{}/{}", board, id)) {
" <span class=\"small\">(Ty)</span>"
} else {
""
}
)
});
Ok(output.to_string())
}

43
src/lib.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,43 @@
use error::NekrochanError;
use lazy_static::lazy_static;
use std::{collections::HashMap, sync::RwLock};
lazy_static! {
pub static ref CAPTCHA: RwLock<HashMap<(String, String, String), String>> =
RwLock::new(HashMap::new());
}
pub mod auth;
pub mod cfg;
pub mod ctx;
pub mod db;
pub mod error;
pub mod files;
pub mod filters;
pub mod markup;
pub mod perms;
pub mod qsform;
pub mod trip;
pub mod web;
pub fn paginate(page_size: i64, count: i64) -> i64 {
count / page_size + (count % page_size).signum()
}
pub fn check_page(
page: i64,
pages: i64,
page_limit: impl Into<Option<i64>>,
) -> Result<(), NekrochanError> {
if page <= 0 || (page > pages && page != 1) {
return Err(NekrochanError::InvalidPageError);
}
if let Some(page_limit) = page_limit.into() {
if page > page_limit {
return Err(NekrochanError::InvalidPageError);
}
}
Ok(())
}

154
src/main.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,154 @@
use actix_files::{Files, NamedFile};
use actix_web::{
body::MessageBody,
dev::ServiceResponse,
get,
http::StatusCode,
middleware::{ErrorHandlerResponse, ErrorHandlers},
post,
web::Data,
App, HttpResponse, HttpServer, ResponseError,
};
use anyhow::Error;
use askama::Template;
use log::{error, info};
use nekrochan::{
cfg::Cfg,
ctx::Ctx,
db::cache::init_cache,
error::NekrochanError,
files::cleanup_files,
web::{self, template_response},
};
use sqlx::migrate;
use std::{env::var, time::Duration};
use tokio::time::sleep;
#[tokio::main]
async fn main() {
dotenv::dotenv().ok();
env_logger::init();
if let Err(err) = run().await {
error!("{err:?}");
}
}
async fn run() -> Result<(), Error> {
let cfg_path = var("NEKROCHAN_CONFIG").unwrap_or_else(|_| "Nekrochan.toml".into());
let cfg = Cfg::load(&cfg_path).await?;
let ctx = Ctx::new(cfg).await?;
migrate!().run(ctx.db()).await?;
init_cache(&ctx).await?;
let ctx_ = ctx.clone();
tokio::spawn(async move {
loop {
match cleanup_files(&ctx_).await {
Ok(()) => info!("Routine file cleanup successful."),
Err(err) => error!("Routine file cleanup failed: {err:?}"),
};
sleep(Duration::from_secs(ctx_.cfg.files.cleanup_interval)).await;
}
});
let bind_addr = ctx.bind_addr();
HttpServer::new(move || {
App::new()
.app_data(Data::new(ctx.clone()))
.service(web::index::index)
.service(web::board::board)
.service(web::board_catalog::board_catalog)
.service(web::overboard::overboard)
.service(web::overboard_catalog::overboard_catalog)
.service(web::thread::thread)
.service(web::actions::create_post::create_post)
.service(web::actions::user_post_actions::user_post_actions)
.service(web::actions::staff_post_actions::staff_post_actions)
.service(web::actions::report_posts::report_posts)
.service(web::login::login_get)
.service(web::login::login_post)
.service(web::logout::logout)
.service(web::staff::account::account)
.service(web::staff::accounts::accounts)
.service(web::staff::boards::boards)
.service(web::staff::bans::bans)
.service(web::staff::reports::reports)
.service(web::staff::permissions::permissions)
.service(web::staff::banners::banners)
.service(web::staff::board_config::board_config)
.service(web::staff::actions::change_password::change_password)
.service(web::staff::actions::transfer_ownership::transfer_ownership)
.service(web::staff::actions::delete_account::delete_account)
.service(web::staff::actions::remove_accounts::remove_accounts)
.service(web::staff::actions::create_account::create_account)
.service(web::staff::actions::remove_boards::remove_boards)
.service(web::staff::actions::update_boards::update_boards)
.service(web::staff::actions::create_board::create_board)
.service(web::staff::actions::remove_bans::remove_bans)
.service(web::staff::actions::update_permissions::update_permissions)
.service(web::staff::actions::remove_banners::remove_banners)
.service(web::staff::actions::add_banners::add_banners)
.service(web::staff::actions::update_board_config::update_board_config)
.service(debug)
.service(favicon)
.service(Files::new("/static", "./static"))
.service(Files::new("/uploads", "./uploads").disable_content_disposition())
.wrap(ErrorHandlers::new().default_handler(error_handler))
})
.bind(bind_addr)?
.run()
.await?;
Ok(())
}
#[get("/favicon.ico")]
async fn favicon() -> Result<NamedFile, NekrochanError> {
let favicon = NamedFile::open("./static/favicon.ico")?;
Ok(favicon)
}
#[post("/debug")]
async fn debug(req: String) -> HttpResponse {
println!("{req}");
HttpResponse::new(StatusCode::OK)
}
#[derive(Template)]
#[template(path = "error.html")]
struct ErrorTempalate {
error_code: u16,
error_message: String,
}
fn error_handler<B>(res: ServiceResponse<B>) -> actix_web::Result<ErrorHandlerResponse<B>>
where
B: MessageBody,
<B as MessageBody>::Error: ResponseError + 'static,
{
let (req, res) = res.into_parts();
let error_code = res.status().as_u16();
let error_message = match res.into_body().try_into_bytes().ok() {
Some(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
None => String::default(),
};
let template = ErrorTempalate {
error_code,
error_message,
};
let res = template_response(template)?;
let res = ServiceResponse::new(req, res).map_into_right_body();
Ok(ErrorHandlerResponse::Response(res))
}

216
src/markup.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,216 @@
use std::collections::HashMap;
use fancy_regex::{Captures, Regex};
use lazy_static::lazy_static;
use sqlx::query_as;
use crate::{
ctx::Ctx,
db::models::Post,
error::NekrochanError,
perms::PermissionWrapper,
trip::{secure_tripcode, tripcode},
};
lazy_static! {
pub static ref NAME_REGEX: Regex =
Regex::new(r"^([^#].*?)?(?:(##([^ ].*?)|#([^#].*?)))?(##( .*?)?)?$").unwrap();
pub static ref QUOTE_REGEX: Regex = Regex::new(r"&gt;&gt;(\d+)").unwrap();
pub static ref GREENTEXT_REGEX: Regex =
Regex::new(r"(?m)^&gt;((?!&gt;\d+|&gt;&gt;&#x2F;\w+(&#x2F;\d*)?|&gt;&gt;#&#x2F;).*)")
.unwrap();
pub static ref ORANGETEXT_REGEX: Regex = Regex::new(r"(?m)^&lt;(.+)").unwrap();
pub static ref REDTEXT_REGEX: Regex = Regex::new(r"(?m)&#x3D;&#x3D;(.+?)&#x3D;&#x3D;").unwrap();
pub static ref BLUETEXT_REGEX: Regex = Regex::new(r"(?m)--(.+?)--").unwrap();
pub static ref GLOWTEXT_REGEX: Regex = Regex::new(r"(?m)\%\%(.+?)\%\%").unwrap();
pub static ref UH_OH_TEXT_REGEX: Regex = Regex::new(r"(?m)\(\(\((.+?)\)\)\)").unwrap();
pub static ref SPOILER_REGEX: Regex = Regex::new(r"(?m)\|\|([\s\S]+?)\|\|").unwrap();
pub static ref URL_REGEX: Regex =
Regex::new(r"https?\:&#x2F;&#x2F;[^\s<>\[\]{}|\\^]+").unwrap();
}
pub fn parse_name(
ctx: &Ctx,
perms: &PermissionWrapper,
anon_name: &str,
name: &str,
) -> Result<(String, Option<String>, Option<String>), NekrochanError> {
let captures = match NAME_REGEX.captures(name)? {
Some(captures) => captures,
None => return Ok((anon_name.to_owned(), None, None)),
};
let name = match captures.get(1) {
Some(name) => {
let name = name.as_str().to_owned();
if name.len() > 32 {
return Err(NekrochanError::PostNameFormatError);
}
name
}
None => anon_name.to_owned(),
};
let tripcode = match captures.get(2) {
Some(_) => {
let strip = captures.get(3);
let itrip = captures.get(4);
if let Some(strip) = strip {
let trip = secure_tripcode(strip.as_str(), &ctx.cfg.secrets.secure_trip);
Some(format!("!!{trip}"))
} else if let Some(itrip) = itrip {
let trip = tripcode(itrip.as_str());
Some(format!("!{trip}"))
} else {
None
}
}
None => None,
};
if !(perms.owner() || perms.capcodes()) {
return Ok((name, tripcode, None));
}
fn capcode_fallback(owner: bool) -> Option<String> {
if owner {
Some("Admin".into())
} else {
Some("Uklízeč".into())
}
}
let capcode = match captures.get(5) {
Some(_) => match captures.get(6) {
Some(capcode) => {
let capcode: String = capcode.as_str().trim().into();
if capcode.is_empty() {
capcode_fallback(perms.owner())
} else {
if capcode.len() > 32 {
return Err(NekrochanError::CapcodeFormatError);
}
Some(capcode)
}
}
None => capcode_fallback(perms.owner()),
},
None => None,
};
Ok((name, tripcode, capcode))
}
pub async fn markup(
ctx: &Ctx,
board: &String,
op: Option<i32>,
text: &str,
) -> Result<String, NekrochanError> {
let text = escape_html(text);
let quoted_posts = get_quoted_posts(ctx, board, &text).await?;
let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| {
let id_raw = &captures[1];
let id = match id_raw.parse() {
Ok(id) => id,
Err(_) => return format!("<span class=\"dead-quote\">&gt;&gt;{id_raw}</span>"),
};
let post = quoted_posts.get(&id);
match post {
Some(post) => format!(
"<a class=\"quote\" href=\"{}\">&gt;&gt;{}</a>{}",
post.post_url(),
post.id,
if op == Some(post.id) {
" <span class=\"small\">(OP)</span>"
} else {
""
}
),
None => format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>"),
}
});
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">&gt;$1</span>");
let text = ORANGETEXT_REGEX.replace_all(&text, "<span class=\"orangetext\">&lt;$1</span>");
let text = REDTEXT_REGEX.replace_all(&text, "<span class=\"redtext\">$1</span>");
let text = BLUETEXT_REGEX.replace_all(&text, "<span class=\"bluetext\">$1</span>");
let text = GLOWTEXT_REGEX.replace_all(&text, "<span class=\"glowtext\">$1</span>");
let text = SPOILER_REGEX.replace_all(&text, "<span class=\"spoiler\">$1</span>");
let text = UH_OH_TEXT_REGEX.replace_all(&text, |captures: &Captures| {
format!(
"<span class=\"uh-oh-text\">((( {} )))</span>",
captures[1].trim()
)
});
let text = URL_REGEX.replace_all(&text, |captures: &Captures| {
let url = &captures[0];
format!("<a rel=\"nofollow\" href=\"{url}\">{url}</a>")
});
Ok(text.to_string())
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('\'', "&#39;")
.replace('/', "&#x2F;")
.replace('`', "&#x60;")
.replace('=', "&#x3D;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
async fn get_quoted_posts(
ctx: &Ctx,
board: &String,
text: &str,
) -> Result<HashMap<i32, Post>, NekrochanError> {
let mut quoted_ids: Vec<i32> = Vec::new();
for quote in QUOTE_REGEX.captures_iter(text) {
let id_raw = &quote.unwrap()[1];
let id = match id_raw.parse() {
Ok(id) => id,
Err(_) => continue,
};
quoted_ids.push(id);
}
if quoted_ids.is_empty() {
return Ok(HashMap::new());
}
let in_list = quoted_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let quoted_posts = query_as(&format!(
"SELECT * FROM posts_{} WHERE id IN ({})",
board, in_list
))
.fetch_all(ctx.db())
.await?
.into_iter()
.map(|post: Post| (post.id, post))
.collect::<HashMap<_, _>>();
Ok(quoted_posts)
}

85
src/perms.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,85 @@
use enumflags2::{bitflags, BitFlags};
#[bitflags]
#[repr(u64)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Permissions {
EditPosts,
ManagePosts,
Capcodes,
StaffLog,
Reports,
Bans,
BoardBanners,
BoardConfig,
BypassBans,
BypassBoardLock,
BypassThreadLock,
BypassCaptcha,
}
pub struct PermissionWrapper(BitFlags<Permissions>, bool);
impl PermissionWrapper {
pub fn new(perms: u64, owner: bool) -> Self {
Self(BitFlags::from_bits_truncate(perms), owner)
}
}
impl PermissionWrapper {
pub fn integer(&self) -> u64 {
self.0.bits()
}
pub fn owner(&self) -> bool {
self.1
}
pub fn edit_posts(&self) -> bool {
self.0.contains(Permissions::EditPosts)
}
pub fn manage_posts(&self) -> bool {
self.0.contains(Permissions::ManagePosts)
}
pub fn capcodes(&self) -> bool {
self.0.contains(Permissions::Capcodes)
}
pub fn staff_log(&self) -> bool {
self.0.contains(Permissions::StaffLog)
}
pub fn reports(&self) -> bool {
self.0.contains(Permissions::Reports)
}
pub fn bans(&self) -> bool {
self.0.contains(Permissions::Bans)
}
pub fn board_banners(&self) -> bool {
self.0.contains(Permissions::BoardBanners)
}
pub fn board_config(&self) -> bool {
self.0.contains(Permissions::BoardConfig)
}
pub fn bypass_bans(&self) -> bool {
self.0.contains(Permissions::BypassBans)
}
pub fn bypass_board_lock(&self) -> bool {
self.0.contains(Permissions::BypassBoardLock)
}
pub fn bypass_thread_lock(&self) -> bool {
self.0.contains(Permissions::BypassThreadLock)
}
pub fn bypass_captcha(&self) -> bool {
self.0.contains(Permissions::BypassCaptcha)
}
}

43
src/qsform.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,43 @@
use actix_web::{dev::Payload, http::StatusCode, FromRequest, HttpRequest, ResponseError};
use serde::Deserialize;
use serde_qs::Config;
use std::{future::Future, pin::Pin};
use thiserror::Error;
pub struct QsForm<T>(pub T)
where
T: for<'de> Deserialize<'de>;
#[derive(Debug, Error)]
pub enum QsFormError {
#[error("{}", .0)]
FutureError(#[from] actix_web::Error),
#[error("{}", .0)]
ParseError(#[from] serde_qs::Error),
}
impl ResponseError for QsFormError {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
}
impl<T> FromRequest for QsForm<T>
where
T: for<'de> Deserialize<'de>,
{
type Error = QsFormError;
type Future = Pin<Box<dyn Future<Output = Result<QsForm<T>, Self::Error>>>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let data = String::from_request(req, payload);
Box::pin(async move {
let data = data.await?;
let config = Config::new(10, false);
let form: T = config.deserialize_str(&data)?;
Ok(QsForm(form))
})
}
}

54
src/trip.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,54 @@
use encoding::{all::WINDOWS_31J, EncoderTrap, Encoding};
use pwhash::{
bcrypt::{self, BcryptSetup},
unix::crypt,
};
pub fn tripcode(password: &str) -> String {
let password = WINDOWS_31J.encode(password, EncoderTrap::Replace).unwrap();
let salt = [password.as_ref(), "H.".as_bytes()].concat();
let salt = &salt[1..3];
let salt = salt
.iter()
.map(|c| match c {
46..=122 => *c,
_ => 46,
} as char)
.map(|c| match c {
':' => 'A',
';' => 'B',
'<' => 'C',
'=' => 'D',
'>' => 'E',
'?' => 'F',
'@' => 'G',
'[' => 'a',
'\\' => 'b',
']' => 'c',
'^' => 'd',
'_' => 'e',
'`' => 'f',
_ => c,
})
.collect::<String>();
let trip = crypt(password, &salt).unwrap();
trip[3..].to_owned()
}
pub fn secure_tripcode(password: &str, tripcode_secret: &str) -> String {
let trip = bcrypt::hash_with(
BcryptSetup {
salt: Some(tripcode_secret),
..Default::default()
},
password,
)
.unwrap();
let trip = &trip[trip.len() - 10..];
trip.into()
}

250
src/web/actions/create_post.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,250 @@
use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm};
use actix_web::{
cookie::Cookie, http::StatusCode, post, web::Data, HttpRequest, HttpResponse,
HttpResponseBuilder,
};
use pwhash::bcrypt::hash;
use sha256::digest;
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},
},
CAPTCHA,
};
#[derive(MultipartForm)]
pub struct PostForm {
pub board: Text<String>,
pub thread: Option<Text<i32>>,
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>>,
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 bumpy_bump = true;
let mut noko = true;
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::ReplyReplyError);
}
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 {
bumpy_bump = false;
}
Some(thread)
}
None => None,
};
let difficulties = ["easy", "medium", "hard"];
let difficulty = if thread.is_none() {
if difficulties.contains(&board.config.0.thread_captcha.as_str()) {
Some(board.config.0.thread_captcha.clone())
} else {
None
}
} else if difficulties.contains(&board.config.0.reply_captcha.as_str()) {
Some(board.config.0.reply_captcha.clone())
} else {
None
};
if let Some(difficulty) = difficulty {
let board = board.id.clone();
let id = form
.captcha_id
.ok_or(NekrochanError::RequiredCaptchaError)?
.0;
let key = (board, difficulty, id);
let solution = form
.captcha_solution
.ok_or(NekrochanError::RequiredCaptchaError)?;
let actual_solution = CAPTCHA
.write()?
.remove(&key)
.ok_or(NekrochanError::SolvedCaptchaError)?;
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() {
if email_raw.len() > 256 {
return Err(NekrochanError::EmailFormatError);
}
if email_raw == "sage" || email_raw == "nonokosage" {
bumpy_bump = false;
}
if email_raw == "nonoko" || email_raw == "nonokosage" {
noko = false;
}
Some(email_raw.into())
} else {
None
};
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() > 4000 {
return Err(NekrochanError::ContentFormatError);
}
let content = markup(
&ctx,
&board.id,
thread.as_ref().map(|t| t.id),
&content_nomarkup,
)
.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.into_iter() {
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,
bumpy_bump,
)
.await?;
let ts = thread
.as_ref()
.map(|thread| thread.created.timestamp_micros())
.unwrap_or_else(|| post.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)
}

38
src/web/actions/mod.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,38 @@
use askama::Template;
use super::tcx::TemplateCtx;
use crate::{ctx::Ctx, db::models::Post};
pub mod create_post;
pub mod staff_post_actions;
pub mod report_posts;
pub mod user_post_actions;
#[derive(Template)]
#[template(path = "action.html")]
pub struct ActionTemplate {
pub tcx: TemplateCtx,
pub response: String,
}
pub async fn get_posts_from_ids(ctx: &Ctx, ids: Vec<String>) -> Vec<Post> {
let mut posts = Vec::new();
for id in ids.into_iter() {
if let Some((board, id)) = parse_id(id) {
if let Ok(Some(post)) = Post::read(ctx, board, id).await {
posts.push(post);
}
}
}
posts
}
fn parse_id(id: String) -> Option<(String, i32)> {
let (board, id) = id.split_once('/')?;
let board = board.to_owned();
let id = id.parse().ok()?;
Some((board, id))
}

106
src/web/actions/report_posts.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,106 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use std::fmt::Write;
use crate::{
ctx::Ctx,
db::models::{Ban, Board},
error::NekrochanError,
qsform::QsForm,
web::{
actions::{get_posts_from_ids, ActionTemplate},
ban_response,
tcx::{ip_from_req, TemplateCtx},
template_response,
},
};
#[derive(Deserialize)]
pub struct ReportPostsForm {
#[serde(default)]
pub posts: Vec<String>,
pub report_reason: String,
}
#[post("/actions/report-posts")]
pub async fn report_posts(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<ReportPostsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let (reporter_ip, reporter_country) = ip_from_req(&req)?;
let bans = Ban::read_by_ip(&ctx, reporter_ip).await?;
if let Some(ban) = bans.get(&None) {
if !(tcx.perms.owner() || tcx.perms.bypass_bans()) {
return ban_response(&ctx, &req, ban.clone()).await;
}
}
let boards = Board::read_all_map(&ctx).await?;
let posts = get_posts_from_ids(&ctx, form.posts).await;
let mut response = String::new();
let mut posts_reported = 0;
let reason = form.report_reason.trim();
for post in posts.iter() {
let board = &boards[&post.board];
if bans.contains_key(&Some(board.id.clone()))
&& !(tcx.perms.owner() || tcx.perms.bypass_bans())
{
writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok();
continue;
}
if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) {
writeln!(
&mut response,
"[Chyba] {}",
NekrochanError::BoardLockError(board.id.to_owned())
)
.ok();
continue;
}
if post
.reports
.iter()
.any(|report| report.reporter_ip == reporter_ip)
{
writeln!(
&mut response,
"[Chyba] Příspěvek #{} jsi už nahlásil.",
post.id
)
.ok();
continue;
}
post.create_report(
&ctx,
reason.to_owned(),
reporter_country.clone(),
reporter_ip,
)
.await?;
posts_reported += 1;
}
if posts_reported != 0 {
writeln!(
&mut response,
"[Úspěch] Nahlášeny příspěvky: {posts_reported}"
)
.ok();
}
let template = ActionTemplate { tcx, response };
template_response(template)
}

Zobrazit soubor

@ -0,0 +1,219 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use chrono::{Duration, Utc};
use ipnetwork::IpNetwork;
use serde::Deserialize;
use std::{collections::HashSet, fmt::Write};
use crate::{
ctx::Ctx,
db::models::Ban,
error::NekrochanError,
qsform::QsForm,
web::{
actions::{get_posts_from_ids, ActionTemplate},
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Deserialize)]
pub struct JannyPostActionsForm {
#[serde(default)]
pub posts: Vec<String>,
#[serde(rename = "staff_remove_posts")]
pub remove_posts: Option<String>,
#[serde(rename = "staff_remove_files")]
pub remove_files: Option<String>,
#[serde(rename = "staff_toggle_spoiler")]
pub toggle_spoiler: Option<String>,
pub toggle_sticky: Option<String>,
pub toggle_lock: Option<String>,
pub ban_user: Option<String>,
pub global_ban: Option<String>,
pub unappealable_ban: Option<String>,
pub ban_reason: Option<String>,
pub ban_duration: Option<i64>,
pub ban_range: Option<String>,
}
#[post("/actions/staff-post-actions")]
pub async fn staff_post_actions(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<JannyPostActionsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
let posts = get_posts_from_ids(&ctx, form.posts).await;
let mut response = String::new();
let mut posts_removed = 0;
let mut files_removed = 0;
let mut spoilers_toggled = 0;
let mut stickies_toggled = 0;
let mut locks_toggled = 0;
let mut bans_issued = 0;
for post in posts.iter() {
if (form.remove_posts.is_some()
|| form.remove_files.is_some()
|| form.toggle_spoiler.is_some()
|| form.toggle_sticky.is_some()
|| form.toggle_lock.is_some())
&& !(account.perms().owner() || account.perms().manage_posts())
{
writeln!(
&mut response,
"[Chyba] Nemáš oprávnění spravovat příspěvky."
)
.ok();
continue;
}
if form.remove_posts.is_some() {
post.delete(&ctx).await?;
posts_removed += 1;
}
if form.remove_files.is_some() {
post.delete_files(&ctx).await?;
files_removed += post.files.0.len();
}
if form.toggle_spoiler.is_some() {
post.update_spoiler(&ctx).await?;
spoilers_toggled += post.files.0.len();
}
if form.toggle_sticky.is_some() {
post.update_sticky(&ctx).await?;
stickies_toggled += post.files.0.len();
}
if form.toggle_lock.is_some() {
post.update_lock(&ctx).await?;
locks_toggled += post.files.0.len();
}
}
let mut already_banned = HashSet::new();
for post in posts.into_iter() {
if let (Some(_), Some(ban_reason), Some(ban_duration), Some(ban_range)) = (
form.ban_user.clone(),
form.ban_reason.clone(),
form.ban_duration,
form.ban_range.clone(),
) {
if !(account.perms().owner() || account.perms().bans()) {
writeln!(&mut response, "[Chyba] Nemáš oprávnění vydat ban.").ok();
continue;
}
if already_banned.contains(&post.ip) {
continue;
}
let account = account.username.clone();
let board = if form.global_ban.is_none() {
Some(post.board.clone())
} else {
None
};
let prefix = if post.ip.is_ipv4() {
match ban_range.as_str() {
"ip" => 32,
"lan" => 24,
"isp" => 16,
_ => 32,
}
} else {
match ban_range.as_str() {
"ip" => 128,
"lan" => 48,
"isp" => 24,
_ => 128,
}
};
let ip_range = IpNetwork::new(post.ip, prefix)?;
let reason = ban_reason.trim().into();
let appealable = form.unappealable_ban.is_none();
let expires = if ban_duration != 0 {
Some(Utc::now() + Duration::days(ban_duration))
} else {
None
};
Ban::create(&ctx, account, board, ip_range, reason, appealable, expires).await?;
let content_nomarkup = format!(
"{}\n\n==(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)==",
post.content_nomarkup
);
let content = format!(
"{}\n\n<span class=\"redtext\">(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)</span>",
post.content
);
post.update_content(&ctx, content, content_nomarkup).await?;
already_banned.insert(post.ip);
bans_issued += 1;
}
}
if posts_removed != 0 {
writeln!(
&mut response,
"[Úspěch] Odstraněny příspěvky: {posts_removed}"
)
.ok();
}
if files_removed != 0 {
writeln!(
&mut response,
"[Úspěch] Odstraněny soubory: {files_removed}"
)
.ok();
}
if spoilers_toggled != 0 {
writeln!(
&mut response,
"[Úspěch] Přepnuty spoilery: {spoilers_toggled}"
)
.ok();
}
if stickies_toggled != 0 {
writeln!(
&mut response,
"[Úspěch] Připnuta/odepnuta vlákna: {stickies_toggled}"
)
.ok();
}
if locks_toggled != 0 {
writeln!(
&mut response,
"[Úspěch] Zamčena/odemčena vlákna: {locks_toggled}"
)
.ok();
}
if bans_issued != 0 {
writeln!(&mut response, "[Úspěch] Uděleny bany: {bans_issued}").ok();
}
let template = ActionTemplate { tcx, response };
template_response(template)
}

135
src/web/actions/user_post_actions.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,135 @@
use std::fmt::Write;
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use pwhash::bcrypt::verify;
use serde::Deserialize;
use crate::{
ctx::Ctx,
db::models::{Ban, Board},
error::NekrochanError,
qsform::QsForm,
web::{
actions::{get_posts_from_ids, ActionTemplate},
ban_response,
tcx::{ip_from_req, TemplateCtx},
template_response,
},
};
#[derive(Deserialize)]
pub struct UserPostActionsForm {
#[serde(default)]
pub posts: Vec<String>,
pub remove_posts: Option<String>,
pub remove_files: Option<String>,
pub toggle_spoiler: Option<String>,
pub password: String,
}
#[post("/actions/user-post-actions")]
pub async fn user_post_actions(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<UserPostActionsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let (ip, _) = ip_from_req(&req)?;
let bans = Ban::read_by_ip(&ctx, ip).await?;
if let Some(ban) = bans.get(&None) {
if !(tcx.perms.owner() || tcx.perms.bypass_bans()) {
return ban_response(&ctx, &req, ban.clone()).await;
}
}
let posts = get_posts_from_ids(&ctx, form.posts).await;
let boards = Board::read_all_map(&ctx).await?;
let mut response = String::new();
let mut posts_removed = 0;
let mut files_removed = 0;
let mut spoilers_toggled = 0;
for post in posts.iter() {
let board = &boards[&post.board];
if bans.contains_key(&Some(board.id.clone()))
&& !(tcx.perms.owner() || tcx.perms.bypass_bans())
{
writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok();
continue;
}
if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) {
writeln!(
&mut response,
"[Chyba] {}",
NekrochanError::BoardLockError(board.id.to_owned())
)
.ok();
continue;
}
if !verify(&form.password, &post.password) {
writeln!(
&mut response,
"[Chyba] {}",
NekrochanError::IncorrectPasswordError(post.id)
)
.ok();
continue;
}
if form.remove_posts.is_some() {
post.delete(&ctx).await?;
posts_removed += 1;
}
if form.remove_files.is_some() {
if (post.thread.is_none() && board.config.0.require_thread_file)
|| (post.thread.is_some() && board.config.0.require_reply_file)
{
writeln!(&mut response, "[Chyba] Soubor je na tomto místě potřebný.").ok();
} else {
post.delete_files(&ctx).await?;
files_removed += post.files.0.len();
}
}
if form.toggle_spoiler.is_some() {
post.update_spoiler(&ctx).await?;
spoilers_toggled += post.files.0.len();
}
}
if posts_removed != 0 {
writeln!(
&mut response,
"[Úspěch] Odstraněny příspěvky: {posts_removed}"
)
.ok();
}
if files_removed != 0 {
writeln!(
&mut response,
"[Úspěch] Odstraněny soubory: {files_removed}"
)
.ok();
}
if spoilers_toggled != 0 {
writeln!(
&mut response,
"[Úspěch] Přepnuty spoilery: {spoilers_toggled}"
)
.ok();
}
let template = ActionTemplate { tcx, response };
template_response(template)
}

74
src/web/board.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,74 @@
use actix_web::{
get,
web::{Data, Path, Query},
HttpRequest, HttpResponse,
};
use askama::Template;
use redis::AsyncCommands;
use serde::Deserialize;
use crate::{
check_page,
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters, paginate,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Deserialize)]
pub struct BoardQuery {
page: i64,
}
#[derive(Template)]
#[template(path = "board.html")]
struct BoardTemplate {
tcx: TemplateCtx,
board: Board,
threads: Vec<(Post, Vec<Post>)>,
page: i64,
pages: i64,
}
#[get("/boards/{board}")]
pub async fn board(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<String>,
query: Option<Query<BoardQuery>>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let board = path.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let count = ctx
.cache()
.get(format!("board_threads:{}", board.id))
.await?;
let page = query.map(|q| q.page).unwrap_or(1);
let pages = paginate(board.config.0.page_size, count);
check_page(page, pages, board.config.0.page_count)?;
let mut threads = Vec::new();
for thread in Post::read_board_page(&ctx, &board, page).await?.into_iter() {
let replies = thread.read_replies(&ctx).await?;
threads.push((thread, replies))
}
let template = BoardTemplate {
tcx,
board,
threads,
page,
pages,
};
template_response(template)
}

46
src/web/board_catalog.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,46 @@
use actix_web::{
get,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Template)]
#[template(path = "board_catalog.html")]
struct BoardCatalogTemplate {
tcx: TemplateCtx,
board: Board,
threads: Vec<Post>,
}
#[get("/boards/{board}/catalog")]
pub async fn board_catalog(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<String>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let board = path.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let threads = Post::read_board_catalog(&ctx, board.id.clone()).await?;
let template = BoardCatalogTemplate {
tcx,
board,
threads,
};
template_response(template)
}

24
src/web/index.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,24 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use super::tcx::TemplateCtx;
use crate::{ctx::Ctx, db::models::Post, error::NekrochanError, filters, web::template_response};
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
tcx: TemplateCtx,
posts: Vec<Post>,
files: Vec<Post>,
}
#[get("/")]
pub async fn index(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let posts = Post::read_latest(&ctx).await.unwrap_or_else(|_| Vec::new());
let files = Post::read_files(&ctx).await.unwrap_or_else(|_| Vec::new());
let template = IndexTemplate { tcx, posts, files };
template_response(template)
}

59
src/web/login.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,59 @@
use actix_web::{
cookie::Cookie, get, http::StatusCode, post, web::Data, HttpRequest, HttpResponse,
HttpResponseBuilder,
};
use askama::Template;
use pwhash::bcrypt::verify;
use serde::Deserialize;
use crate::{
auth::Claims,
ctx::Ctx,
db::models::Account,
error::NekrochanError,
qsform::QsForm,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Template)]
#[template(path = "login.html")]
struct LogInTemplate {
tcx: TemplateCtx,
}
#[get("/login")]
pub async fn login_get(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let template = LogInTemplate { tcx };
template_response(template)
}
#[derive(Deserialize)]
pub struct LogInForm {
username: String,
password: String,
}
#[post("/login")]
pub async fn login_post(
ctx: Data<Ctx>,
QsForm(form): QsForm<LogInForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = Account::read(&ctx, form.username.clone())
.await?
.ok_or(NekrochanError::IncorrectCredentialError)?;
if !verify(form.password, &account.password) {
return Err(NekrochanError::IncorrectCredentialError);
}
let auth = Claims::new(account.username).encode(&ctx)?;
let res = HttpResponseBuilder::new(StatusCode::SEE_OTHER)
.append_header(("Location", "/staff/account"))
.cookie(Cookie::new("auth", auth))
.finish();
Ok(res)
}

13
src/web/logout.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,13 @@
use actix_web::{cookie::Cookie, get, http::StatusCode, HttpResponse, HttpResponseBuilder};
#[get("/logout")]
pub async fn logout() -> HttpResponse {
let mut auth = Cookie::named("auth");
auth.make_removal();
HttpResponseBuilder::new(StatusCode::SEE_OTHER)
.append_header(("Location", "/"))
.cookie(auth)
.finish()
}

45
src/web/mod.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,45 @@
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
use askama::Template;
pub mod actions;
pub mod board;
pub mod board_catalog;
pub mod index;
pub mod login;
pub mod logout;
pub mod overboard;
pub mod overboard_catalog;
pub mod staff;
pub mod tcx;
pub mod thread;
use self::tcx::TemplateCtx;
use crate::{ctx::Ctx, db::models::Ban, error::NekrochanError, filters};
#[derive(Template)]
#[template(path = "banned.html")]
struct BannedTemplate {
tcx: TemplateCtx,
ban: Ban,
}
pub async fn ban_response(
ctx: &Ctx,
req: &HttpRequest,
ban: Ban,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(ctx, req).await?;
template_response(BannedTemplate { tcx, ban })
}
pub fn template_response<T>(template: T) -> Result<HttpResponse, NekrochanError>
where
T: Template,
{
let res = HttpResponseBuilder::new(StatusCode::OK)
.append_header(("Content-Type", "text/html"))
.body(template.render()?);
Ok(res)
}

67
src/web/overboard.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,67 @@
use actix_web::{
get,
web::{Data, Query},
HttpRequest, HttpResponse,
};
use askama::Template;
use redis::AsyncCommands;
use serde::Deserialize;
use std::collections::HashMap;
use crate::{
check_page,
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters, paginate,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Deserialize)]
pub struct OverboardQuery {
page: i64,
}
#[derive(Template)]
#[template(path = "overboard.html")]
struct OverboardTemplate {
tcx: TemplateCtx,
boards: HashMap<String, Board>,
threads: Vec<(Post, Vec<Post>)>,
page: i64,
pages: i64,
}
#[get("/overboard")]
pub async fn overboard(
ctx: Data<Ctx>,
req: HttpRequest,
query: Option<Query<OverboardQuery>>,
) -> Result<HttpResponse, NekrochanError> {
let boards = Board::read_all_map(&ctx).await?;
let tcx = TemplateCtx::new(&ctx, &req).await?;
let count = ctx.cache().get("total_threads").await?;
let page = query.map(|q| q.page).unwrap_or(1);
let pages = paginate(15, count);
check_page(page, pages, None)?;
let mut threads = Vec::new();
for thread in Post::read_overboard_page(&ctx, page).await?.into_iter() {
let replies = thread.read_replies(&ctx).await?;
threads.push((thread, replies))
}
let template = OverboardTemplate {
tcx,
boards,
threads,
page,
pages,
};
template_response(template)
}

30
src/web/overboard_catalog.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,30 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Post,
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Template)]
#[template(path = "overboard_catalog.html")]
struct OverboardCatalogTemplate {
tcx: TemplateCtx,
threads: Vec<Post>,
}
#[get("/overboard/catalog")]
pub async fn overboard_catalog(
ctx: Data<Ctx>,
req: HttpRequest,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let threads = Post::read_overboard_catalog(&ctx).await?;
let template = OverboardCatalogTemplate { tcx, threads };
template_response(template)
}

28
src/web/staff/account.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,28 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Account,
error::NekrochanError,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/account.html")]
struct AccountTemplate {
tcx: TemplateCtx,
account: Account,
}
#[get("/staff/account")]
pub async fn account(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
let template = AccountTemplate { tcx, account };
template_response(template)
}

26
src/web/staff/accounts.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,26 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Account,
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Template)]
#[template(path = "staff/accounts.html")]
struct AccountsTemplate {
tcx: TemplateCtx,
accounts: Vec<Account>,
}
#[get("/staff/accounts")]
pub async fn accounts(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let accounts = Account::read_all(&ctx).await?;
let template = AccountsTemplate { tcx, accounts };
template_response(template)
}

55
src/web/staff/actions/add_banners.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,55 @@
use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm};
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use crate::{
ctx::Ctx,
db::models::{Board, File},
error::NekrochanError,
web::tcx::account_from_auth,
};
#[derive(MultipartForm)]
pub struct AddBannersForm {
board: Text<String>,
#[multipart(rename = "files[]")]
files: Vec<TempFile>,
}
#[post("/staff/actions/add-banners")]
pub async fn add_banners(
ctx: Data<Ctx>,
req: HttpRequest,
MultipartForm(form): MultipartForm<AddBannersForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().board_banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let board = form.board.0;
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let mut new_banners = board.banners.0.clone();
let added_banners = form.files;
let mut cfg = ctx.cfg.clone();
cfg.files.videos = false;
for banner in added_banners.into_iter() {
let file = File::new(&cfg, banner, false, false).await?;
new_banners.push(file)
}
board.update_banners(&ctx, new_banners).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", format!("/staff/banners/{}", board.id).as_str()))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,38 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use pwhash::bcrypt::{hash, verify};
use serde::Deserialize;
use crate::{ctx::Ctx, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth};
#[derive(Deserialize)]
pub struct ChangePasswordForm {
old_password: String,
new_password: String,
}
#[post("/staff/actions/change-password")]
pub async fn change_password(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<ChangePasswordForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !verify(form.old_password, &account.password) {
return Err(NekrochanError::IncorrectCredentialError);
}
if form.new_password.len() < 8 {
return Err(NekrochanError::PasswordFormatError);
}
let password = hash(form.new_password)?;
account.update_password(&ctx, password).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/account"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,48 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use pwhash::bcrypt::hash;
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct CreateAccountForm {
username: String,
password: String,
}
#[post("/staff/actions/create-account")]
pub async fn create_account(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<CreateAccountForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
let username = form.username.trim().to_owned();
let password = form.password.trim().to_owned();
if username.is_empty() || username.len() > 32 {
return Err(NekrochanError::UsernameFormatError);
}
if password.len() < 8 {
return Err(NekrochanError::PasswordFormatError);
}
let password = hash(password)?;
let _ = Account::create(&ctx, username, password).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/accounts"))
.finish();
Ok(res)
}

50
src/web/staff/actions/create_board.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,50 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct CreateBoardForm {
id: String,
name: String,
description: String,
}
#[post("/staff/actions/create-board")]
pub async fn create_board(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<CreateBoardForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
let id = form.id.trim().to_owned();
let name = form.name.trim().to_owned();
let description = form.description.trim().to_owned();
if id.is_empty() || id.len() > 16 {
return Err(NekrochanError::IdFormatError);
}
if name.is_empty() || name.len() > 32 {
return Err(NekrochanError::BoardNameFormatError);
}
if description.is_empty() || description.len() > 128 {
return Err(NekrochanError::DescriptionFormatError);
}
let _ = Board::create(&ctx, id, name, description).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/boards"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,23 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use crate::{ctx::Ctx, error::NekrochanError, web::tcx::account_from_auth};
#[post("/staff/actions/delete-account")]
pub async fn delete_account(
ctx: Data<Ctx>,
req: HttpRequest,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if account.perms().owner() {
return Err(NekrochanError::OwnerDeletionError);
}
account.delete(&ctx).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/logout"))
.finish();
Ok(res)
}

13
src/web/staff/actions/mod.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,13 @@
pub mod add_banners;
pub mod change_password;
pub mod create_account;
pub mod create_board;
pub mod delete_account;
pub mod remove_accounts;
pub mod remove_banners;
pub mod remove_bans;
pub mod remove_boards;
pub mod transfer_ownership;
pub mod update_board_config;
pub mod update_boards;
pub mod update_permissions;

Zobrazit soubor

@ -0,0 +1,42 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct RemoveAccountsForm {
#[serde(default)]
accounts: Vec<String>,
}
#[post("/staff/actions/remove-accounts")]
pub async fn remove_accounts(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<RemoveAccountsForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
for account in form.accounts.into_iter() {
if let Some(account) = Account::read(&ctx, account).await? {
if account.owner {
return Err(NekrochanError::OwnerDeletionError);
}
account.delete(&ctx).await?;
}
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/accounts"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,50 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct RemoveBannersForm {
board: String,
#[serde(default)]
banners: Vec<usize>,
}
#[post("/staff/actions/remove-banners")]
pub async fn remove_banners(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<RemoveBannersForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().board_banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let board = form.board;
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let old_banners = board.banners.0.clone();
let mut new_banners = Vec::new();
for (i, banner) in old_banners.into_iter().enumerate() {
if form.banners.contains(&i) {
banner.delete().await;
} else {
new_banners.push(banner);
}
}
board.update_banners(&ctx, new_banners).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", format!("/staff/banners/{}", board.id).as_str()))
.finish();
Ok(res)
}

37
src/web/staff/actions/remove_bans.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,37 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Ban, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct RemoveBansForm {
#[serde(default)]
bans: Vec<i32>,
}
#[post("/staff/actions/remove-bans")]
pub async fn remove_bans(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<RemoveBansForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().bans()) {
return Err(NekrochanError::InsufficientPermissionError);
}
for ban in form.bans.into_iter() {
if let Some(ban) = Ban::read_by_id(&ctx, ban).await? {
ban.delete(&ctx).await?;
}
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/bans"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,37 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct RemoveBoardsForm {
#[serde(default)]
boards: Vec<String>,
}
#[post("/staff/actions/remove-boards")]
pub async fn remove_boards(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<RemoveBoardsForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
for board in form.boards.into_iter() {
if let Some(board) = Board::read(&ctx, board).await? {
board.delete(&ctx).await?;
}
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/boards"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,39 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct TransferOwnershipForm {
account: String,
}
#[post("/staff/actions/transfer-ownership")]
pub async fn transfer_ownership(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<TransferOwnershipForm>,
) -> Result<HttpResponse, NekrochanError> {
let old_owner = account_from_auth(&ctx, &req).await?;
if !old_owner.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
let new_owner = form.account;
let new_owner = Account::read(&ctx, new_owner.clone())
.await?
.ok_or(NekrochanError::AccountNotFound(new_owner))?;
old_owner.update_owner(&ctx, false).await?;
new_owner.update_owner(&ctx, true).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/account"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,87 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
cfg::BoardCfg, ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct UpdateBoardConfigForm {
board: String,
anon_name: String,
page_size: i64,
page_count: i64,
file_limit: usize,
bump_limit: i32,
reply_limit: i32,
locked: Option<String>,
user_ids: Option<String>,
flags: Option<String>,
thread_captcha: String,
reply_captcha: String,
require_thread_content: Option<String>,
require_thread_file: Option<String>,
require_reply_content: Option<String>,
require_reply_file: Option<String>,
}
#[post("/staff/actions/update-board-config")]
pub async fn update_board_config(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<UpdateBoardConfigForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().board_config()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let board = form.board;
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let anon_name = form.anon_name;
let page_size = form.page_size;
let page_count = form.page_count;
let file_limit = form.file_limit;
let bump_limit = form.bump_limit;
let reply_limit = form.reply_limit;
let locked = form.locked.is_some();
let user_ids = form.user_ids.is_some();
let flags = form.flags.is_some();
let thread_captcha = form.thread_captcha;
let reply_captcha = form.reply_captcha;
let require_thread_content = form.require_thread_content.is_some();
let require_thread_file = form.require_thread_file.is_some();
let require_reply_content = form.require_reply_content.is_some();
let require_reply_file = form.require_reply_file.is_some();
let config = BoardCfg {
anon_name,
page_size,
page_count,
file_limit,
bump_limit,
reply_limit,
locked,
user_ids,
flags,
thread_captcha,
reply_captcha,
require_thread_content,
require_thread_file,
require_reply_content,
require_reply_file,
};
board.update_config(&ctx, config).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/boards"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,57 @@
use actix_web::{
post,
web::{Bytes, Data},
HttpRequest, HttpResponse,
};
use serde::Deserialize;
use serde_qs::Config;
use crate::{ctx::Ctx, db::models::Board, error::NekrochanError, web::tcx::account_from_auth};
#[derive(Deserialize)]
pub struct UpdateBoardsForm {
#[serde(default)]
boards: Vec<String>,
name: String,
description: String,
}
#[post("/staff/actions/update-boards")]
pub async fn update_boards(
ctx: Data<Ctx>,
req: HttpRequest,
bytes: Bytes,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
let config = Config::new(10, false);
let form: UpdateBoardsForm = config.deserialize_bytes(&bytes)?;
let name = form.name.trim().to_owned();
let description = form.description.trim().to_owned();
if name.is_empty() || name.len() > 32 {
return Err(NekrochanError::BoardNameFormatError);
}
if description.is_empty() || description.len() > 128 {
return Err(NekrochanError::DescriptionFormatError);
}
for board in form.boards.into_iter() {
if let Some(board) = Board::read(&ctx, board).await? {
board.update_name(&ctx, name.clone()).await?;
board.update_description(&ctx, description.clone()).await?;
}
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/boards"))
.finish();
Ok(res)
}

Zobrazit soubor

@ -0,0 +1,103 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use enumflags2::BitFlags;
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Account, error::NekrochanError, perms::Permissions, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct UpdatePermissionsForm {
account: String,
edit_posts: Option<String>,
manage_posts: Option<String>,
capcodes: Option<String>,
staff_log: Option<String>,
reports: Option<String>,
bans: Option<String>,
board_banners: Option<String>,
board_config: Option<String>,
bypass_bans: Option<String>,
bypass_board_lock: Option<String>,
bypass_thread_lock: Option<String>,
bypass_captcha: Option<String>,
}
#[post("/staff/actions/update-permissions")]
pub async fn update_permissions(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<UpdatePermissionsForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !account.perms().owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
let updated_account = form.account;
let updated_account = Account::read(&ctx, updated_account.clone())
.await?
.ok_or(NekrochanError::AccountNotFound(updated_account))?;
let mut permissions = BitFlags::empty();
if form.edit_posts.is_some() {
permissions |= Permissions::EditPosts
}
if form.manage_posts.is_some() {
permissions |= Permissions::ManagePosts
}
if form.capcodes.is_some() {
permissions |= Permissions::Capcodes
}
if form.staff_log.is_some() {
permissions |= Permissions::StaffLog
}
if form.reports.is_some() {
permissions |= Permissions::Reports
}
if form.bans.is_some() {
permissions |= Permissions::Bans
}
if form.board_banners.is_some() {
permissions |= Permissions::BoardBanners
}
if form.board_config.is_some() {
permissions |= Permissions::BoardConfig
}
if form.bypass_bans.is_some() {
permissions |= Permissions::BypassBans
}
if form.bypass_board_lock.is_some() {
permissions |= Permissions::BypassBoardLock
}
if form.bypass_thread_lock.is_some() {
permissions |= Permissions::BypassThreadLock
}
if form.bypass_captcha.is_some() {
permissions |= Permissions::BypassCaptcha
}
updated_account
.update_permissions(&ctx, permissions.bits())
.await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/accounts"))
.finish();
Ok(res)
}

46
src/web/staff/banners.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,46 @@
use actix_web::{
get,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Board,
error::NekrochanError,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/banners.html")]
struct BannersTemplate {
tcx: TemplateCtx,
board: Board,
}
#[get("/staff/banners/{board}")]
pub async fn banners(
ctx: Data<Ctx>,
req: HttpRequest,
board: Path<String>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().board_banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let board = board.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let template = BannersTemplate { tcx, board };
template_response(template)
}

35
src/web/staff/bans.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,35 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Ban,
error::NekrochanError,
filters,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/bans.html")]
struct BansTemplate {
tcx: TemplateCtx,
bans: Vec<Ban>,
}
#[get("/staff/bans")]
pub async fn bans(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().bans()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let bans = Ban::read_all(&ctx).await?;
let template = BansTemplate { tcx, bans };
template_response(template)
}

46
src/web/staff/board_config.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,46 @@
use actix_web::{
get,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Board,
error::NekrochanError,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/board-config.html")]
struct BannersTemplate {
tcx: TemplateCtx,
board: Board,
}
#[get("/staff/board-config/{board}")]
pub async fn board_config(
ctx: Data<Ctx>,
req: HttpRequest,
board: Path<String>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().board_config()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let board = board.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let template = BannersTemplate { tcx, board };
template_response(template)
}

38
src/web/staff/boards.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,38 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Board,
error::NekrochanError,
filters,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/boards.html")]
struct BoardsTemplate {
tcx: TemplateCtx,
boards: Vec<Board>,
}
#[get("/staff/boards")]
pub async fn boards(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner()
|| account.perms().board_config()
|| account.perms().board_banners())
{
return Err(NekrochanError::InsufficientPermissionError);
}
let boards = Board::read_all(&ctx).await?;
let template = BoardsTemplate { tcx, boards };
template_response(template)
}

9
src/web/staff/mod.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,9 @@
pub mod account;
pub mod accounts;
pub mod actions;
pub mod banners;
pub mod bans;
pub mod board_config;
pub mod boards;
pub mod permissions;
pub mod reports;

42
src/web/staff/permissions.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,42 @@
use actix_web::{
get,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Account,
error::NekrochanError,
web::{
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Template)]
#[template(path = "staff/permissions.html")]
struct PermissionsTemplate {
tcx: TemplateCtx,
account: Account,
}
#[get("/staff/permissions/{account}")]
pub async fn permissions(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<String>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let _ = account_from_auth(&ctx, &req).await?;
let account = path.into_inner();
let account = Account::read(&ctx, account.clone())
.await?
.ok_or(NekrochanError::AccountNotFound(account))?;
let template = PermissionsTemplate { tcx, account };
template_response(template)
}

23
src/web/staff/reports.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,23 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
error::NekrochanError,
web::{tcx::TemplateCtx, template_response},
};
#[allow(dead_code)]
#[derive(Template)]
#[template(path = "staff/reports.html")]
struct ReportsTemplate {
tcx: TemplateCtx,
}
#[get("/staff/reports")]
async fn reports(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let template = ReportsTemplate { tcx };
template_response(template)
}

113
src/web/tcx.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,113 @@
use actix_web::HttpRequest;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use redis::AsyncCommands;
use std::{
collections::HashSet,
net::{IpAddr, Ipv4Addr},
};
use crate::{
auth::Claims, cfg::Cfg, ctx::Ctx, db::models::Account, error::NekrochanError,
perms::PermissionWrapper,
};
pub struct TemplateCtx {
pub cfg: Cfg,
pub boards: Vec<String>,
pub logged_in: bool,
pub perms: PermissionWrapper,
pub name: Option<String>,
pub password: String,
pub ip: IpAddr,
pub yous: HashSet<String>,
}
impl TemplateCtx {
pub async fn new(ctx: &Ctx, req: &HttpRequest) -> Result<TemplateCtx, NekrochanError> {
let cfg = ctx.cfg.to_owned();
let boards = ctx.cache().lrange("board_ids", 0, -1).await?;
let account = account_from_auth_opt(ctx, req).await?;
let logged_in = account.is_some();
let perms = match &account {
Some(account) => account.perms(),
None => PermissionWrapper::new(0, false),
};
let name = req.cookie("name").map(|cookie| cookie.value().into());
let password_cookie = req.cookie("password").map(|cookie| cookie.value().into());
let password: String = match password_cookie {
Some(password) => password,
None => thread_rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
.collect(),
};
let (ip, _) = ip_from_req(req)?;
let yous = ctx.cache().zrange(format!("yous:{ip}"), 0, -1).await?;
let tcx = Self {
cfg,
boards,
logged_in,
perms,
name,
password,
ip,
yous,
};
Ok(tcx)
}
}
pub async fn account_from_auth(ctx: &Ctx, req: &HttpRequest) -> Result<Account, NekrochanError> {
let account = account_from_auth_opt(ctx, req)
.await?
.ok_or(NekrochanError::NotLoggedInError)?;
Ok(account)
}
pub async fn account_from_auth_opt(
ctx: &Ctx,
req: &HttpRequest,
) -> Result<Option<Account>, NekrochanError> {
let account = match req.cookie("auth") {
Some(auth) => {
let claims = Claims::decode(ctx, auth.value())?;
let account = Account::read(ctx, claims.sub)
.await?
.ok_or(NekrochanError::InvalidAuthError)?;
Some(account)
}
None => None,
};
Ok(account)
}
pub fn ip_from_req(req: &HttpRequest) -> Result<(IpAddr, String), NekrochanError> {
let ip = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
// let ip = req
// .headers()
// .get("X-Real-IP")
// .ok_or(NekrochanError::HeaderError("X-Real-IP"))?
// .to_str()
// .map_err(|_| NekrochanError::HeaderError("X-Real-IP"))?
// .parse::<IpAddr>()?;
let country = req
.headers()
.get("X-Country-Code")
.map(|hdr| hdr.to_str().unwrap_or("xx").to_ascii_lowercase())
.unwrap_or_else(|| "xx".into());
Ok((ip, country))
}

59
src/web/thread.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,59 @@
use actix_web::{
get,
http::StatusCode,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Template)]
#[template(path = "thread.html")]
struct ThreadTemplate {
tcx: TemplateCtx,
board: Board,
thread: Post,
replies: Vec<Post>,
}
#[get("/boards/{board}/{thread}")]
pub async fn thread(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<(String, i32)>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let (board, id) = path.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let thread = Post::read(&ctx, board.id.clone(), id)
.await?
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
if thread.thread.is_some() {
return Ok(HttpResponse::build(StatusCode::PERMANENT_REDIRECT)
.append_header(("Location", thread.post_url()))
.finish());
}
let replies = thread.read_replies(&ctx).await?;
let template = ThreadTemplate {
tcx,
board,
thread,
replies,
};
template_response(template)
}

binární
static/banner.gif Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 206 KiB

binární
static/favicon.ico Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 15 KiB

binární
static/flags/ad.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 643 B

binární
static/flags/ae.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 408 B

binární
static/flags/af.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 604 B

binární
static/flags/ag.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 591 B

binární
static/flags/ai.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 643 B

binární
static/flags/al.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 600 B

binární
static/flags/am.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 497 B

binární
static/flags/an.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 488 B

binární
static/flags/ao.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 428 B

binární
static/flags/ar.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 506 B

binární
static/flags/as.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 647 B

binární
static/flags/at.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 403 B

binární
static/flags/au.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 673 B

binární
static/flags/aw.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 524 B

binární
static/flags/ax.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 663 B

binární
static/flags/az.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 589 B

binární
static/flags/ba.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 593 B

binární
static/flags/bb.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 585 B

binární
static/flags/bd.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 504 B

binární
static/flags/be.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 449 B

binární
static/flags/bf.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 497 B

binární
static/flags/bg.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 462 B

binární
static/flags/bh.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 457 B

binární
static/flags/bi.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 675 B

binární
static/flags/bj.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 486 B

binární
static/flags/bm.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 611 B

binární
static/flags/bn.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 639 B

binární
static/flags/bo.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 500 B

binární
static/flags/br.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 593 B

binární
static/flags/bs.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 526 B

binární
static/flags/bt.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 631 B

binární
static/flags/bv.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 512 B

binární
static/flags/bw.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 443 B

binární
static/flags/by.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 514 B

binární
static/flags/bz.png Spustitelný soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 600 B

Některé soubory nejsou zobrazny, neboť je v této revizi změněno mnoho souborů Zobrazit více