Žijí v mých zdech

Tento commit je obsažen v:
sneedmaster 2024-03-16 12:16:46 +01:00
revize 115e78dc58
385 změnil soubory, kde provedl 13720 přidání a 0 odebrání

6
.gitignore vendorováno Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,6 @@
/pages/*.html
/target
/templates_min
/uploads
Nekrochan.toml
.env

3
.vscode/settings.json vendorováno Normální soubor
Zobrazit soubor

@ -0,0 +1,3 @@
{
"html.validate.styles": false
}

3411
Cargo.lock vygenerováno Spustitelný soubor

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

54
Cargo.toml Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,54 @@
[package]
name = "nekrochan"
version = "0.1.0"
edition = "2021"
[dependencies]
actix = "0.13.3"
actix-files = "0.6.2"
actix-multipart = "0.6.0"
actix-web = { version = "4.3.1", features = ["cookies"] }
actix-web-actors = "4.3.0"
askama = "0.12.0"
anyhow = "1.0.71"
captcha = "0.0.9"
chrono = { version = "0.4.31", features = ["serde", "unstable-locales"] }
chrono-tz = "0.8.5"
dotenv = "0.15.0"
enumflags2 = "0.7.7"
encoding = "0.2.33"
env_logger = "0.11.2"
glob = "0.3.1"
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"] }
regex = "1.10.2"
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"
uuid = { version = "1.7.0", features = ["v4"] }
[build-dependencies]
anyhow = "1.0.74"
fs_extra = "1.3.0"
glob = "0.3.1"
html-minifier = "5.0.0"
[profile.dev]
opt-level = 1

47
Nekrochan.toml.template Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,47 @@
[server]
port = ${PORT}
database_url = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
cache_url = "redis://${REDIS_HOST}/${REDIS_DB}"
[site]
name = "${SITE_NAME}"
description = "${SITE_DESCRIPTION}"
theme = "yotsuba.css"
links = []
noko = true
[secrets]
auth_token = "${AUTH_SECRET}"
secure_trip = "${TRIP_SECRET}"
user_id = "${UID_SECRET}"
[files]
videos = true
thumb_size = 150
max_size_mb = 50
max_height = 10000
max_width = 10000
cleanup_interval = 3600
[board_defaults]
anon_name = "Anonym"
page_size = 10
page_count = 20
file_limit = 1
bump_limit = 500
reply_limit = 1000
locked = false
user_ids = false
flags = false
thread_captcha = "off"
reply_captcha = "off"
board_theme = "yotsuba.css"
require_thread_content = true
require_thread_file = true
require_reply_content = false
require_reply_file = false
antispam = true
antispam_ip = 5
antispam_content = 10
antispam_both = 60
thread_cooldown = 60

5
README.md Normální soubor
Zobrazit soubor

@ -0,0 +1,5 @@
# czchan
100% český imidžbórdový skript
> 100% český přestože je kód anglicky...

2
askama.toml Normální soubor
Zobrazit soubor

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

39
build.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,39 @@
use anyhow::Error;
use fs_extra::dir::{copy, remove, CopyOptions};
use glob::glob;
use html_minifier::minify;
use std::{
fs::{read_to_string, File},
io::Write,
};
fn main() -> Result<(), Error> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=templates");
remove("templates_min")?;
copy(
"templates",
"templates_min",
&CopyOptions::new().copy_inside(true),
)?;
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', "").replace("&#32;", " ");
File::create(path)?.write_all(minified.as_bytes())?;
}
Ok(())
}

68
configure.sh Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,68 @@
set -e
echo "# Výběr portu"
read -p "Port serveru [7000]: " port
echo "# Konfigurace databáze"
read -p "Host databáze [localhost]: " db_host
read -p "Port databáze [5432]: " db_port
read -p "Uživatelské jméno: " db_user
if [ "$db_user" == "" ]
then
echo "Uživatelské jméno je povinné"
exit 1
fi
read -p "Heslo: " db_password
if [ "$db_user" == "" ]
then
echo "Heslo je povinné"
exit 1
fi
read -p "Jméno databáze: " db_name
if [ "$db_name" == "" ]
then
echo "Jméno databáze je povinné"
exit 1
fi
echo "# Konfigurace redisu"
read -p "Host redisu [localhost]: " redis_host
read -p "Číslo databáze [0]: " redis_db
echo "# Konfigurace stránky"
read -p "Jméno stránky: " site_name
if [ "$site_name" == "" ]
then
echo "Jméno stránky je povinné"
exit 1
fi
read -p "Popis stránky: " site_description
if [ "$site_description" == "" ]
then
echo "Popis stránky je povinný"
exit 1
fi
export PORT=${port:-7000}
export DB_HOST=${db_host:-localhost}
export DB_PORT=${db_host:-5432}
export DB_USER=${db_user}
export DB_PASSWORD=${db_password}
export DB_NAME=${db_name}
export REDIS_HOST=${redis_host:-localhost}
export REDIS_DB=${redis_db:-0}
export REDIS_HOST=${redis_host:-localhost}
export SITE_NAME=${site_name}
export SITE_DESCRIPTION=${site_description}
export AUTH_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
export TRIP_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
export UID_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
envsubst < Nekrochan.toml.template > Nekrochan.toml
mkdir -p ./uploads/thumb

Zobrazit soubor

@ -0,0 +1,28 @@
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
);

Zobrazit soubor

@ -0,0 +1,6 @@
ALTER TABLE boards DROP COLUMN banners;
CREATE TABLE banners (
id SERIAL NOT NULL PRIMARY KEY,
banner JSONB NOT NULL
);

Zobrazit soubor

@ -0,0 +1,8 @@
CREATE TABLE news (
id SERIAL NOT NULL PRIMARY KEY,
title VARCHAR(256) NOT NULL,
content TEXT NOT NULL,
content_nomarkup TEXT NOT NULL,
author VARCHAR(32) NOT NULL REFERENCES accounts(username),
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Zobrazit soubor

@ -0,0 +1,2 @@
ALTER TABLE bans DROP CONSTRAINT bans_issued_by_fkey;
ALTER TABLE news DROP CONSTRAINT news_author_fkey;

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)
}
}

79
src/cfg.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,79 @@
use anyhow::Error;
use serde::{Deserialize, Serialize};
use tokio::fs::read_to_string;
#[derive(Deserialize, Debug, 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, Debug, Clone)]
pub struct ServerCfg {
pub port: u16,
pub database_url: String,
pub cache_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SiteCfg {
pub name: String,
pub description: String,
pub theme: String,
pub links: Vec<Vec<(String, String)>>,
pub noko: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SecretsCfg {
pub auth_token: String,
pub secure_trip: String,
pub user_id: String,
}
#[derive(Deserialize, Debug, 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, Debug, 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 board_theme: String,
pub require_thread_content: bool,
pub require_thread_file: bool,
pub require_reply_content: bool,
pub require_reply_file: bool,
pub antispam: bool,
pub antispam_ip: i64,
pub antispam_content: i64,
pub antispam_both: i64,
pub thread_cooldown: i64,
}

48
src/ctx.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,48 @@
use actix::{Actor, Addr};
use anyhow::Error;
use redis::{aio::MultiplexedConnection, Client};
use sqlx::PgPool;
use std::net::SocketAddr;
use crate::{cfg::Cfg, live_hub::LiveHub};
#[derive(Clone)]
pub struct Ctx {
pub cfg: Cfg,
db: PgPool,
cache: MultiplexedConnection,
hub: Addr<LiveHub>,
}
impl Ctx {
pub async fn new(cfg: Cfg) -> Result<Self, Error> {
let db = PgPool::connect(&cfg.server.database_url).await?;
let client = Client::open(cfg.server.cache_url.as_str())?;
let cache = client.get_multiplexed_async_connection().await?;
let sync_cache = client.get_connection()?;
let hub = LiveHub::new(sync_cache).start();
Ok(Self {
cfg,
db,
cache,
hub,
})
}
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()
}
pub fn hub(&self) -> Addr<LiveHub> {
self.hub.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 {
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(())
}
}

64
src/db/banner.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,64 @@
use redis::AsyncCommands;
use sqlx::{query, query_as, types::Json};
use super::models::{Banner, File};
use crate::{ctx::Ctx, NekrochanError};
impl Banner {
pub async fn create(ctx: &Ctx, banner: File) -> Result<Self, NekrochanError> {
let banner: Self = query_as("INSERT INTO banners (banner) VALUES ($1) RETURNING *")
.bind(Json(banner))
.fetch_one(ctx.db())
.await?;
ctx.cache()
.zadd("banners", serde_json::to_string(&banner)?, banner.id)
.await?;
Ok(banner)
}
pub async fn read(ctx: &Ctx, id: i32) -> Result<Option<Self>, NekrochanError> {
let banners: Vec<String> = ctx.cache().zrangebyscore("banners", id, id).await?;
let json = banners.get(0);
let banner = match json {
Some(json) => Some(serde_json::from_str(json)?),
None => None,
};
Ok(banner)
}
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let banners_str: Vec<String> = ctx.cache().zrange("banners", 0, -1).await?;
let banners_json = format!("[{}]", banners_str.join(",")); // If it works, it works
let banners = serde_json::from_str(&banners_json)?;
Ok(banners)
}
pub async fn read_random(ctx: &Ctx) -> Result<Option<Self>, NekrochanError> {
let banner: Option<String> = ctx.cache().zrandmember("banners", None).await?;
let banner = match banner {
Some(json) => Some(serde_json::from_str(&json)?),
None => None,
};
Ok(banner)
}
pub async fn remove(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
self.banner.delete().await;
query("DELETE FROM banners WHERE id = $1")
.bind(self.id)
.execute(ctx.db())
.await?;
ctx.cache().zrembyscore("banners", self.id, self.id).await?;
Ok(())
}
}

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

@ -0,0 +1,249 @@
use redis::{cmd, AsyncCommands, Connection, JsonAsyncCommands, JsonCommands};
use sqlx::{query, query_as, types::Json};
use std::collections::HashMap;
use super::models::{Board, File};
use crate::{cfg::BoardCfg, ctx::Ctx, error::NekrochanError};
impl Board {
pub async fn create(
ctx: &Ctx,
id: String,
name: String,
description: String,
) -> Result<Self, NekrochanError> {
let config = Json(ctx.cfg.board_defaults.clone());
let board: Board = query_as("INSERT INTO boards (id, name, description, config) VALUES ($1, $2, $3, $4) RETURNING *")
.bind(id)
.bind(name)
.bind(description)
.bind(config)
.fetch_one(ctx.db())
.await?;
query(&format!(
r#"CREATE TABLE posts_{} (
id BIGSERIAL NOT NULL PRIMARY KEY,
board VARCHAR(16) NOT NULL DEFAULT '{}' REFERENCES boards(id),
thread BIGINT 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,
quotes BIGINT[] NOT NULL DEFAULT '{{}}',
sticky BOOLEAN NOT NULL DEFAULT false,
locked BOOLEAN NOT NULL DEFAULT false,
reported TIMESTAMPTZ DEFAULT NULL,
reports JSONB NOT NULL DEFAULT '[]'::jsonb,
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 fn read_sync(cache: &mut Connection, id: String) -> Result<Option<Self>, NekrochanError> {
let board: Option<String> = cache.json_get(format!("boards:{id}"), ".")?;
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 {
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 {
if let Some(board) = Self::read(ctx, id.clone()).await? {
boards.insert(id, board);
}
}
Ok(boards)
}
pub async fn read_post_count(&self, ctx: &Ctx) -> Result<i64, NekrochanError> {
let (count,) = query_as(&format!("SELECT last_value FROM posts_{}_id_seq", self.id))
.fetch_one(ctx.db())
.await?;
Ok(count)
}
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("DELETE FROM bans WHERE board = $1")
.bind(&self.id)
.execute(ctx.db())
.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(())
}
}
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(())
}

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

@ -0,0 +1,100 @@
use anyhow::Error;
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
use sha256::digest;
use sqlx::query_as;
use super::models::{Account, Banner, 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 {
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 {
ctx.cache()
.json_set(format!("boards:{}", board.id), ".", board)
.await?;
ctx.cache().lpush("board_ids", &board.id).await?;
}
let banners: Vec<Banner> = query_as("SELECT * FROM banners")
.fetch_all(ctx.db())
.await?;
for banner in &banners {
ctx.cache()
.zadd("banners", serde_json::to_string(banner)?, banner.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 {
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 {
let posts = Post::read_all(ctx, board.id.clone()).await?;
for post in posts {
let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id);
let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?;
if post.thread.is_none() {
let key = format!("last_thread:{}", post.ip);
let last_thread = ctx
.cache()
.get::<_, Option<i64>>(&key)
.await?
.unwrap_or_default();
let timestamp = post.created.timestamp_micros();
if timestamp > last_thread {
ctx.cache().set(key, timestamp).await?;
}
}
}
}
Ok(())
}

30
src/db/local_stats.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,30 @@
use sqlx::query_as;
use super::models::LocalStats;
use crate::{ctx::Ctx, error::NekrochanError};
impl LocalStats {
pub async fn read(ctx: &Ctx) -> Result<Self, NekrochanError> {
let (post_count,) = query_as(
"SELECT COALESCE(SUM(last_value)::bigint, 0) FROM pg_sequences WHERE sequencename LIKE 'posts_%_id_seq'",
)
.fetch_one(ctx.db())
.await?;
let (file_count, file_size) = query_as(
r#"SELECT COUNT(files), COALESCE(SUM((files->>'size')::bigint)::bigint, 0) FROM (
SELECT jsonb_array_elements(files) AS files FROM overboard
) flatten"#,
)
.fetch_one(ctx.db())
.await?;
let stats = Self {
post_count,
file_count,
file_size,
};
Ok(stats)
}
}

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

@ -0,0 +1,10 @@
pub mod cache;
pub mod models;
mod account;
mod ban;
mod banner;
mod board;
mod local_stats;
mod newspost;
mod post;

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

@ -0,0 +1,107 @@
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, Serialize, Deserialize, Clone)]
pub struct Account {
pub username: String,
pub password: String,
pub owner: bool,
pub permissions: Json<u64>,
pub created: DateTime<Utc>,
}
#[derive(FromRow, Serialize, Deserialize, Clone)]
pub struct Board {
pub id: String,
pub name: String,
pub description: String,
pub config: Json<BoardCfg>,
pub created: DateTime<Utc>,
}
#[derive(FromRow, 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, Clone)]
pub struct Post {
pub id: i64,
pub board: String,
pub thread: Option<i64>,
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 quotes: Vec<i64>,
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(FromRow, Serialize, Deserialize)]
pub struct Banner {
pub id: i32,
pub banner: Json<File>,
}
#[derive(FromRow)]
pub struct NewsPost {
pub id: i32,
pub title: String,
pub content: String,
pub content_nomarkup: String,
pub author: String,
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,
}
pub struct LocalStats {
pub post_count: i64,
pub file_count: i64,
pub file_size: i64,
}

74
src/db/newspost.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,74 @@
use sqlx::{query, query_as};
use super::models::NewsPost;
use crate::{ctx::Ctx, error::NekrochanError};
impl NewsPost {
pub async fn create(
ctx: &Ctx,
title: String,
content: String,
content_nomarkup: String,
author: String,
) -> Result<Self, NekrochanError> {
let newspost = query_as("INSERT INTO news (title, content, content_nomarkup, author) VALUES ($1, $2, $3, $4) RETURNING *")
.bind(title)
.bind(content)
.bind(content_nomarkup)
.bind(author)
.fetch_one(ctx.db())
.await?;
Ok(newspost)
}
pub async fn read(ctx: &Ctx, id: i32) -> Result<Option<Self>, NekrochanError> {
let newspost = query_as("SELECT * FROM news WHERE id = $1")
.bind(id)
.fetch_optional(ctx.db())
.await?;
Ok(newspost)
}
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let newsposts = query_as("SELECT * FROM news ORDER BY created DESC")
.fetch_all(ctx.db())
.await?;
Ok(newsposts)
}
pub async fn read_latest(ctx: &Ctx) -> Result<Option<Self>, NekrochanError> {
let newspost = query_as("SELECT * FROM news ORDER BY created DESC LIMIT 1")
.fetch_optional(ctx.db())
.await?;
Ok(newspost)
}
pub async fn update(
&self,
ctx: &Ctx,
content: String,
content_nomarkup: String,
) -> Result<(), NekrochanError> {
query("UPDATE news SET content = $1, content_nomarkup = $2 WHERE id = $3")
.bind(content)
.bind(content_nomarkup)
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query("DELETE FROM news WHERE id = $1")
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
}

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

@ -0,0 +1,594 @@
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,
live_hub::{PostCreatedMessage, PostRemovedMessage, PostUpdatedMessage},
GENERIC_PAGE_SIZE,
};
impl Post {
#[allow(clippy::too_many_arguments)]
pub async fn create(
ctx: &Ctx,
board: &Board,
thread: Option<i64>,
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,
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 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.to_lowercase())
);
let member = format!("{}/{}", board.id, post.id);
let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?;
if thread.is_none() {
ctx.cache()
.set(format!("last_thread:{ip}"), post.created.timestamp_micros())
.await?;
}
Ok(post)
}
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: i64) -> Result<Option<Self>, NekrochanError> {
let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", 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_{board}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC"#
))
.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(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.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_reports_page(ctx: &Ctx, page: i64) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE reports != '[]'::jsonb
ORDER BY jsonb_array_length(reports), reported DESC
LIMIT $1
OFFSET $2"#,
)
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_ip_page(
ctx: &Ctx,
ip: IpAddr,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE ip = $1
ORDER BY created DESC
LIMIT $2
OFFSET $3"#,
)
.bind(ip)
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.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 sticky DESC, created ASC",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
Ok(replies)
}
pub async fn read_replies_after(
&self,
ctx: &Ctx,
last: i64,
) -> Result<Vec<Self>, NekrochanError> {
let replies = query_as(&format!(
"SELECT * FROM posts_{} WHERE thread = $1 AND id > $2 ORDER BY created ASC",
self.board
))
.bind(self.id)
.bind(last)
.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 read_all_overboard(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as("SELECT * FROM overboard")
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_by_query(
ctx: &Ctx,
board: &Board,
query: String,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
"SELECT * FROM posts_{} WHERE LOWER(content_nomarkup) LIKE LOWER($1) ORDER BY created DESC LIMIT $2 OFFSET $3",
board.id
))
.bind(format!("%{query}%"))
.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_by_query_overboard(
ctx: &Ctx,
query: String,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts =
query_as("SELECT * FROM overboard WHERE LOWER(content_nomarkup) LIKE LOWER($1) ORDER BY created DESC LIMIT $2 OFFSET $3")
.bind(format!("%{query}%"))
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET user_id = $1 WHERE id = $2 RETURNING *",
self.board,
))
.bind(user_id)
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostCreatedMessage { post }).await?;
Ok(())
}
pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1 RETURNING *",
self.board
))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1 RETURNING *",
self.board
))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_content(
&self,
ctx: &Ctx,
content: String,
content_nomarkup: String,
) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3 RETURNING *",
self.board
))
.bind(content)
.bind(&content_nomarkup)
.bind(self.id)
.fetch_optional(ctx.db())
.await?;
let Some(post) = post else { return Ok(()) };
let old_key = format!(
"by_content:{}",
digest(self.content_nomarkup.to_lowercase())
);
let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase()));
let member = format!("{}/{}", self.board, self.id);
let score = Utc::now().timestamp_micros();
ctx.cache().zrem(old_key, &member).await?;
ctx.cache().zadd(new_key, &member, score).await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_quotes(&self, ctx: &Ctx, id: i64) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET quotes = array_append(quotes, $1) WHERE id = $2 RETURNING *",
self.board
))
.bind(id)
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).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;
}
let post = query_as(&format!(
"UPDATE posts_{} SET files = $1 WHERE id = $2 RETURNING *",
self.board
))
.bind(Json(files))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).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 ORDER BY id ASC",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
for post in &to_be_deleted {
for file in post.files.iter() {
file.delete().await;
}
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>");
let posts = query_as(&format!(
"UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *",
self.board, live_quote
))
.bind(live_quote)
.bind(dead_quote)
.fetch_all(ctx.db())
.await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let posts = query_as(&format!(
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes) RETURNING *",
self.board
))
.bind(id)
.fetch_all(ctx.db())
.await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id);
ctx.cache().zrem(ip_key, &member).await?;
ctx.cache().zrem(content_key, &member).await?;
ctx.hub()
.send(PostRemovedMessage { post: post.clone() })
.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 let Some(thread) = self.thread {
query(&format!(
"UPDATE posts_{} SET replies = replies - 1 WHERE id = $1",
self.board
))
.bind(thread)
.execute(ctx.db())
.await?;
} else {
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.0 {
file.delete().await;
}
query(&format!(
"UPDATE posts_{} SET files = '[]'::jsonb WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
ctx.hub()
.send(PostUpdatedMessage { post: self.clone() })
.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 {
format!(
"/boards/{}/{}#{}",
self.board,
self.thread.unwrap_or(self.id),
self.id
)
}
pub fn post_url_notarget(&self) -> String {
format!("/boards/{}/{}", self.board, self.id)
}
pub fn thread_url(&self) -> String {
format!("/boards/{}/{}", self.board, self.thread.unwrap_or(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 {
thread.delete(ctx).await?;
}
Ok(())
}

269
src/error.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,269 @@
use actix_web::{http::StatusCode, ResponseError};
use log::error;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NekrochanError {
#[error("Účet '{}' neexistuje.", .0)]
AccountNotFound(String),
#[error("Tento ban už byl odvolán.")]
AlreadyAppealedError,
#[error("Odvolání můsí mít 1-1000 znaků.")]
BanAppealFormatError,
#[error("Žádný takový ban pro tuto IP adresu neexistuje.")]
BanNotFound,
#[error("Důvod banu musí mít 1-200 znaků.")]
BanReasonFormatError,
#[error("Nástěnka /{}/ je uzamčená.", .0)]
BoardLockError(String),
#[error("Jméno nástěnky musí mít 1-32 znaků.")]
BoardNameFormatError,
#[error("Nástěnka /{}/ neexistuje.", .0)]
BoardNotFound(String),
#[error("Capcode nesmí mít více než 32 znaků.")]
CapcodeFormatError,
#[error("Obsah nesmí mít více než 10000 znaků.")]
ContentFormatError,
#[error("Popis nesmí mít více než 128 znaků.")]
DescriptionFormatError,
#[error("E-mail nesmí mít více než 256 znaků.")]
EmailFormatError,
#[error("Příspěvek musí mít obsah nebo soubor.")]
EmptyPostError,
#[error("Chyba při zpracovávání souboru '{}': {}", .0, .1)]
FileError(String, &'static str),
#[error("Maximální počet souborů na této nástěnce je {}.", .0)]
FileLimitError(usize),
#[error("Tvůj příspěvek vypadá jako spam.")]
FloodError,
#[error("Domovní stránka vznikne po vytvoření nástěnky.")]
HomePageError,
#[error("ID musí mít 1-16 znaků a obsahovat pouze alfanumerické znaky.")]
IdFormatError,
#[error("Nesprávné řešení CAPTCHA.")]
IncorrectCaptchaError,
#[error("Nesprávné přihlašovací údaje.")]
IncorrectCredentialError,
#[error("Nesprávné heslo pro příspěvek #{}.", .0)]
IncorrectPasswordError(i64),
#[error("Nedostatečná oprávnění.")]
InsufficientPermissionError,
#[error("Server se připojil k 41 procentům.")]
InternalError,
#[error("Neplatný autentizační token. Vymaž soubory cookie.")]
InvalidAuthError,
#[error("Tato CAPTCHA vypršela nebo neexistuje.")]
InvalidCaptchaError,
#[error("Neplatná strana.")]
InvalidPageError,
#[error("Tento příspěvek není vlákno.")]
IsReplyError,
#[error("Obsah musí mít 1-20000 znaků.")]
NewsContentFormatError,
#[error("Titulek musí mít 1-100 znaků.")]
NewsTitleFormatError,
#[error("Tato nástěnka nevyžaduje CAPTCHA.")]
NoCaptchaError,
#[error("Příspěvek musí mít obsah.")]
NoContentError,
#[error("Příspěvek musí mít soubor.")]
NoFileError,
#[error("Nebyly vybrány žádné příspěvky.")]
NoPostsError,
#[error("Pro přístup se musíš přihlásit.")]
NotLoggedInError,
#[error("Nadnástěnka nebyla inicializována.")]
OverboardError,
#[error("Účet vlastníka nemůže být vymazán.")]
OwnerDeletionError,
#[error("Stránka {} neexistuje", .0)]
PageNotFound(String),
#[error("Heslo musí mít alespoň 8 znaků.")]
PasswordFormatError,
#[error("Jméno nesmí mít více než 32 znaků.")]
PostNameFormatError,
#[error("Příspěvek /{}/{} neexistuje.", .0, .1)]
PostNotFound(String, i64),
#[error("Hledaný termín musí mít 1-256 znaků.")]
QueryFormatError,
#[error("Vlákno dosáhlo limitu odpovědí.")]
ReplyLimitError,
#[error("Hlášení můsí mít 1-200 znaků.")]
ReportFormatError,
#[error("Na této nástěnce se musí vyplnit CAPTCHA.")]
RequiredCaptchaError,
#[error("Toto vlákno je uzamčené.")]
ThreadLockError,
#[error("Tento ban nelze odvolat.")]
UnappealableError,
#[error("Uživatelské jméno musí mít 1-32 znaků.")]
UsernameFormatError,
}
impl From<actix::MailboxError> for NekrochanError {
fn from(e: actix::MailboxError) -> Self {
error!("Internal server error: {e:#?}");
Self::InternalError
}
}
impl From<actix_web::Error> for NekrochanError {
fn from(e: actix_web::Error) -> Self {
error!("Internal server error: {e:#?}");
Self::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<regex::Error> for NekrochanError {
fn from(e: 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 {
Self::OverboardError
} else {
error!("{e:#?}");
Self::InternalError
}
}
}
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!("Some Mutex or Lock 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::AccountNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::AlreadyAppealedError => StatusCode::BAD_REQUEST,
NekrochanError::BanAppealFormatError => StatusCode::BAD_REQUEST,
NekrochanError::BanNotFound => StatusCode::NOT_FOUND,
NekrochanError::BanReasonFormatError => StatusCode::BAD_REQUEST,
NekrochanError::BoardLockError(_) => StatusCode::FORBIDDEN,
NekrochanError::BoardNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::BoardNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::CapcodeFormatError => StatusCode::BAD_REQUEST,
NekrochanError::ContentFormatError => StatusCode::BAD_REQUEST,
NekrochanError::DescriptionFormatError => StatusCode::BAD_REQUEST,
NekrochanError::EmailFormatError => StatusCode::BAD_REQUEST,
NekrochanError::EmptyPostError => StatusCode::BAD_REQUEST,
NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY,
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS,
NekrochanError::HomePageError => StatusCode::NOT_FOUND,
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
NekrochanError::IncorrectCredentialError => StatusCode::UNAUTHORIZED,
NekrochanError::IncorrectPasswordError(_) => StatusCode::UNAUTHORIZED,
NekrochanError::InsufficientPermissionError => StatusCode::FORBIDDEN,
NekrochanError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
NekrochanError::InvalidAuthError => StatusCode::UNAUTHORIZED,
NekrochanError::InvalidCaptchaError => StatusCode::BAD_REQUEST,
NekrochanError::InvalidPageError => StatusCode::BAD_REQUEST,
NekrochanError::IsReplyError => StatusCode::BAD_REQUEST,
NekrochanError::NewsContentFormatError => StatusCode::BAD_REQUEST,
NekrochanError::NewsTitleFormatError => StatusCode::BAD_REQUEST,
NekrochanError::NoCaptchaError => StatusCode::NOT_FOUND,
NekrochanError::NoContentError => StatusCode::BAD_REQUEST,
NekrochanError::NoFileError => StatusCode::BAD_REQUEST,
NekrochanError::NoPostsError => StatusCode::BAD_REQUEST,
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
NekrochanError::QueryFormatError => StatusCode::BAD_REQUEST,
NekrochanError::ReplyLimitError => StatusCode::FORBIDDEN,
NekrochanError::ReportFormatError => StatusCode::BAD_REQUEST,
NekrochanError::RequiredCaptchaError => StatusCode::UNAUTHORIZED,
NekrochanError::ThreadLockError => StatusCode::FORBIDDEN,
NekrochanError::UnappealableError => StatusCode::BAD_REQUEST,
NekrochanError::UsernameFormatError => StatusCode::BAD_REQUEST,
}
}
}

299
src/files.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,299 @@
use actix_multipart::form::tempfile::TempFile;
use chrono::Utc;
use std::process::Command;
use tokio::{
fs::{copy, remove_file},
task::spawn_blocking,
};
use crate::{cfg::Cfg, db::models::File, 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 (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)
};
let path = temp_file.file.path().to_string_lossy().to_string();
let (width, height) = if video {
process_video(cfg, original_name.clone(), path.clone(), thumb_name).await?
} else {
process_image(cfg, original_name.clone(), path.clone(), thumb_name).await?
};
copy(path, format!("./uploads/{timestamp}.{format}")).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,
path: String,
thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> {
let path_ = path.clone();
let identify_out = spawn_blocking(move || {
Command::new("identify")
.args(["-format", "%wx%h", &format!("{path_}[0]")])
.output()
})
.await??;
let invalid_dimensions = "imagemagick vrátil neplatné rozměry";
let out_string = String::from_utf8_lossy(&identify_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 obrázku jsou příliš velké",
));
}
let Some(thumb_name) = thumb_name else {
return Ok((width, height));
};
let thumb_size = cfg.files.thumb_size;
let output = spawn_blocking(move || {
Command::new("convert")
.arg(path)
.arg("-coalesce")
.arg("-thumbnail")
.arg(&format!("{thumb_size}x{thumb_size}>"))
.arg(&format!("./uploads/thumb/{thumb_name}"))
.output()
})
.await??;
if !output.status.success() {
println!("{}", String::from_utf8_lossy(&output.stderr));
return Err(NekrochanError::FileError(
original_name,
"nepodařilo se vytvořit náhled obrázku",
));
}
Ok((width, height))
}
async fn process_video(
cfg: &Cfg,
original_name: String,
path: String,
thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> {
let path_ = path.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",
&path_,
])
.output()
})
.await??;
if !ffprobe_out.status.success() {
return Err(NekrochanError::FileError(
original_name,
"nepodařilo se získat rozměry videa",
));
}
let invalid_dimensions = "ffmpeg 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 Some(thumb_name) = thumb_name else {
return Ok((width, height));
};
let thumb_size = cfg.files.thumb_size;
let output = spawn_blocking(move || {
Command::new("ffmpeg")
.args([
"-i",
&path,
"-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))
}

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

@ -0,0 +1,87 @@
use chrono::{DateTime, Locale, TimeZone, Utc};
use chrono_tz::Europe::Prague;
use lazy_static::lazy_static;
use regex::{Captures, Regex};
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_datetime(utc: &DateTime<Utc>) -> askama::Result<String> {
let time = Prague.from_utc_datetime(&utc.naive_utc());
let time = time
.format_localized("%d.%m.%Y (%a) %H:%M:%S", 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 && count != 0 {
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())
}

61
src/lib.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,61 @@
use askama::Template;
use db::models::{Board, Post};
use error::NekrochanError;
use web::tcx::TemplateCtx;
const GENERIC_PAGE_SIZE: i64 = 10;
pub mod auth;
pub mod cfg;
pub mod ctx;
pub mod db;
pub mod error;
pub mod files;
pub mod filters;
pub mod live_hub;
pub mod live_session;
pub mod markup;
pub mod perms;
pub mod qsform;
pub mod schedule;
pub mod trip;
pub mod web;
pub fn paginate(page_size: i64, count: i64) -> i64 {
let pages = count / page_size + (count % page_size).signum();
if pages == 0 {
1
} else {
pages
}
}
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(())
}
#[derive(Template)]
#[template(
ext = "html",
source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}"
)]
pub struct PostTemplate<'a> {
tcx: &'a TemplateCtx,
board: &'a Board,
post: &'a Post,
}

272
src/live_hub.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,272 @@
use actix::{Actor, Context, Handler, Message, Recipient};
use askama::Template;
use redis::Connection;
use serde_json::json;
use std::collections::HashMap;
use uuid::Uuid;
use crate::{
db::models::{Board, Post},
web::tcx::TemplateCtx,
PostTemplate,
};
#[derive(Message)]
#[rtype(result = "()")]
pub enum SessionMessage {
Data(String),
Stop,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct ConnectMessage {
pub uuid: Uuid,
pub thread: (String, i64),
pub tcx: TemplateCtx,
pub recv: Recipient<SessionMessage>,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct DisconnectMessage {
pub uuid: Uuid,
pub thread: (String, i64),
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct PostCreatedMessage {
pub post: Post,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct TargetedPostCreatedMessage {
pub uuid: Uuid,
pub post: Post,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct PostUpdatedMessage {
pub post: Post,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct PostRemovedMessage {
pub post: Post,
}
pub struct LiveHub {
pub cache: Connection,
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
pub recv_by_thread: HashMap<(String, i64), Vec<Uuid>>,
}
impl LiveHub {
pub fn new(cache: Connection) -> Self {
Self {
cache,
recv_by_uuid: HashMap::new(),
recv_by_thread: HashMap::new(),
}
}
}
impl Actor for LiveHub {
type Context = Context<Self>;
}
impl Handler<ConnectMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: ConnectMessage, _: &mut Self::Context) -> Self::Result {
self.recv_by_uuid.insert(msg.uuid, (msg.tcx, msg.recv));
match self.recv_by_thread.get_mut(&msg.thread) {
Some(vec) => {
vec.push(msg.uuid);
}
None => {
self.recv_by_thread.insert(msg.thread, vec![msg.uuid]);
}
}
}
}
impl Handler<DisconnectMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: DisconnectMessage, _: &mut Self::Context) -> Self::Result {
self.recv_by_uuid.remove(&msg.uuid);
let recv_by_thread = match self.recv_by_thread.get_mut(&msg.thread) {
Some(recv_by_thread) => recv_by_thread,
None => return,
};
*recv_by_thread = recv_by_thread
.iter()
.filter(|uuid| **uuid != msg.uuid)
.map(Uuid::clone)
.collect();
if recv_by_thread.is_empty() {
self.recv_by_thread.remove(&msg.thread);
}
}
}
impl Handler<PostCreatedMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: PostCreatedMessage, _: &mut Self::Context) -> Self::Result {
let post = msg.post;
let uuids = self
.recv_by_thread
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
let uuids = match uuids {
Some(uuids) => uuids,
None => return,
};
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
return;
};
for uuid in uuids {
let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else {
continue;
};
tcx.update_yous(&mut self.cache).ok();
let tcx = &tcx;
let board = &board;
let post = &post;
let id = post.id;
let html = PostTemplate { tcx, board, post }
.render()
.unwrap_or_default();
recv.do_send(SessionMessage::Data(
json!({ "type": "created", "id": id, "html": html }).to_string(),
));
}
}
}
impl Handler<TargetedPostCreatedMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: TargetedPostCreatedMessage, _: &mut Self::Context) -> Self::Result {
let post = msg.post;
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
return;
};
let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid) else {
return;
};
let id = post.id;
let tcx = &tcx;
let board = &board;
let post = &post;
let html = PostTemplate { tcx, board, post }
.render()
.unwrap_or_default();
recv.do_send(SessionMessage::Data(
json!({ "type": "created", "id": id, "html": html }).to_string(),
));
}
}
impl Handler<PostUpdatedMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: PostUpdatedMessage, _: &mut Self::Context) -> Self::Result {
let post = msg.post;
let uuids = self
.recv_by_thread
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
let uuids = match uuids {
Some(uuids) => uuids,
None => return,
};
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
return;
};
for uuid in uuids {
let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else {
continue;
};
tcx.update_yous(&mut self.cache).ok();
let id = post.id;
let tcx = &tcx;
let board = &board;
let post = &post;
let html = PostTemplate { tcx, post, board }
.render()
.unwrap_or_default();
recv.do_send(SessionMessage::Data(
json!({ "type": "updated", "id": id, "html": html }).to_string(),
));
}
}
}
impl Handler<PostRemovedMessage> for LiveHub {
type Result = ();
fn handle(&mut self, msg: PostRemovedMessage, _: &mut Self::Context) -> Self::Result {
let post = msg.post;
let uuids = self
.recv_by_thread
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
let uuids = match uuids {
Some(uuids) => uuids,
None => return,
};
if post.thread.is_none() {
for uuid in uuids {
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
continue;
};
recv.do_send(SessionMessage::Stop);
}
return;
}
for uuid in uuids {
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
continue;
};
recv.do_send(SessionMessage::Data(
json!({ "type": "removed", "id": post.id }).to_string(),
));
}
}
}

73
src/live_session.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,73 @@
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
use serde_json::json;
use uuid::Uuid;
use crate::{
live_hub::{ConnectMessage, DisconnectMessage, LiveHub, SessionMessage},
web::tcx::TemplateCtx,
};
pub struct LiveSession {
pub uuid: Uuid,
pub thread: (String, i64),
pub tcx: TemplateCtx,
pub hub: Addr<LiveHub>,
}
impl Actor for LiveSession {
type Context = WebsocketContext<Self>;
}
impl Handler<SessionMessage> for LiveSession {
type Result = ();
fn handle(&mut self, msg: SessionMessage, ctx: &mut Self::Context) -> Self::Result {
match msg {
SessionMessage::Data(data) => ctx.text(data),
SessionMessage::Stop => {
ctx.text(json!({ "type": "thread_removed" }).to_string());
self.finished(ctx)
}
};
}
}
impl StreamHandler<Result<WsMessage, ProtocolError>> for LiveSession {
fn started(&mut self, ctx: &mut Self::Context) {
let uuid = self.uuid;
let thread = self.thread.clone();
let tcx = self.tcx.clone();
let recv = ctx.address().recipient();
self.hub.do_send(ConnectMessage {
uuid,
thread,
tcx,
recv,
});
}
fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(WsMessage::Text(text)) => {
if text == "{\"type\":\"ping\"}" {
ctx.text("{\"type\":\"pong\"}");
}
}
Ok(WsMessage::Ping(data)) => ctx.pong(&data),
Ok(WsMessage::Close(_)) => self.finished(ctx),
_ => (),
}
}
fn finished(&mut self, ctx: &mut Self::Context) {
self.hub.do_send(DisconnectMessage {
uuid: self.uuid,
thread: self.thread.clone(),
});
ctx.close(None);
ctx.stop();
}
}

188
src/main.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,188 @@
use actix_files::{Files, NamedFile};
use actix_web::{
body::MessageBody,
dev::ServiceResponse,
get,
http::header::{HeaderValue, CACHE_CONTROL, PRAGMA},
middleware::{ErrorHandlerResponse, ErrorHandlers},
web::Data,
App, HttpRequest, HttpResponse, HttpServer, ResponseError,
};
use anyhow::Error;
use askama::Template;
use log::{error, info};
use nekrochan::{
cfg::Cfg,
ctx::Ctx,
db::{cache::init_cache, models::Banner},
error::NekrochanError,
schedule::s_cleanup_files,
web::{self, template_response},
};
use sqlx::migrate;
use std::{env::var, time::Duration};
use tokio::time::sleep;
#[actix_web::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 s_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::board::board)
.service(web::board_catalog::board_catalog)
.service(web::index::index)
.service(web::captcha::captcha)
.service(web::edit_posts::edit_posts)
.service(web::ip_posts::ip_posts)
.service(web::live::live)
.service(web::login::login_get)
.service(web::login::login_post)
.service(web::logout::logout)
.service(web::news::news)
.service(web::overboard::overboard)
.service(web::overboard_catalog::overboard_catalog)
.service(web::page::page)
.service(web::search::search)
.service(web::thread::thread)
.service(web::thread_json::thread_json)
.service(web::actions::appeal_ban::appeal_ban)
.service(web::actions::create_post::create_post)
.service(web::actions::edit_posts::edit_posts)
.service(web::actions::report_posts::report_posts)
.service(web::actions::staff_post_actions::staff_post_actions)
.service(web::actions::user_post_actions::user_post_actions)
.service(web::staff::account::account)
.service(web::staff::accounts::accounts)
.service(web::staff::bans::bans)
.service(web::staff::banners::banners)
.service(web::staff::board_config::board_config)
.service(web::staff::boards::boards)
.service(web::staff::edit_news::edit_news)
.service(web::staff::news::news)
.service(web::staff::permissions::permissions)
.service(web::staff::reports::reports)
.service(web::staff::actions::add_banners::add_banners)
.service(web::staff::actions::change_password::change_password)
.service(web::staff::actions::create_account::create_account)
.service(web::staff::actions::create_board::create_board)
.service(web::staff::actions::create_news::create_news)
.service(web::staff::actions::delete_account::delete_account)
.service(web::staff::actions::edit_news::edit_news)
.service(web::staff::actions::remove_accounts::remove_accounts)
.service(web::staff::actions::remove_banners::remove_banners)
.service(web::staff::actions::remove_bans::remove_bans)
.service(web::staff::actions::remove_boards::remove_boards)
.service(web::staff::actions::remove_news::remove_news)
.service(web::staff::actions::transfer_ownership::transfer_ownership)
.service(web::staff::actions::update_board_config::update_board_config)
.service(web::staff::actions::update_boards::update_boards)
.service(web::staff::actions::update_permissions::update_permissions)
.service(favicon)
.service(random_banner)
.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)
}
#[get("/random-banner")]
async fn random_banner(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let file = if let Some(banner) = Banner::read_random(&ctx).await? {
let timestamp = banner.banner.timestamp;
let format = &banner.banner.format;
NamedFile::open(format!("./uploads/{timestamp}.{format}"))?
} else {
NamedFile::open("./static/default-banner.png")?
};
let mut res = file.into_response(&req);
res.headers_mut().append(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
);
res.headers_mut()
.append(PRAGMA, HeaderValue::from_static("no-cache"));
Ok(res)
}
#[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 status = res.status();
let error_code = 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 mut res = template_response(&template)?;
*(res.status_mut()) = status;
let res = ServiceResponse::new(req, res).map_into_right_body();
Ok(ErrorHandlerResponse::Response(res))
}

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

@ -0,0 +1,229 @@
use lazy_static::lazy_static;
use regex::{Captures, Regex};
use sqlx::query_as;
use std::collections::HashMap;
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"(?mR)^&gt;(.*)$").unwrap();
pub static ref ORANGETEXT_REGEX: Regex = Regex::new(r"(?mR)^&lt;(.*)$").unwrap();
pub static ref REDTEXT_REGEX: Regex = Regex::new(r"&#x3D;&#x3D;(.+?)&#x3D;&#x3D;").unwrap();
pub static ref BLUETEXT_REGEX: Regex = Regex::new(r"--(.+?)--").unwrap();
pub static ref GLOWTEXT_REGEX: Regex = Regex::new(r"\%\%(.+?)\%\%").unwrap();
pub static ref UH_OH_TEXT_REGEX: Regex = Regex::new(r"\(\(\((.+?)\)\)\)").unwrap();
pub static ref SPOILER_REGEX: Regex = Regex::new(r"\|\|([\s\S]+?)\|\|").unwrap();
pub static ref URL_REGEX: Regex =
Regex::new(r"https?\:&#x2F;&#x2F;[^\s<>\[\]{}|\\^]+").unwrap();
pub static ref JANNYTEXT_REGEX: Regex = Regex::new(r"##(.+?)##").unwrap();
}
pub fn parse_name(
ctx: &Ctx,
perms: &PermissionWrapper,
anon_name: &str,
name: &str,
) -> Result<(String, Option<String>, Option<String>), NekrochanError> {
let Some(captures) = NAME_REGEX.captures(name) else {
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));
}
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() || !(perms.owner() || perms.custom_capcodes()) {
Some(capcode_fallback(perms.owner()))
} else {
if capcode.len() > 32 {
return Err(NekrochanError::CapcodeFormatError);
}
Some(capcode)
}
}
None => Some(capcode_fallback(perms.owner())),
},
None => None,
};
Ok((name, tripcode, capcode))
}
fn capcode_fallback(owner: bool) -> String {
if owner {
"Admin".into()
} else {
"Uklízeč".into()
}
}
pub async fn markup(
ctx: &Ctx,
perms: &PermissionWrapper,
board: Option<String>,
op: Option<i64>,
text: &str,
) -> Result<(String, Vec<Post>), NekrochanError> {
let text = escape_html(text);
let (text, quoted_posts) = if let Some(board) = board {
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 Ok(id) = id_raw.parse() else {
return format!("<span class=\"dead-quote\">&gt;&gt;{id_raw}</span>");
};
let post = quoted_posts.get(&id);
if let Some(post) = 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 {
""
}
)
} else {
format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>")
}
});
let quoted_posts = quoted_posts
.into_values()
.filter(|post| op == Some(post.thread.unwrap_or(post.id)))
.collect();
(text.to_string(), quoted_posts)
} else {
(text, Vec::new())
};
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>")
});
let text = if perms.owner() || perms.jannytext() {
JANNYTEXT_REGEX.replace_all(&text, "<span class=\"jannytext\">$1</span>")
} else {
text
};
Ok((text.to_string(), quoted_posts))
}
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<i64, Post>, NekrochanError> {
let mut quoted_ids: Vec<i64> = Vec::new();
for quote in QUOTE_REGEX.captures_iter(text) {
let id_raw = &quote[1];
let Ok(id) = id_raw.parse() else { continue };
quoted_ids.push(id);
}
if quoted_ids.is_empty() {
return Ok(HashMap::new());
}
let in_list = quoted_ids
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(",");
let quoted_posts = query_as(&format!(
"SELECT * FROM posts_{board} WHERE id IN ({in_list})"
))
.fetch_all(ctx.db())
.await?
.into_iter()
.map(|post: Post| (post.id, post))
.collect::<HashMap<_, _>>();
Ok(quoted_posts)
}

111
src/perms.rs Spustitelný soubor
Zobrazit soubor

@ -0,0 +1,111 @@
use enumflags2::{bitflags, BitFlags};
#[bitflags]
#[repr(u64)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Permissions {
EditPosts,
ManagePosts,
Capcodes,
CustomCapcodes,
StaffLog,
Reports,
Bans,
BoardBanners,
BoardConfig,
News,
Jannytext,
ViewIPs,
BypassBans,
BypassBoardLock,
BypassThreadLock,
BypassCaptcha,
BypassAntispam,
}
#[derive(Debug, Clone)]
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 custom_capcodes(&self) -> bool {
self.0.contains(Permissions::CustomCapcodes)
}
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 banners(&self) -> bool {
self.0.contains(Permissions::BoardBanners)
}
pub fn board_config(&self) -> bool {
self.0.contains(Permissions::BoardConfig)
}
pub fn news(&self) -> bool {
self.0.contains(Permissions::News)
}
pub fn jannytext(&self) -> bool {
self.0.contains(Permissions::Jannytext)
}
pub fn view_ips(&self) -> bool {
self.0.contains(Permissions::ViewIPs)
}
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)
}
pub fn bypass_antispam(&self) -> bool {
self.0.contains(Permissions::BypassAntispam)
}
}

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))
})
}
}

67
src/schedule.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,67 @@
use anyhow::Error;
use glob::glob;
use std::collections::HashSet;
use tokio::fs::remove_file;
use crate::{
ctx::Ctx,
db::models::{Banner, Board, Post},
};
pub async fn s_cleanup_files(ctx: &Ctx) -> Result<(), Error> {
let mut keep = HashSet::new();
let mut keep_thumbs = HashSet::new();
let banners = Banner::read_all(ctx).await?;
for banner in banners {
keep.insert(format!(
"{}.{}",
banner.banner.timestamp, banner.banner.format
));
}
let boards = Board::read_all(ctx).await?;
for board in boards {
let posts = Post::read_all(ctx, board.id.clone()).await?;
for post in posts {
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(())
}

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()
}

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

@ -0,0 +1,59 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use super::ActionTemplate;
use crate::{
ctx::Ctx,
db::models::Ban,
error::NekrochanError,
qsform::QsForm,
web::{
tcx::{ip_from_req, TemplateCtx},
template_response,
},
};
#[derive(Deserialize)]
pub struct AppealBanForm {
pub id: i32,
pub appeal: String,
}
#[post("/actions/appeal-ban")]
pub async fn appeal_ban(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<AppealBanForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let (ip, _) = ip_from_req(&req)?;
let ban = Ban::read_by_id(&ctx, form.id)
.await?
.ok_or(NekrochanError::BanNotFound)?;
if !ban.ip_range.contains(ip) {
return Err(NekrochanError::BanNotFound);
}
if ban.appeal.is_some() {
return Err(NekrochanError::AlreadyAppealedError);
}
if !ban.appealable {
return Err(NekrochanError::UnappealableError);
}
let appeal: String = form.appeal.trim().into();
if appeal.is_empty() || appeal.len() > 1000 {
return Err(NekrochanError::BanAppealFormatError);
}
ban.update_appeal(&ctx, appeal).await?;
template_response(&ActionTemplate {
tcx,
response: "Ban byl úspěšně odvolán.".into(),
})
}

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

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

76
src/web/actions/edit_posts.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,76 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use sqlx::query;
use std::{collections::HashMap, fmt::Write};
use crate::{
ctx::Ctx,
db::models::Post,
error::NekrochanError,
markup::markup,
qsform::QsForm,
web::{tcx::TemplateCtx, template_response},
};
use super::{get_posts_from_ids, ActionTemplate};
#[post("/actions/edit-posts")]
pub async fn edit_posts(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(edits): QsForm<HashMap<String, String>>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.edit_posts()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let ids = edits.keys().map(|s| s.to_owned()).collect::<Vec<String>>();
let posts = get_posts_from_ids(&ctx, &ids)
.await
.into_iter()
.map(|post| (format!("{}/{}", post.board, post.id), post))
.collect::<HashMap<String, Post>>();
let mut response = String::new();
let mut posts_edited = 0;
for (key, content_nomarkup) in edits {
let post = &posts[&key];
let content_nomarkup = content_nomarkup.trim();
let (content, quoted_posts) = markup(
&ctx,
&tcx.perms,
Some(post.board.clone()),
post.thread,
content_nomarkup,
)
.await?;
post.update_content(&ctx, content, content_nomarkup.into())
.await?;
query(&format!(
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes)",
post.board
))
.bind(post.id)
.execute(ctx.db())
.await?;
for quoted_post in quoted_posts {
quoted_post.update_quotes(&ctx, post.id).await?;
}
posts_edited += 1;
}
if posts_edited != 0 {
writeln!(&mut response, "[Úspěch] Upraveny příspěvky: {posts_edited}").ok();
}
let template = ActionTemplate { tcx, response };
template_response(&template)
}

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

@ -0,0 +1,46 @@
use askama::Template;
use sqlx::query_as;
use super::tcx::TemplateCtx;
use crate::{ctx::Ctx, db::models::Post};
pub mod appeal_ban;
pub mod create_post;
pub mod edit_posts;
pub mod report_posts;
pub mod staff_post_actions;
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 {
if let Some((board, id)) = parse_id(id) {
if let Ok(Some(post)) = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
.bind(board)
.bind(id)
.fetch_optional(ctx.db())
.await
{
posts.push(post);
}
}
}
posts
}
fn parse_id(id: &str) -> Option<(String, i64)> {
let (board, id) = id.split_once('/')?;
let board = board.to_owned();
let id = id.parse().ok()?;
Some((board, id))
}

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

@ -0,0 +1,110 @@
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();
if reason.is_empty() || reason.len() > 200 {
return Err(NekrochanError::ReportFormatError);
}
for post in &posts {
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.clone())
)
.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,369 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use chrono::{Duration, Utc};
use ipnetwork::IpNetwork;
use redis::AsyncCommands;
use serde::Deserialize;
use std::{collections::HashSet, fmt::Write, net::IpAddr};
use crate::{
ctx::Ctx,
db::models::{Account, Ban},
error::NekrochanError,
qsform::QsForm,
web::{
actions::{get_posts_from_ids, ActionTemplate},
tcx::{account_from_auth, TemplateCtx},
template_response,
},
};
#[derive(Deserialize)]
pub struct StaffPostActionsForm {
#[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 remove_by_ip_board: Option<String>,
pub remove_by_ip_global: Option<String>,
pub toggle_sticky: Option<String>,
pub toggle_lock: Option<String>,
pub remove_reports: Option<String>,
pub ban_user: Option<String>,
pub ban_reporters: Option<String>,
pub global_ban: Option<String>,
pub unappealable_ban: Option<String>,
pub ban_reason: Option<String>,
pub ban_duration: Option<u64>,
pub ban_range: Option<String>,
pub troll_user: Option<String>,
}
#[post("/actions/staff-post-actions")]
pub async fn staff_post_actions(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<StaffPostActionsForm>,
) -> 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 reports_removed = 0;
let mut bans_issued = 0;
let mut users_trolled = 0;
for post in &posts {
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.remove_by_ip_board.is_some() {
let key = format!("by_ip:{}", post.ip);
let ip_posts: Vec<String> = ctx.cache().zrange(key, 0, -1).await?;
let board_ip_posts = ip_posts
.into_iter()
.filter(|p| p.starts_with(&format!("{}/", post.board)))
.collect::<Vec<_>>();
for post in get_posts_from_ids(&ctx, &board_ip_posts).await {
post.delete(&ctx).await?;
posts_removed += 1;
}
}
if form.remove_by_ip_global.is_some() {
let key = format!("by_ip:{}", post.ip);
let ip_posts: Vec<String> = ctx.cache().zrange(key, 0, -1).await?;
for post in get_posts_from_ids(&ctx, &ip_posts).await {
post.delete(&ctx).await?;
posts_removed += 1;
}
}
if form.toggle_sticky.is_some() {
post.update_sticky(&ctx).await?;
stickies_toggled += 1;
}
if form.toggle_lock.is_some() {
if post.thread.is_some() {
writeln!(&mut response, "[Chyba] Odpověď nelze uzamknout.").ok();
} else {
post.update_lock(&ctx).await?;
locks_toggled += 1;
}
}
}
for post in &posts {
if form.remove_reports.is_some() {
if !(tcx.perms.owner() || tcx.perms.reports()) {
writeln!(&mut response, "[Chyba] Nemáš přístup k hlášením.").ok();
continue;
}
post.delete_reports(&ctx).await?;
reports_removed += post.reports.0.len();
}
}
let mut already_banned = HashSet::new();
for post in &posts {
if let ((Some(_), _) | (_, Some(_)), reason, duration, Some(range)) = (
(form.ban_user.clone(), form.ban_reporters.clone()),
form.ban_reason.clone().unwrap_or_default(),
form.ban_duration.unwrap_or_default(),
form.ban_range.clone(),
) {
if !(account.perms().owner() || account.perms().bans()) {
writeln!(&mut response, "[Chyba] Nemáš oprávnění vydat ban.").ok();
continue;
}
let mut ips_to_ban = HashSet::new();
if form.ban_user.is_some() && !already_banned.contains(&post.ip) {
ips_to_ban.insert(post.ip);
}
if form.ban_reporters.is_some() {
if !(tcx.perms.owner() || tcx.perms.reports()) {
writeln!(&mut response, "[Chyba] Nemáš přístup k hlášením.").ok();
continue;
}
ips_to_ban.extend(
post.reports
.0
.iter()
.map(|r| r.reporter_ip)
.filter(|ip| !already_banned.contains(ip)),
)
}
if ips_to_ban.is_empty() {
continue;
}
for ip in &ips_to_ban {
ban_ip(
&ctx,
&account,
&form,
*ip,
post.board.clone(),
reason.clone(),
duration,
&range,
)
.await?;
}
if form.ban_user.is_some() {
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=\"jannytext\">(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)</span>",
post.content
);
post.update_content(&ctx, content, content_nomarkup).await?;
}
bans_issued += ips_to_ban.len();
already_banned.extend(ips_to_ban);
}
}
for post in &posts {
if form.troll_user.is_some() {
if !(tcx.perms.owner() || tcx.perms.edit_posts()) {
writeln!(
&mut response,
"[Chyba] Nemáš oprávnění upravovat příspěvky."
)
.ok();
continue;
}
if !(tcx.perms.owner() || tcx.perms.view_ips()) {
writeln!(
&mut response,
"[Chyba] Nemáš oprávnění zobrazovat IP adresy."
)
.ok();
continue;
}
let content_nomarkup = format!("{}\n\n##({})##", post.content_nomarkup, post.ip);
let content = format!(
"{}\n\n<span class=\"jannytext\">({})</span>",
post.content, post.ip
);
post.update_content(&ctx, content, content_nomarkup).await?;
users_trolled += 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řipnuty/odepnuty příspěvky: {stickies_toggled}"
)
.ok();
}
if locks_toggled != 0 {
writeln!(
&mut response,
"[Úspěch] Zamčena/odemčena vlákna: {locks_toggled}"
)
.ok();
}
if reports_removed != 0 {
writeln!(
&mut response,
"[Úspěch] Odstraněna hlášení: {reports_removed}"
)
.ok();
}
if users_trolled != 0 {
writeln!(
&mut response,
"[Úspěch] Vytroleni uživatelé: {users_trolled}"
)
.ok();
}
if bans_issued != 0 {
writeln!(&mut response, "[Úspěch] Uděleny bany: {bans_issued}").ok();
}
let template = ActionTemplate { tcx, response };
template_response(&template)
}
#[allow(clippy::too_many_arguments)]
async fn ban_ip(
ctx: &Ctx,
account: &Account,
form: &StaffPostActionsForm,
ip: IpAddr,
board: String,
reason: String,
duration: u64,
range: &str,
) -> Result<(), NekrochanError> {
let account = account.username.clone();
let board = if form.global_ban.is_none() {
Some(board)
} else {
None
};
let prefix = if ip.is_ipv4() {
match range {
"lan" => 24,
"isp" => 16,
_ => 32,
}
} else {
match range {
"lan" => 48,
"isp" => 24,
_ => 128,
}
};
let ip_range = IpNetwork::new(ip, prefix)?;
let reason: String = reason.trim().into();
if reason.is_empty() || reason.len() > 200 {
return Err(NekrochanError::BanReasonFormatError);
}
let appealable = form.unappealable_ban.is_none();
let expires = if duration == 0 {
None
} else {
Some(Utc::now() + Duration::days(duration as i64))
};
Ban::create(ctx, account, board, ip_range, reason, appealable, expires).await?;
Ok(())
}

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

@ -0,0 +1,135 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use pwhash::bcrypt::verify;
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 UserPostActionsForm {
#[serde(default)]
pub posts: Vec<String>,
pub remove_posts: Option<String>,
pub remove_files: Option<String>,
pub toggle_spoiler: Option<String>,
#[serde(rename = "post_password")]
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 {
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.clone())
)
.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_or(1, |q| q.page);
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? {
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)
}

55
src/web/captcha.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,55 @@
use ::captcha::{gen, Difficulty};
use actix_web::{
get,
web::{Data, Json, Query},
};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use sha256::digest;
use crate::{ctx::Ctx, db::models::Board, error::NekrochanError};
#[derive(Deserialize)]
pub struct CaptchaQuery {
pub board: String,
pub reply: bool,
}
#[derive(Serialize)]
pub struct CaptchaResponse {
pub png: String,
pub id: String,
}
#[get("/captcha")]
pub async fn captcha(
ctx: Data<Ctx>,
Query(query): Query<CaptchaQuery>,
) -> Result<Json<CaptchaResponse>, NekrochanError> {
let board = Board::read(&ctx, query.board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(query.board))?;
let captcha = match board.config.thread_captcha.as_str() {
"easy" => gen(Difficulty::Easy),
"medium" => gen(Difficulty::Medium),
"hard" => gen(Difficulty::Hard),
_ => return Err(NekrochanError::NoCaptchaError),
};
// >YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE OR HOWEVER THE TRANS CHILDREN ARE PROTECTED
let png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?;
let board = board.id;
let id = digest(png.as_bytes());
let key = format!("captcha:{board}:{id}");
let solution = captcha.chars_as_string();
ctx.cache().set(&key, solution).await?;
ctx.cache().expire(&key, 600).await?;
let res = CaptchaResponse { png, id };
Ok(Json(res))
}

42
src/web/edit_posts.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,42 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use serde::Deserialize;
use crate::{
ctx::Ctx,
db::models::Post,
error::NekrochanError,
qsform::QsForm,
web::{actions::get_posts_from_ids, template_response, TemplateCtx},
};
#[derive(Deserialize)]
pub struct EditPostsForm {
#[serde(default)]
pub posts: Vec<String>,
}
#[derive(Template)]
#[template(path = "edit-posts.html")]
struct EditPostsTemplate {
tcx: TemplateCtx,
posts: Vec<Post>,
}
#[post("/edit-posts")]
pub async fn edit_posts(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<EditPostsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.edit_posts()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let posts = get_posts_from_ids(&ctx, &form.posts).await;
let template = EditPostsTemplate { tcx, posts };
template_response(&template)
}

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

@ -0,0 +1,43 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use super::tcx::TemplateCtx;
use crate::{
ctx::Ctx,
db::models::{Board, LocalStats, NewsPost},
error::NekrochanError,
filters,
web::template_response,
};
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
tcx: TemplateCtx,
news: Option<NewsPost>,
boards: Vec<Board>,
stats: LocalStats,
}
#[get("/")]
pub async fn index(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if tcx.boards.is_empty() {
return Err(NekrochanError::HomePageError);
}
let news = NewsPost::read_latest(&ctx).await?;
let boards = Board::read_all(&ctx).await?;
let stats = LocalStats::read(&ctx).await?;
let template = IndexTemplate {
tcx,
boards,
stats,
news,
};
template_response(&template)
}

65
src/web/ip_posts.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,65 @@
use actix_web::{
get,
web::{Data, Path, Query},
HttpRequest, HttpResponse,
};
use askama::Template;
use serde::Deserialize;
use std::{collections::HashMap, net::IpAddr};
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Deserialize)]
pub struct IpPostsQuery {
page: i64,
}
#[derive(Template)]
#[template(path = "ip-posts.html")]
struct IpPostsTemplate {
tcx: TemplateCtx,
ip: IpAddr,
boards: HashMap<String, Board>,
posts: Vec<Post>,
page: i64,
}
#[get("/ip-posts/{ip}")]
pub async fn ip_posts(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<IpAddr>,
query: Option<Query<IpPostsQuery>>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.view_ips()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let ip = path.into_inner();
let boards = Board::read_all_map(&ctx).await?;
let page = query.map_or(1, |q| q.page);
if page <= 0 {
return Err(NekrochanError::InvalidPageError);
}
let posts = Post::read_ip_page(&ctx, ip, page).await?;
let template = IpPostsTemplate {
tcx,
ip,
boards,
posts,
page,
};
template_response(&template)
}

62
src/web/live.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,62 @@
use actix_web::{
get,
web::{Data, Path, Payload},
HttpRequest, HttpResponse,
};
use actix_web_actors::ws;
use uuid::Uuid;
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
live_hub::TargetedPostCreatedMessage,
live_session::LiveSession,
web::tcx::TemplateCtx,
};
#[get("/live/{board}/{id}/{last}")]
pub async fn live(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<(String, i64, i64)>,
stream: Payload,
) -> Result<HttpResponse, NekrochanError> {
let (board, id, last) = path.into_inner();
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
let post = Post::read(&ctx, board.id.clone(), id)
.await?
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
if post.thread.is_some() {
return Err(NekrochanError::IsReplyError);
}
let uuid = Uuid::new_v4();
let thread = (board.id, id);
let tcx = TemplateCtx::new(&ctx, &req).await?;
let hub = ctx.hub();
let ws = LiveSession {
uuid,
thread,
tcx,
hub,
};
let res = ws::start(ws, &req, stream)?;
let new_replies = post.read_replies_after(&ctx, last).await?;
for post in new_replies {
ctx.hub()
.send(TargetedPostCreatedMessage { uuid, post })
.await?;
}
Ok(res)
}

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()
}

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

@ -0,0 +1,53 @@
pub mod actions;
pub mod board;
pub mod board_catalog;
pub mod captcha;
pub mod edit_posts;
pub mod index;
pub mod ip_posts;
pub mod live;
pub mod login;
pub mod logout;
pub mod news;
pub mod overboard;
pub mod overboard_catalog;
pub mod page;
pub mod search;
pub mod staff;
pub mod tcx;
pub mod thread;
pub mod thread_json;
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
use askama::Template;
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)
}

21
src/web/news.rs Normální soubor
Zobrazit soubor

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

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

@ -0,0 +1,68 @@
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},
GENERIC_PAGE_SIZE,
};
#[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_or(1, |q| q.page);
let pages = paginate(GENERIC_PAGE_SIZE, count);
check_page(page, pages, None)?;
let mut threads = Vec::new();
for thread in Post::read_overboard_page(&ctx, page).await? {
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)
}

36
src/web/page.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,36 @@
use actix_web::{
get,
web::{Data, Path},
HttpRequest, HttpResponse,
};
use askama::Template;
use tokio::fs::read_to_string;
use crate::{ctx::Ctx, error::NekrochanError, web::template_response};
use super::tcx::TemplateCtx;
#[derive(Template)]
#[template(path = "page.html")]
struct PageTemplate {
pub tcx: TemplateCtx,
pub name: String,
pub content: String,
}
#[get("/page/{name}")]
pub async fn page(
ctx: Data<Ctx>,
req: HttpRequest,
name: Path<String>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let name = name.into_inner();
let content = read_to_string(format!("./pages/{name}.html"))
.await
.map_err(|_| NekrochanError::PageNotFound(name.clone()))?;
let template = PageTemplate { tcx, name, content };
template_response(&template)
}

88
src/web/search.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,88 @@
use actix_web::{
get,
web::{Data, Query},
HttpRequest, HttpResponse,
};
use askama::Template;
use serde::Deserialize;
use std::collections::HashMap;
use super::tcx::TemplateCtx;
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters, web::template_response,
};
#[derive(Template)]
#[template(path = "search.html")]
struct SearchTemplate {
tcx: TemplateCtx,
board_opt: Option<Board>,
boards: HashMap<String, Board>,
query: String,
posts: Vec<Post>,
page: i64,
}
#[derive(Deserialize)]
pub struct SearchQuery {
board: Option<String>,
query: String,
page: Option<i64>,
}
#[get("/search")]
pub async fn search(
ctx: Data<Ctx>,
req: HttpRequest,
Query(query): Query<SearchQuery>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
let board_opt = if let Some(board) = query.board {
let board = Board::read(&ctx, board.clone())
.await?
.ok_or(NekrochanError::BoardNotFound(board))?;
Some(board)
} else {
None
};
let boards = if board_opt.is_none() {
Board::read_all_map(&ctx).await?
} else {
HashMap::new()
};
let page = query.page.unwrap_or(1);
if page <= 0 {
return Err(NekrochanError::InvalidPageError);
}
let query = query.query;
if query.is_empty() || query.len() > 256 {
return Err(NekrochanError::QueryFormatError);
}
let posts = if let Some(board) = &board_opt {
Post::read_by_query(&ctx, board, query.clone(), page).await?
} else {
Post::read_by_query_overboard(&ctx, query.clone(), page).await?
};
let template = SearchTemplate {
tcx,
board_opt,
boards,
query,
posts,
page,
};
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)
}

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

@ -0,0 +1,31 @@
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?;
if tcx.account.is_none() {
return Err(NekrochanError::NotLoggedInError);
}
let accounts = Account::read_all(&ctx).await?;
let template = AccountsTemplate { tcx, accounts };
template_response(&template)
}

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

@ -0,0 +1,42 @@
use actix_multipart::form::{tempfile::TempFile, MultipartForm};
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use crate::{
ctx::Ctx,
db::models::{Banner, File},
error::NekrochanError,
web::tcx::account_from_auth,
};
#[derive(MultipartForm)]
pub struct AddBannersForm {
#[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().banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let mut cfg = ctx.cfg.clone();
cfg.files.videos = false;
for file in form.files {
Banner::create(&ctx, File::new(&cfg, file, false, false).await?).await?;
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/banners"))
.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,49 @@
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,
#[serde(rename = "account_password")]
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)
}

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

@ -0,0 +1,56 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use lazy_static::lazy_static;
use regex::Regex;
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
};
lazy_static! {
static ref ID_REGEX: Regex = Regex::new(r"^\w{1,16}$").unwrap();
}
#[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_REGEX.is_match(&id) {
return Err(NekrochanError::IdFormatError);
}
if name.is_empty() || name.len() > 32 {
return Err(NekrochanError::BoardNameFormatError);
}
if 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,48 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::NewsPost, error::NekrochanError, markup::markup, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct CreateNewsForm {
title: String,
content: String,
}
#[post("/staff/actions/create-news")]
pub async fn create_news(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<CreateNewsForm>,
) -> Result<HttpResponse, NekrochanError> {
let account = account_from_auth(&ctx, &req).await?;
if !(account.perms().owner() || account.perms().news()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let title = form.title.trim().to_owned();
let content = form.content.trim().to_owned();
if title.is_empty() || title.len() > 100 {
return Err(NekrochanError::NewsTitleFormatError);
}
if content.is_empty() || content.len() > 10000 {
return Err(NekrochanError::NewsContentFormatError);
}
let content_nomarkup = content;
let (content, _) = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?;
NewsPost::create(&ctx, title, content, content_nomarkup, account.username).await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/news"))
.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)
}

71
src/web/staff/actions/edit_news.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,71 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use std::{collections::HashMap, fmt::Write};
use crate::{
ctx::Ctx,
db::models::NewsPost,
error::NekrochanError,
markup::markup,
qsform::QsForm,
web::{actions::ActionTemplate, tcx::TemplateCtx, template_response},
};
#[post("/staff/actions/edit-news")]
pub async fn edit_news(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(edits): QsForm<HashMap<i32, String>>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.news()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let mut news = Vec::new();
for id in edits.keys() {
if let Some(newspost) = NewsPost::read(&ctx, *id).await? {
news.push(newspost);
}
}
let news = news
.into_iter()
.map(|newspost| (newspost.id, newspost))
.collect::<HashMap<i32, NewsPost>>();
let mut response = String::new();
let mut news_edited = 0;
for (id, content_nomarkup) in edits {
let newspost = &news[&id];
if !tcx.perms.owner() && tcx.account != Some(newspost.author.clone()) {
writeln!(
&mut response,
"[Chyba] pouze vlastník nebo autor může upravit novinky."
)
.ok();
continue;
}
let content_nomarkup = content_nomarkup.trim();
let (content, _) = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?;
newspost
.update(&ctx, content, content_nomarkup.into())
.await?;
news_edited += 1;
}
if news_edited != 0 {
writeln!(&mut response, "[Úspěch] Upraveny novinky: {news_edited}").ok();
}
let template = ActionTemplate { tcx, response };
template_response(&template)
}

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

@ -0,0 +1,16 @@
pub mod add_banners;
pub mod change_password;
pub mod create_account;
pub mod create_board;
pub mod create_news;
pub mod delete_account;
pub mod edit_news;
pub mod remove_accounts;
pub mod remove_banners;
pub mod remove_bans;
pub mod remove_boards;
pub mod remove_news;
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 {
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,38 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx, db::models::Banner, error::NekrochanError, qsform::QsForm,
web::tcx::account_from_auth,
};
#[derive(Deserialize)]
pub struct RemoveBannersForm {
#[serde(default)]
banners: Vec<i32>,
}
#[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().banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
for id in form.banners {
if let Some(banner) = Banner::read(&ctx, id).await? {
banner.remove(&ctx).await?;
}
}
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/banners"))
.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 {
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 {
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,65 @@
use std::fmt::Write;
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use serde::Deserialize;
use crate::{
ctx::Ctx,
db::models::NewsPost,
error::NekrochanError,
qsform::QsForm,
web::{actions::ActionTemplate, template_response, TemplateCtx},
};
#[derive(Deserialize)]
pub struct RemoveNewsForm {
#[serde(default)]
pub news: Vec<i32>,
}
#[post("/staff/actions/remove-news")]
pub async fn remove_news(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<RemoveNewsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.news()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let mut news = Vec::new();
for id in form.news {
if let Some(newspost) = NewsPost::read(&ctx, id).await? {
news.push(newspost);
}
}
let mut response = String::new();
let mut news_removed = 0;
for newspost in news {
if !tcx.perms.owner() && tcx.account != Some(newspost.author.clone()) {
writeln!(
&mut response,
"[Chyba] pouze vlastník nebo autor může odstranit novinky."
)
.ok();
continue;
}
newspost.delete(&ctx).await?;
news_removed += 1;
}
if news_removed != 0 {
writeln!(&mut response, "[Úspěch] Odstraněny novinky: {news_removed}").ok();
}
let template = ActionTemplate { tcx, response };
template_response(&template)
}

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,105 @@
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,
board_theme: String,
require_thread_content: Option<String>,
require_thread_file: Option<String>,
require_reply_content: Option<String>,
require_reply_file: Option<String>,
antispam: Option<String>,
antispam_ip: i64,
antispam_content: i64,
antispam_both: i64,
thread_cooldown: i64,
}
#[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 board_theme = form.board_theme;
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 antispam = form.antispam.is_some();
let antispam_ip = form.antispam_ip;
let antispam_content = form.antispam_content;
let antispam_both = form.antispam_both;
let thread_cooldown = form.thread_cooldown;
let config = BoardCfg {
anon_name,
page_size,
page_count,
file_limit,
bump_limit,
reply_limit,
locked,
user_ids,
flags,
thread_captcha,
reply_captcha,
board_theme,
require_thread_content,
require_thread_file,
require_reply_content,
require_reply_file,
antispam,
antispam_ip,
antispam_content,
antispam_both,
thread_cooldown,
};
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.len() > 128 {
return Err(NekrochanError::DescriptionFormatError);
}
for board in form.boards {
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,128 @@
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>,
custom_capcodes: Option<String>,
staff_log: Option<String>,
reports: Option<String>,
bans: Option<String>,
banners: Option<String>,
board_config: Option<String>,
news: Option<String>,
jannytext: Option<String>,
view_ips: Option<String>,
bypass_bans: Option<String>,
bypass_board_lock: Option<String>,
bypass_thread_lock: Option<String>,
bypass_captcha: Option<String>,
bypass_antispam: 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.custom_capcodes.is_some() {
permissions |= Permissions::CustomCapcodes;
}
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.banners.is_some() {
permissions |= Permissions::BoardBanners;
}
if form.board_config.is_some() {
permissions |= Permissions::BoardConfig;
}
if form.news.is_some() {
permissions |= Permissions::News;
}
if form.jannytext.is_some() {
permissions |= Permissions::Jannytext;
}
if form.view_ips.is_some() {
permissions |= Permissions::ViewIPs;
}
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;
}
if form.bypass_antispam.is_some() {
permissions |= Permissions::BypassAntispam;
}
updated_account
.update_permissions(&ctx, permissions.bits())
.await?;
let res = HttpResponse::SeeOther()
.append_header(("Location", "/staff/accounts"))
.finish();
Ok(res)
}

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

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

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

@ -0,0 +1,31 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Ban,
error::NekrochanError,
filters,
web::{tcx::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?;
if !(tcx.perms.owner() || tcx.perms.bans()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let bans = Ban::read_all(&ctx).await?;
let template = BansTemplate { tcx, bans };
template_response(&template)
}

42
src/web/staff/board_config.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::Board,
error::NekrochanError,
web::{tcx::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?;
if !(tcx.perms.owner() || tcx.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)
}

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

@ -0,0 +1,31 @@
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use crate::{
ctx::Ctx,
db::models::Board,
error::NekrochanError,
filters,
web::{tcx::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?;
if !(tcx.perms.owner() || tcx.perms.board_config() || tcx.perms.banners()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let boards = Board::read_all(&ctx).await?;
let template = BoardsTemplate { tcx, boards };
template_response(&template)
}

49
src/web/staff/edit_news.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,49 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use askama::Template;
use serde::Deserialize;
use crate::{
ctx::Ctx,
db::models::NewsPost,
error::NekrochanError,
filters,
qsform::QsForm,
web::{template_response, TemplateCtx},
};
#[derive(Deserialize)]
pub struct EditNewsForm {
pub news: Vec<i32>,
}
#[derive(Template)]
#[template(path = "staff/edit-news.html")]
struct EditNewsTemplate {
tcx: TemplateCtx,
news: Vec<NewsPost>,
}
#[post("/staff/edit-news")]
pub async fn edit_news(
ctx: Data<Ctx>,
req: HttpRequest,
QsForm(form): QsForm<EditNewsForm>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.news()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let mut news = Vec::new();
for id in form.news {
if let Some(newspost) = NewsPost::read(&ctx, id).await? {
news.push(newspost);
}
}
let template = EditNewsTemplate { tcx, news };
template_response(&template)
}

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

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

31
src/web/staff/news.rs Normální soubor
Zobrazit soubor

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

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::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?;
if !tcx.perms.owner() {
return Err(NekrochanError::InsufficientPermissionError);
}
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)
}

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

@ -0,0 +1,63 @@
use std::collections::HashMap;
use actix_web::{
get,
web::{Data, Query},
HttpRequest, HttpResponse,
};
use askama::Template;
use serde::Deserialize;
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};
#[derive(Deserialize)]
pub struct BoardQuery {
page: i64,
}
#[allow(dead_code)]
#[derive(Template)]
#[template(path = "staff/reports.html")]
struct ReportsTemplate {
tcx: TemplateCtx,
boards: HashMap<String, Board>,
posts: Vec<Post>,
page: i64,
}
#[get("/staff/reports")]
async fn reports(
ctx: Data<Ctx>,
req: HttpRequest,
query: Option<Query<BoardQuery>>,
) -> Result<HttpResponse, NekrochanError> {
let tcx = TemplateCtx::new(&ctx, &req).await?;
if !(tcx.perms.owner() || tcx.perms.reports()) {
return Err(NekrochanError::InsufficientPermissionError);
}
let boards = Board::read_all_map(&ctx).await?;
let page = query.map_or(1, |q| q.page);
if page <= 0 {
return Err(NekrochanError::InvalidPageError);
}
let posts = Post::read_reports_page(&ctx, page).await?;
let template = ReportsTemplate {
tcx,
boards,
posts,
page,
};
template_response(&template)
}

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

@ -0,0 +1,116 @@
use actix_web::HttpRequest;
use redis::{AsyncCommands, Commands, Connection};
use sqlx::query_as;
use std::{
collections::HashSet,
net::{IpAddr, Ipv4Addr},
};
use crate::{
auth::Claims, cfg::Cfg, ctx::Ctx, db::models::Account, error::NekrochanError,
perms::PermissionWrapper,
};
#[derive(Debug, Clone)]
pub struct TemplateCtx {
pub cfg: Cfg,
pub boards: Vec<String>,
pub account: Option<String>,
pub perms: PermissionWrapper,
pub ip: IpAddr,
pub yous: HashSet<String>,
pub report_count: Option<i64>,
}
impl TemplateCtx {
pub async fn new(ctx: &Ctx, req: &HttpRequest) -> Result<TemplateCtx, NekrochanError> {
let cfg = ctx.cfg.clone();
let boards = ctx.cache().lrange("board_ids", 0, -1).await?;
let account = account_from_auth_opt(ctx, req).await?;
let perms = match &account {
Some(account) => account.perms(),
None => PermissionWrapper::new(0, false),
};
let (ip, _) = ip_from_req(req)?;
let yous = ctx.cache().zrange(format!("by_ip:{ip}"), 0, -1).await?;
let account = account.map(|account| account.username);
let report_count = if perms.owner() || perms.reports() {
let count: Option<Option<(i64,)>> = query_as("SELECT SUM(jsonb_array_length(reports)) FROM overboard WHERE reports != '[]'::jsonb")
.fetch_optional(ctx.db())
.await
.ok();
match count {
Some(Some((count,))) if count != 0 => Some(count),
_ => None,
}
} else {
None
};
let tcx = Self {
cfg,
boards,
perms,
ip,
yous,
account,
report_count,
};
Ok(tcx)
}
pub fn update_yous(&mut self, cache: &mut Connection) -> Result<(), NekrochanError> {
self.yous = cache.zrange(format!("by_ip:{}", self.ip), 0, -1)?;
Ok(())
}
}
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 = req
.connection_info()
.realip_remote_addr()
.map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |ip| {
ip.parse().unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
});
let country = req.headers().get("X-Country-Code").map_or_else(
|| "xx".into(),
|hdr| hdr.to_str().unwrap_or("xx").to_ascii_lowercase(),
);
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, i64)>,
) -> 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)
}

58
src/web/thread_json.rs Normální soubor
Zobrazit soubor

@ -0,0 +1,58 @@
use actix_web::{
get,
web::{Data, Json, Path},
HttpRequest,
};
use askama::Template;
use std::{collections::HashMap, vec};
use crate::{
ctx::Ctx,
db::models::{Board, Post},
error::NekrochanError,
web::tcx::TemplateCtx,
PostTemplate,
};
#[get("/thread-json/{board}/{id}")]
pub async fn thread_json(
ctx: Data<Ctx>,
req: HttpRequest,
path: Path<(String, i64)>,
) -> Result<Json<HashMap<i64, String>>, NekrochanError> {
let (board, id) = path.into_inner();
let tcx = TemplateCtx::new(&ctx, &req).await?;
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 Err(NekrochanError::IsReplyError);
}
let mut res = HashMap::new();
let replies = thread.read_replies(&ctx).await?;
let posts = [vec![thread], replies].concat();
for post in posts {
let id = post.id;
let tcx = &tcx;
let board = &board;
let post = &post;
let html = PostTemplate { tcx, board, post }
.render()
.unwrap_or_default();
res.insert(id, html);
}
Ok(Json(res))
}

binární
static/default-banner.png Normální soubor

Binární soubor nebyl zobrazen.

Za

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

binární
static/favicon.ico Normální soubor

Binární soubor nebyl zobrazen.

Za

Šířka:  |  Výška:  |  Velikost: 4.2 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

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