Žijí v mých zdech
6
.gitignore
vendorováno
Spustitelný soubor
@ -0,0 +1,6 @@
|
||||
/pages/*.html
|
||||
/target
|
||||
/templates_min
|
||||
/uploads
|
||||
Nekrochan.toml
|
||||
.env
|
3
.vscode/settings.json
vendorováno
Normální soubor
@ -0,0 +1,3 @@
|
||||
{
|
||||
"html.validate.styles": false
|
||||
}
|
3411
Cargo.lock
vygenerováno
Spustitelný soubor
54
Cargo.toml
Spustitelný 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
@ -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
@ -0,0 +1,5 @@
|
||||
# nekrochan
|
||||
|
||||
100% český imidžbórdový skript
|
||||
|
||||
> 100% český přestože je kód anglicky...
|
2
askama.toml
Normální soubor
@ -0,0 +1,2 @@
|
||||
[general]
|
||||
dirs = ["templates_min"]
|
39
build.rs
Spustitelný 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(" ", " ");
|
||||
|
||||
File::create(path)?.write_all(minified.as_bytes())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
68
configure.sh
Spustitelný 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
|
28
migrations/20230710121446_create_tables.sql
Spustitelný 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
|
||||
);
|
6
migrations/20231216092451_global_banners.sql
Normální soubor
@ -0,0 +1,6 @@
|
||||
ALTER TABLE boards DROP COLUMN banners;
|
||||
|
||||
CREATE TABLE banners (
|
||||
id SERIAL NOT NULL PRIMARY KEY,
|
||||
banner JSONB NOT NULL
|
||||
);
|
8
migrations/20231217111814_create_news.sql
Normální 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
|
||||
);
|
2
migrations/20231229180942_remove_references.sql
Normální 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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}\">>>{id}</a>");
|
||||
let dead_quote = format!("<span class=\"dead-quote\">>>{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
@ -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
@ -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
@ -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=".+">>>(\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
@ -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
@ -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
@ -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
@ -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
@ -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">>(\d+)").unwrap();
|
||||
pub static ref GREENTEXT_REGEX: Regex = Regex::new(r"(?mR)^>(.*)$").unwrap();
|
||||
pub static ref ORANGETEXT_REGEX: Regex = Regex::new(r"(?mR)^<(.*)$").unwrap();
|
||||
pub static ref REDTEXT_REGEX: Regex = Regex::new(r"==(.+?)==").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?\://[^\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\">>>{id_raw}</span>");
|
||||
};
|
||||
|
||||
let post = quoted_posts.get(&id);
|
||||
|
||||
if let Some(post) = post {
|
||||
format!(
|
||||
"<a class=\"quote\" href=\"{}\">>>{}</a>{}",
|
||||
post.post_url(),
|
||||
post.id,
|
||||
if op == Some(post.id) {
|
||||
" <span class=\"small\">(OP)</span>"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
} else {
|
||||
format!("<span class=\"dead-quote\">>>{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\">>$1</span>");
|
||||
let text = ORANGETEXT_REGEX.replace_all(&text, "<span class=\"orangetext\"><$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('&', "&")
|
||||
.replace('\'', "'")
|
||||
.replace('/', "/")
|
||||
.replace('`', "`")
|
||||
.replace('=', "=")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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 = "e[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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||
}
|
369
src/web/actions/staff_post_actions.rs
Normální 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||
}
|
38
src/web/staff/actions/change_password.rs
Spustitelný 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)
|
||||
}
|
49
src/web/staff/actions/create_account.rs
Spustitelný 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
@ -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)
|
||||
}
|
48
src/web/staff/actions/create_news.rs
Normální 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)
|
||||
}
|
23
src/web/staff/actions/delete_account.rs
Spustitelný 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
@ -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
@ -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;
|
42
src/web/staff/actions/remove_accounts.rs
Spustitelný 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)
|
||||
}
|
38
src/web/staff/actions/remove_banners.rs
Spustitelný 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
@ -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)
|
||||
}
|
37
src/web/staff/actions/remove_boards.rs
Spustitelný 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)
|
||||
}
|
65
src/web/staff/actions/remove_news.rs
Normální 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)
|
||||
}
|
39
src/web/staff/actions/transfer_ownership.rs
Spustitelný 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)
|
||||
}
|
105
src/web/staff/actions/update_board_config.rs
Spustitelný 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)
|
||||
}
|
57
src/web/staff/actions/update_boards.rs
Spustitelný 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)
|
||||
}
|
128
src/web/staff/actions/update_permissions.rs
Spustitelný 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
Za Šířka: | Výška: | Velikost: 19 KiB |
binární
static/favicon.ico
Normální soubor
Za Šířka: | Výška: | Velikost: 4.2 KiB |
binární
static/flags/ad.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 643 B |
binární
static/flags/ae.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 408 B |
binární
static/flags/af.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 604 B |
binární
static/flags/ag.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 591 B |
binární
static/flags/ai.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 643 B |
binární
static/flags/al.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 600 B |
binární
static/flags/am.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 497 B |