Nahrát existující kód
3354
Cargo.lock
vygenerováno
Spustitelný soubor
50
Cargo.toml
Spustitelný soubor
@ -0,0 +1,50 @@
|
|||||||
|
[package]
|
||||||
|
name = "nekrochan"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-files = "0.6.2"
|
||||||
|
actix-multipart = "0.6.0"
|
||||||
|
actix-web = { version = "4.3.1", features = ["cookies"] }
|
||||||
|
askama = "0.12.0"
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
captcha = "0.0.9"
|
||||||
|
chrono = { version = "0.4.31", features = ["serde", "unstable-locales"] }
|
||||||
|
dotenv = "0.15.0"
|
||||||
|
enumflags2 = "0.7.7"
|
||||||
|
encoding = "0.2.33"
|
||||||
|
env_logger = "0.10.0"
|
||||||
|
fancy-regex = "0.12.0"
|
||||||
|
glob = "0.3.1"
|
||||||
|
image = "0.24.7"
|
||||||
|
ipnetwork = "0.20.0"
|
||||||
|
jsonwebtoken = "9.1.0"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
log = "0.4.19"
|
||||||
|
num-traits = "0.2.16"
|
||||||
|
pwhash = "1.0.0"
|
||||||
|
rand = "0.8.5"
|
||||||
|
redis = { version = "0.24.0", features = ["aio", "json", "tokio-comp"] }
|
||||||
|
serde = "1.0.166"
|
||||||
|
serde_json = "1.0.100"
|
||||||
|
serde_qs = "0.12.0"
|
||||||
|
sha256 = "1.1.4"
|
||||||
|
sqlx = { version = "0.7.0", features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"postgres",
|
||||||
|
"json",
|
||||||
|
"chrono",
|
||||||
|
"ipnetwork",
|
||||||
|
] }
|
||||||
|
thiserror = "1.0.41"
|
||||||
|
tokio = { version = "1.29.1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
toml = "0.8.6"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
anyhow = "1.0.74"
|
||||||
|
glob = "0.3.1"
|
||||||
|
html-minifier = "4.0.0"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1
|
2
askama.toml
Normální soubor
@ -0,0 +1,2 @@
|
|||||||
|
[general]
|
||||||
|
dirs = ["templates_min"]
|
37
build.rs
Spustitelný soubor
@ -0,0 +1,37 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use glob::glob;
|
||||||
|
use html_minifier::minify;
|
||||||
|
use std::{
|
||||||
|
fs::{read_to_string, File},
|
||||||
|
io::Write,
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Error> {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
println!("cargo:rerun-if-changed=templates");
|
||||||
|
|
||||||
|
Command::new("rm").args(["-rf", "templates_min"]).output()?;
|
||||||
|
|
||||||
|
Command::new("cp")
|
||||||
|
.args(["-r", "templates", "templates_min"])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
let templates = glob("templates_min/**/*.html")?;
|
||||||
|
|
||||||
|
for path in templates {
|
||||||
|
let path = path?;
|
||||||
|
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = read_to_string(&path)?;
|
||||||
|
let minified = minify(html)?.replace('\n', "");
|
||||||
|
|
||||||
|
File::create(path)?.write_all(minified.as_bytes())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
1
migrations/20230710121446_create_tables.down.sql
Spustitelný soubor
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE accounts, boards, bans;
|
30
migrations/20230710121446_create_tables.up.sql
Spustitelný soubor
@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE accounts (
|
||||||
|
username VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||||
|
password VARCHAR(64) NOT NULL,
|
||||||
|
owner BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
permissions JSONB NOT NULL DEFAULT '0'::jsonb,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE boards (
|
||||||
|
id VARCHAR(16) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(32) NOT NULL,
|
||||||
|
description VARCHAR(128) NOT NULL,
|
||||||
|
banners JSONB NOT NULL,
|
||||||
|
config JSONB NOT NULL,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bans (
|
||||||
|
id SERIAL NOT NULL PRIMARY KEY,
|
||||||
|
ip_range INET NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
board VARCHAR(16) DEFAULT NULL REFERENCES boards(id),
|
||||||
|
issued_by VARCHAR(32) NOT NULL REFERENCES accounts(username),
|
||||||
|
appealable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
appeal TEXT DEFAULT NULL,
|
||||||
|
expires TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$XcxAe19B1eWC15sfnDRyiuiNLZIhdL7PMTnTmtTfglJIz0zOpN3oa', true, '16383'::jsonb);
|
39
src/auth.rs
Spustitelný soubor
@ -0,0 +1,39 @@
|
|||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::{ctx::Ctx, error::NekrochanError};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn new(sub: String) -> Self {
|
||||||
|
Self { sub }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self, ctx: &Ctx) -> Result<String, NekrochanError> {
|
||||||
|
let header = Header::default();
|
||||||
|
let key = EncodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes());
|
||||||
|
|
||||||
|
let auth = encode(&header, &self, &key)?;
|
||||||
|
|
||||||
|
Ok(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(ctx: &Ctx, auth: &str) -> Result<Self, NekrochanError> {
|
||||||
|
let key = DecodingKey::from_secret(ctx.cfg.secrets.auth_token.as_bytes());
|
||||||
|
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.required_spec_claims = HashSet::from_iter(["sub".to_owned()]);
|
||||||
|
validation.validate_exp = false;
|
||||||
|
|
||||||
|
let claims = decode(auth, &key, &validation)
|
||||||
|
.map_err(|_| NekrochanError::InvalidAuthError)?
|
||||||
|
.claims;
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
}
|
71
src/cfg.rs
Spustitelný soubor
@ -0,0 +1,71 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::fs::read_to_string;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct Cfg {
|
||||||
|
pub server: ServerCfg,
|
||||||
|
pub site: SiteCfg,
|
||||||
|
pub secrets: SecretsCfg,
|
||||||
|
pub files: FilesCfg,
|
||||||
|
pub board_defaults: BoardCfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cfg {
|
||||||
|
pub async fn load(path: &str) -> Result<Self, Error> {
|
||||||
|
let cfg_string = read_to_string(path).await?;
|
||||||
|
let cfg: Cfg = toml::from_str(&cfg_string)?;
|
||||||
|
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ServerCfg {
|
||||||
|
pub port: u16,
|
||||||
|
pub database_url: String,
|
||||||
|
pub cache_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct SiteCfg {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub site_banner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct SecretsCfg {
|
||||||
|
pub auth_token: String,
|
||||||
|
pub secure_trip: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct FilesCfg {
|
||||||
|
pub videos: bool,
|
||||||
|
pub thumb_size: u32,
|
||||||
|
pub max_size_mb: usize,
|
||||||
|
pub max_height: u32,
|
||||||
|
pub max_width: u32,
|
||||||
|
pub cleanup_interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct BoardCfg {
|
||||||
|
pub anon_name: String,
|
||||||
|
pub page_size: i64,
|
||||||
|
pub page_count: i64,
|
||||||
|
pub file_limit: usize,
|
||||||
|
pub bump_limit: i32,
|
||||||
|
pub reply_limit: i32,
|
||||||
|
pub locked: bool,
|
||||||
|
pub user_ids: bool,
|
||||||
|
pub flags: bool,
|
||||||
|
pub thread_captcha: String,
|
||||||
|
pub reply_captcha: String,
|
||||||
|
pub require_thread_content: bool,
|
||||||
|
pub require_thread_file: bool,
|
||||||
|
pub require_reply_content: bool,
|
||||||
|
pub require_reply_file: bool,
|
||||||
|
}
|
36
src/ctx.rs
Spustitelný soubor
@ -0,0 +1,36 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use redis::{aio::MultiplexedConnection, Client};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::cfg::Cfg;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Ctx {
|
||||||
|
pub cfg: Cfg,
|
||||||
|
db: PgPool,
|
||||||
|
cache: MultiplexedConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx {
|
||||||
|
pub async fn new(cfg: Cfg) -> Result<Self, Error> {
|
||||||
|
let db = PgPool::connect(&cfg.server.database_url).await?;
|
||||||
|
let cache = Client::open(cfg.server.cache_url.as_str())?
|
||||||
|
.get_multiplexed_async_connection()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self { cfg, db, cache })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_addr(&self) -> SocketAddr {
|
||||||
|
SocketAddr::from(([127, 0, 0, 1], self.cfg.server.port))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db(&self) -> &PgPool {
|
||||||
|
&self.db
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache(&self) -> MultiplexedConnection {
|
||||||
|
self.cache.clone()
|
||||||
|
}
|
||||||
|
}
|
117
src/db/account.rs
Spustitelný soubor
@ -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.into_iter() {
|
||||||
|
let board = ban.board.clone();
|
||||||
|
|
||||||
|
ban_map.insert(board, ban);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ban_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_appeal(&self, ctx: &Ctx, appeal: String) -> Result<(), NekrochanError> {
|
||||||
|
query("UPDATE bans SET appeal = $1 WHERE id = $2")
|
||||||
|
.bind(appeal)
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
query("DELETE FROM bans WHERE id = $1")
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
281
src/db/board.rs
Spustitelný soubor
@ -0,0 +1,281 @@
|
|||||||
|
use captcha::{gen, Difficulty};
|
||||||
|
use rand::{seq::SliceRandom, thread_rng};
|
||||||
|
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
|
||||||
|
use sha256::digest;
|
||||||
|
use sqlx::{query, query_as, types::Json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::models::{Board, File};
|
||||||
|
use crate::{cfg::BoardCfg, ctx::Ctx, error::NekrochanError, CAPTCHA};
|
||||||
|
|
||||||
|
impl Board {
|
||||||
|
pub async fn create(
|
||||||
|
ctx: &Ctx,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
) -> Result<Self, NekrochanError> {
|
||||||
|
let banners = Json(Vec::<File>::new());
|
||||||
|
let config = Json(ctx.cfg.board_defaults.clone());
|
||||||
|
|
||||||
|
let board: Board = query_as("INSERT INTO boards (id, name, description, banners, config) VALUES ($1, $2, $3, $4, $5) RETURNING *")
|
||||||
|
.bind(id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.bind(banners)
|
||||||
|
.bind(config)
|
||||||
|
.fetch_one(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
r#"CREATE TABLE posts_{} (
|
||||||
|
id SERIAL NOT NULL PRIMARY KEY,
|
||||||
|
board VARCHAR(16) NOT NULL DEFAULT '{}' REFERENCES boards(id),
|
||||||
|
thread INT DEFAULT NULL REFERENCES posts_{}(id),
|
||||||
|
name VARCHAR(32) NOT NULL,
|
||||||
|
user_id VARCHAR(6) NOT NULL DEFAULT '000000',
|
||||||
|
tripcode VARCHAR(12) DEFAULT NULL,
|
||||||
|
capcode VARCHAR(32) DEFAULT NULL,
|
||||||
|
email VARCHAR(256) DEFAULT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_nomarkup TEXT NOT NULL,
|
||||||
|
files JSONB NOT NULL,
|
||||||
|
password VARCHAR(64) DEFAULT NULL,
|
||||||
|
country VARCHAR(2) NOT NULL,
|
||||||
|
ip INET NOT NULL,
|
||||||
|
bumps INT NOT NULL DEFAULT 0,
|
||||||
|
replies INT NOT NULL DEFAULT 0,
|
||||||
|
sticky BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
locked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
reported TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
reports JSONB NOT NULL DEFAULT '[]'::json,
|
||||||
|
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)"#,
|
||||||
|
board.id, board.id, board.id
|
||||||
|
))
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.set(format!("board_threads:{}", board.id), 0)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache().lpush("board_ids", &board.id).await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", board.id), ".", &board)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
cmd("SORT")
|
||||||
|
.arg("board_ids")
|
||||||
|
.arg("ALPHA")
|
||||||
|
.arg("STORE")
|
||||||
|
.arg("board_ids")
|
||||||
|
.query_async(&mut ctx.cache())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
update_overboard(ctx, Self::read_all(ctx).await?).await?;
|
||||||
|
|
||||||
|
Ok(board)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(ctx: &Ctx, id: String) -> Result<Option<Self>, NekrochanError> {
|
||||||
|
let board: Option<String> = ctx.cache().json_get(format!("boards:{}", id), ".").await?;
|
||||||
|
|
||||||
|
let board = match board {
|
||||||
|
Some(json) => Some(serde_json::from_str(&json)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(board)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let mut boards = Vec::new();
|
||||||
|
let ids: Vec<String> = ctx.cache().lrange("board_ids", 0, -1).await?;
|
||||||
|
|
||||||
|
for id in ids.into_iter() {
|
||||||
|
if let Some(board) = Self::read(ctx, id).await? {
|
||||||
|
boards.push(board);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(boards)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_all_map(ctx: &Ctx) -> Result<HashMap<String, Self>, NekrochanError> {
|
||||||
|
let mut boards = HashMap::new();
|
||||||
|
let ids: Vec<String> = ctx.cache().lrange("board_ids", 0, -1).await?;
|
||||||
|
|
||||||
|
for id in ids.into_iter() {
|
||||||
|
if let Some(board) = Self::read(ctx, id.clone()).await? {
|
||||||
|
boards.insert(id, board);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(boards)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_name(&self, ctx: &Ctx, name: String) -> Result<(), NekrochanError> {
|
||||||
|
query("UPDATE boards SET name = $1 WHERE id = $2")
|
||||||
|
.bind(&name)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", self.id), "name", &name)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_description(
|
||||||
|
&self,
|
||||||
|
ctx: &Ctx,
|
||||||
|
description: String,
|
||||||
|
) -> Result<(), NekrochanError> {
|
||||||
|
query("UPDATE boards SET description = $1 WHERE id = $2")
|
||||||
|
.bind(&description)
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", self.id), "description", &description)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_banners(
|
||||||
|
&self,
|
||||||
|
ctx: &Ctx,
|
||||||
|
banners: Vec<File>,
|
||||||
|
) -> Result<(), NekrochanError> {
|
||||||
|
query("UPDATE boards SET banners = $1 WHERE id = $2")
|
||||||
|
.bind(Json(&banners))
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", self.id), "banners", &banners)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_config(&self, ctx: &Ctx, config: BoardCfg) -> Result<(), NekrochanError> {
|
||||||
|
query("UPDATE boards SET config = $1 WHERE id = $2")
|
||||||
|
.bind(Json(&config))
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", self.id), "config", &config)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
let boards = Self::read_all(ctx)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|board| board.id != self.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
update_overboard(ctx, boards).await?;
|
||||||
|
|
||||||
|
query(&format!("DROP TABLE posts_{}", self.id))
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
query("DELETE FROM boards WHERE id = $1")
|
||||||
|
.bind(&self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache().del(format!("boards:{}", self.id)).await?;
|
||||||
|
ctx.cache().lrem("board_ids", 0, &self.id).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Board {
|
||||||
|
pub fn random_banner(&self) -> Option<File> {
|
||||||
|
self.banners
|
||||||
|
.choose(&mut thread_rng())
|
||||||
|
.map(|banner| banner.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thread_captcha(&self) -> Option<(String, String)> {
|
||||||
|
let captcha = match self.config.thread_captcha.as_str() {
|
||||||
|
"easy" => gen(Difficulty::Easy),
|
||||||
|
"medium" => gen(Difficulty::Medium),
|
||||||
|
"hard" => gen(Difficulty::Hard),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let base64 = captcha.as_base64()?;
|
||||||
|
|
||||||
|
let board = self.id.clone();
|
||||||
|
let difficulty = self.config.thread_captcha.clone();
|
||||||
|
let id = digest(base64.as_bytes());
|
||||||
|
|
||||||
|
let key = (board, difficulty, id.clone());
|
||||||
|
let solution = captcha.chars_as_string();
|
||||||
|
|
||||||
|
CAPTCHA.write().ok()?.insert(key, solution);
|
||||||
|
|
||||||
|
Some((id, base64))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reply_captcha(&self) -> Option<(String, String)> {
|
||||||
|
let captcha = match self.config.reply_captcha.as_str() {
|
||||||
|
"easy" => gen(Difficulty::Easy),
|
||||||
|
"medium" => gen(Difficulty::Medium),
|
||||||
|
"hard" => gen(Difficulty::Hard),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let base64 = captcha.as_base64()?;
|
||||||
|
|
||||||
|
let board = self.id.clone();
|
||||||
|
let difficulty = self.config.thread_captcha.clone();
|
||||||
|
let id = digest(base64.as_bytes());
|
||||||
|
|
||||||
|
let key = (board, difficulty, id.clone());
|
||||||
|
let solution = captcha.chars_as_string();
|
||||||
|
|
||||||
|
CAPTCHA.write().ok()?.insert(key, solution);
|
||||||
|
|
||||||
|
Some((id, base64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_overboard(ctx: &Ctx, boards: Vec<Board>) -> Result<(), NekrochanError> {
|
||||||
|
query("DROP VIEW IF EXISTS overboard")
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if boards.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let unions = boards
|
||||||
|
.into_iter()
|
||||||
|
.map(|board| format!("SELECT * FROM posts_{}", board.id))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" UNION ");
|
||||||
|
|
||||||
|
query(&format!("CREATE VIEW overboard AS {unions}"))
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
72
src/db/cache.rs
Normální soubor
@ -0,0 +1,72 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
|
||||||
|
use sha256::digest;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use super::models::{Account, Board, Post};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
|
pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> {
|
||||||
|
cmd("FLUSHDB").query_async(&mut ctx.cache()).await?;
|
||||||
|
|
||||||
|
let accounts: Vec<Account> = query_as("SELECT * FROM accounts")
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for account in accounts.iter() {
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("accounts:{}", account.username), ".", &account)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let boards: Vec<Board> = query_as("SELECT * FROM boards").fetch_all(ctx.db()).await?;
|
||||||
|
|
||||||
|
for board in boards.iter() {
|
||||||
|
ctx.cache()
|
||||||
|
.json_set(format!("boards:{}", board.id), ".", board)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache().lpush("board_ids", &board.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd("SORT")
|
||||||
|
.arg("board_ids")
|
||||||
|
.arg("ALPHA")
|
||||||
|
.arg("STORE")
|
||||||
|
.arg("board_ids")
|
||||||
|
.query_async(&mut ctx.cache())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache().set("total_threads", 0).await?;
|
||||||
|
|
||||||
|
for board in boards.iter() {
|
||||||
|
let (thread_count,): (i64,) = query_as(&format!(
|
||||||
|
"SELECT COUNT(id) FROM posts_{} WHERE thread IS NULL",
|
||||||
|
board.id
|
||||||
|
))
|
||||||
|
.fetch_one(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.cache().incr("total_threads", thread_count).await?;
|
||||||
|
ctx.cache()
|
||||||
|
.set(format!("board_threads:{}", board.id), thread_count)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for board in boards.iter() {
|
||||||
|
let posts = Post::read_all(ctx, board.id.clone()).await?;
|
||||||
|
|
||||||
|
for post in posts.into_iter() {
|
||||||
|
let ip_key = format!("by_ip:{}", post.ip);
|
||||||
|
let content_key = format!("by_content:{}", digest(post.content_nomarkup));
|
||||||
|
|
||||||
|
let member = format!("{}/{}", post.board, post.id);
|
||||||
|
let score = post.created.timestamp_micros();
|
||||||
|
|
||||||
|
ctx.cache().zadd(ip_key, &member, score).await?;
|
||||||
|
ctx.cache().zadd(content_key, &member, score).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
7
src/db/mod.rs
Spustitelný soubor
@ -0,0 +1,7 @@
|
|||||||
|
pub mod cache;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
|
mod account;
|
||||||
|
mod ban;
|
||||||
|
mod board;
|
||||||
|
mod post;
|
92
src/db/models.rs
Spustitelný soubor
@ -0,0 +1,92 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{types::Json, FromRow};
|
||||||
|
|
||||||
|
use crate::cfg::BoardCfg;
|
||||||
|
|
||||||
|
#[derive(FromRow, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub owner: bool,
|
||||||
|
pub permissions: Json<u64>,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct Board {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub banners: Json<Vec<File>>,
|
||||||
|
pub config: Json<BoardCfg>,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Ban {
|
||||||
|
pub id: i32,
|
||||||
|
pub ip_range: IpNetwork,
|
||||||
|
pub reason: String,
|
||||||
|
pub board: Option<String>,
|
||||||
|
pub issued_by: String,
|
||||||
|
pub appealable: bool,
|
||||||
|
pub appeal: Option<String>,
|
||||||
|
pub expires: Option<DateTime<Utc>>,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Report {
|
||||||
|
pub reason: String,
|
||||||
|
pub reporter_country: String,
|
||||||
|
pub reporter_ip: IpAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct LogRecord {
|
||||||
|
pub id: i32,
|
||||||
|
pub message: String,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize, Deserialize)]
|
||||||
|
pub struct Post {
|
||||||
|
pub id: i32,
|
||||||
|
pub board: String,
|
||||||
|
pub thread: Option<i32>,
|
||||||
|
pub name: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub tripcode: Option<String>,
|
||||||
|
pub capcode: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub content_nomarkup: String,
|
||||||
|
pub files: Json<Vec<File>>,
|
||||||
|
pub password: String,
|
||||||
|
pub country: String,
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub bumps: i32,
|
||||||
|
pub replies: i32,
|
||||||
|
pub sticky: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub reported: Option<DateTime<Utc>>,
|
||||||
|
pub reports: Json<Vec<Report>>,
|
||||||
|
pub bumped: DateTime<Utc>,
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct File {
|
||||||
|
pub original_name: String,
|
||||||
|
pub format: String,
|
||||||
|
pub thumb_format: Option<String>,
|
||||||
|
pub spoiler: bool,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
451
src/db/post.rs
Spustitelný soubor
@ -0,0 +1,451 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use redis::AsyncCommands;
|
||||||
|
use sha256::digest;
|
||||||
|
use sqlx::{query, query_as, types::Json};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use super::models::{Board, File, Post, Report};
|
||||||
|
use crate::{ctx::Ctx, error::NekrochanError};
|
||||||
|
|
||||||
|
impl Post {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn create(
|
||||||
|
ctx: &Ctx,
|
||||||
|
board: &Board,
|
||||||
|
thread: Option<i32>,
|
||||||
|
name: String,
|
||||||
|
tripcode: Option<String>,
|
||||||
|
capcode: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
content: String,
|
||||||
|
content_nomarkup: String,
|
||||||
|
files: Vec<File>,
|
||||||
|
password: String,
|
||||||
|
country: String,
|
||||||
|
ip: IpAddr,
|
||||||
|
bumpy_bump: bool,
|
||||||
|
) -> Result<Self, NekrochanError> {
|
||||||
|
let post: Post = query_as(&format!(
|
||||||
|
r#"INSERT INTO posts_{}
|
||||||
|
(thread, name, tripcode, capcode, email, content, content_nomarkup, files, password, country, ip)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *"#, board.id)
|
||||||
|
)
|
||||||
|
.bind(thread)
|
||||||
|
.bind(name)
|
||||||
|
.bind(tripcode)
|
||||||
|
.bind(capcode)
|
||||||
|
.bind(email)
|
||||||
|
.bind(content)
|
||||||
|
.bind(content_nomarkup)
|
||||||
|
.bind(Json(files))
|
||||||
|
.bind(password)
|
||||||
|
.bind(country)
|
||||||
|
.bind(ip)
|
||||||
|
.fetch_one(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(thread) = thread {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET replies = replies + 1 WHERE id = $1",
|
||||||
|
board.id
|
||||||
|
))
|
||||||
|
.bind(thread)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if bumpy_bump {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET bumps = bumps + 1, bumped = CURRENT_TIMESTAMP WHERE id = $1",
|
||||||
|
board.id
|
||||||
|
))
|
||||||
|
.bind(thread)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete_old_threads(ctx, board).await?;
|
||||||
|
|
||||||
|
ctx.cache().incr("total_threads", 1).await?;
|
||||||
|
ctx.cache()
|
||||||
|
.incr(format!("board_threads:{}", board.id), 1)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_key = format!("by_ip:{}", ip);
|
||||||
|
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
|
||||||
|
let member = format!("{}/{}", board.id, post.id);
|
||||||
|
let score = post.created.timestamp_micros();
|
||||||
|
|
||||||
|
ctx.cache().zadd(ip_key, &member, score).await?;
|
||||||
|
ctx.cache().zadd(content_key, &member, score).await?;
|
||||||
|
|
||||||
|
Ok(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_report(
|
||||||
|
&self,
|
||||||
|
ctx: &Ctx,
|
||||||
|
reason: String,
|
||||||
|
reporter_country: String,
|
||||||
|
reporter_ip: IpAddr,
|
||||||
|
) -> Result<(), NekrochanError> {
|
||||||
|
let mut reports = self.reports.clone();
|
||||||
|
|
||||||
|
reports.push(Report {
|
||||||
|
reason,
|
||||||
|
reporter_country,
|
||||||
|
reporter_ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET reported = CURRENT_TIMESTAMP, reports = $1 WHERE id = $2",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(reports)
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(ctx: &Ctx, board: String, id: i32) -> Result<Option<Self>, NekrochanError> {
|
||||||
|
let post = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
|
||||||
|
.bind(board)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_board_page(
|
||||||
|
ctx: &Ctx,
|
||||||
|
board: &Board,
|
||||||
|
page: i64,
|
||||||
|
) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(&format!(
|
||||||
|
r#"SELECT * FROM posts_{}
|
||||||
|
WHERE thread IS NULL
|
||||||
|
ORDER BY sticky DESC, bumped DESC
|
||||||
|
LIMIT $1
|
||||||
|
OFFSET $2"#,
|
||||||
|
board.id
|
||||||
|
))
|
||||||
|
.bind(board.config.0.page_size)
|
||||||
|
.bind((page - 1) * board.config.0.page_size)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_board_catalog(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(&format!(
|
||||||
|
r#"SELECT * FROM posts_{}
|
||||||
|
WHERE thread IS NULL
|
||||||
|
ORDER BY sticky DESC, bumped DESC"#,
|
||||||
|
board
|
||||||
|
))
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_overboard_page(ctx: &Ctx, page: i64) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(
|
||||||
|
r#"SELECT * FROM overboard
|
||||||
|
WHERE thread IS NULL
|
||||||
|
ORDER BY bumped DESC
|
||||||
|
LIMIT $1
|
||||||
|
OFFSET $2"#,
|
||||||
|
)
|
||||||
|
.bind(15)
|
||||||
|
.bind((page - 1) * 15)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_overboard_catalog(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(
|
||||||
|
r#"SELECT * FROM overboard
|
||||||
|
WHERE thread IS NULL
|
||||||
|
ORDER BY bumped DESC"#,
|
||||||
|
)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_latest(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(
|
||||||
|
r#"SELECT * FROM overboard
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT $1"#,
|
||||||
|
)
|
||||||
|
.bind(15)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_reports(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(
|
||||||
|
r#"SELECT * FROM overboard
|
||||||
|
WHERE reports != '[]'::jsonb
|
||||||
|
ORDER BY jsonb_array_length(reports), reported DESC"#,
|
||||||
|
)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_files(ctx: &Ctx) -> Result<Vec<Post>, NekrochanError> {
|
||||||
|
let posts = query_as(
|
||||||
|
r#"SELECT *
|
||||||
|
FROM overboard
|
||||||
|
WHERE files != '[]'::jsonb
|
||||||
|
ORDER BY created DESC
|
||||||
|
LIMIT 3"#,
|
||||||
|
)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_replies(&self, ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let replies = query_as(&format!(
|
||||||
|
"SELECT * FROM posts_{} WHERE thread = $1 ORDER BY created ASC",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(replies)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_all(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
|
||||||
|
let posts = query_as(&format!("SELECT * FROM posts_{}", board))
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET user_id = $1 WHERE id = $2",
|
||||||
|
self.board,
|
||||||
|
))
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_content(
|
||||||
|
&self,
|
||||||
|
ctx: &Ctx,
|
||||||
|
content: String,
|
||||||
|
content_nomarkup: String,
|
||||||
|
) -> Result<(), NekrochanError> {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(content)
|
||||||
|
.bind(&content_nomarkup)
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes()));
|
||||||
|
let new_key = format!("by_content:{}", digest(content_nomarkup.as_bytes()));
|
||||||
|
let member = format!("{}/{}", self.board, self.id);
|
||||||
|
let score = Utc::now().timestamp_micros();
|
||||||
|
|
||||||
|
ctx.cache().zrem(old_key, &member).await?;
|
||||||
|
ctx.cache().zadd(new_key, &member, score).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
let mut files = self.files.clone();
|
||||||
|
|
||||||
|
for file in files.iter_mut() {
|
||||||
|
file.spoiler = !file.spoiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET files = $1 WHERE id = $2",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(Json(files))
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
let to_be_deleted: Vec<Post> = query_as(&format!(
|
||||||
|
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for post in to_be_deleted.iter() {
|
||||||
|
let id = post.id;
|
||||||
|
let url = post.post_url();
|
||||||
|
|
||||||
|
let live_quote = format!("<a class=\"quote\" href=\"{url}\">>>{id}</a>");
|
||||||
|
let dead_quote = format!("<span class=\"dead-quote\">>>{id}</span>");
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET content = REPLACE(content, $1, $2)",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(live_quote)
|
||||||
|
.bind(dead_quote)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ip_key = format!("by_ip:{}", post.ip);
|
||||||
|
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
|
||||||
|
|
||||||
|
let member = format!("{}/{}", post.board, post.id);
|
||||||
|
|
||||||
|
ctx.cache().zrem(ip_key, &member).await?;
|
||||||
|
ctx.cache().zrem(content_key, &member).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let in_list = to_be_deleted
|
||||||
|
.iter()
|
||||||
|
.map(|post| (post.id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"DELETE FROM posts_{} WHERE id = ANY($1)",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(&in_list)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if self.thread.is_none() {
|
||||||
|
ctx.cache().decr("total_threads", 1).await?;
|
||||||
|
ctx.cache()
|
||||||
|
.decr(format!("board_threads:{}", self.board), 1)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_files(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
for file in self.files.iter() {
|
||||||
|
file.delete().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET files = '[]'::jsonb WHERE id = $1",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_reports(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET reported = NULL, reports = '[]'::jsonb WHERE id = $1",
|
||||||
|
self.board
|
||||||
|
))
|
||||||
|
.bind(self.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Post {
|
||||||
|
pub fn post_url(&self) -> String {
|
||||||
|
if let Some(thread) = self.thread {
|
||||||
|
format!("/boards/{}/{}#{}", self.board, thread, self.id)
|
||||||
|
} else {
|
||||||
|
format!("/boards/{}/{}#{}", self.board, self.id, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn post_url_notarget(&self) -> String {
|
||||||
|
format!("/boards/{}/{}", self.board, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_old_threads(ctx: &Ctx, board: &Board) -> Result<(), NekrochanError> {
|
||||||
|
let old_threads: Vec<Post> = query_as(&format!(
|
||||||
|
r#"SELECT * FROM posts_{}
|
||||||
|
WHERE thread IS NULL AND id NOT IN (
|
||||||
|
SELECT id
|
||||||
|
FROM (
|
||||||
|
SELECT id
|
||||||
|
FROM posts_{}
|
||||||
|
WHERE thread IS NULL
|
||||||
|
ORDER BY sticky DESC, bumped DESC
|
||||||
|
LIMIT $1
|
||||||
|
) catty
|
||||||
|
)"#,
|
||||||
|
board.id, board.id
|
||||||
|
))
|
||||||
|
.bind(board.config.0.page_size * board.config.0.page_count)
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for thread in old_threads.iter() {
|
||||||
|
thread.delete(ctx).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
233
src/error.rs
Spustitelný soubor
@ -0,0 +1,233 @@
|
|||||||
|
use actix_web::{http::StatusCode, ResponseError};
|
||||||
|
use log::error;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum NekrochanError {
|
||||||
|
#[error("Chyba při zpracovávání souboru '{}': {}", .0, .1)]
|
||||||
|
FileError(String, &'static str),
|
||||||
|
#[error("Uživatelské jméno musí mít 1-32 znaků.")]
|
||||||
|
UsernameFormatError,
|
||||||
|
#[error("Heslo musí mít alespoň 8 znaků.")]
|
||||||
|
PasswordFormatError,
|
||||||
|
#[error("ID musí mít 1-16 znaků.")]
|
||||||
|
IdFormatError,
|
||||||
|
#[error("Jméno nástěnky musí mít 1-32 znaků.")]
|
||||||
|
BoardNameFormatError,
|
||||||
|
#[error("Popis musí mít 1-128 znaků.")]
|
||||||
|
DescriptionFormatError,
|
||||||
|
#[error("Jméno nesmí mít více než 32 znaků.")]
|
||||||
|
PostNameFormatError,
|
||||||
|
#[error("Capcode nesmí mít více než 32 znaků.")]
|
||||||
|
CapcodeFormatError,
|
||||||
|
#[error("E-mail nesmí mít více než 256 znaků.")]
|
||||||
|
EmailFormatError,
|
||||||
|
#[error("Obsah nesmí mít více než 4000 znaků")]
|
||||||
|
ContentFormatError,
|
||||||
|
#[error("Nástěnka /{}/ neexistuje.", .0)]
|
||||||
|
BoardNotFound(String),
|
||||||
|
#[error("Účet '{}' neexistuje.", .0)]
|
||||||
|
AccountNotFound(String),
|
||||||
|
#[error("Příspěvek /{}/{} neexistuje.", .0, .1)]
|
||||||
|
PostNotFound(String, i32),
|
||||||
|
#[error("Nedostatečná oprávnění.")]
|
||||||
|
InsufficientPermissionError,
|
||||||
|
#[error("Nesprávné přihlašovací údaje.")]
|
||||||
|
IncorrectCredentialError,
|
||||||
|
#[error("Neplatná strana.")]
|
||||||
|
InvalidPageError,
|
||||||
|
#[error("Neplatný autentizační token. Vymaž soubory cookie.")]
|
||||||
|
InvalidAuthError,
|
||||||
|
#[error("Pro přístup se musíš přihlásit.")]
|
||||||
|
NotLoggedInError,
|
||||||
|
#[error("Účet vlastníka nemůže být vymazán.")]
|
||||||
|
OwnerDeletionError,
|
||||||
|
#[error("Reverzní proxy nevrátilo vyžadovanou hlavičku '{}'.", .0)]
|
||||||
|
HeaderError(&'static str),
|
||||||
|
#[error("Nástěnka /{}/ je uzamčená.", .0)]
|
||||||
|
BoardLockError(String),
|
||||||
|
#[error("Toto vlákno je uzamčené.")]
|
||||||
|
ThreadLockError,
|
||||||
|
#[error("Nelze vytvořit odpověď na odpověď.")]
|
||||||
|
ReplyReplyError,
|
||||||
|
#[error("Vlákno dosáhlo limitu odpovědí.")]
|
||||||
|
ReplyLimitError,
|
||||||
|
#[error("Příspěvek musí mít obsah.")]
|
||||||
|
NoContentError,
|
||||||
|
#[error("Příspěvek musí mít soubor.")]
|
||||||
|
NoFileError,
|
||||||
|
#[error("Příspěvek musí mít obsah nebo soubor.")]
|
||||||
|
EmptyPostError,
|
||||||
|
#[error("Na této nástěnce se musí vyplnit CAPTCHA.")]
|
||||||
|
RequiredCaptchaError,
|
||||||
|
#[error("Nesprávné řešení CAPTCHA.")]
|
||||||
|
IncorrectCaptchaError,
|
||||||
|
#[error("Tato CAPTCHA neexistuje nebo už byla vyřešena.")]
|
||||||
|
SolvedCaptchaError,
|
||||||
|
#[error("Nebyly vybrány žádné příspěvky.")]
|
||||||
|
NoPostsError,
|
||||||
|
#[error("Maximální počet souborů na této nástěnce je {}.", .0)]
|
||||||
|
FileLimitError(usize),
|
||||||
|
#[error("Nesprávné heslo pro příspěvek #{}.", .0)]
|
||||||
|
IncorrectPasswordError(i32),
|
||||||
|
// 500
|
||||||
|
#[error("Nadnástěnka nebyla inicializována.")]
|
||||||
|
OverboardError,
|
||||||
|
#[error("Server se připojil k 41 procentům.")]
|
||||||
|
InternalError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<askama::Error> for NekrochanError {
|
||||||
|
fn from(e: askama::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ipnetwork::IpNetworkError> for NekrochanError {
|
||||||
|
fn from(e: ipnetwork::IpNetworkError) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<jsonwebtoken::errors::Error> for NekrochanError {
|
||||||
|
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pwhash::error::Error> for NekrochanError {
|
||||||
|
fn from(e: pwhash::error::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<fancy_regex::Error> for NekrochanError {
|
||||||
|
fn from(e: fancy_regex::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<redis::RedisError> for NekrochanError {
|
||||||
|
fn from(e: redis::RedisError) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for NekrochanError {
|
||||||
|
fn from(e: serde_json::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_qs::Error> for NekrochanError {
|
||||||
|
fn from(e: serde_qs::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for NekrochanError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
let overboard_err = match e.as_database_error() {
|
||||||
|
Some(e) => e.message() == "relation \"overboard\" does not exist",
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !overboard_err {
|
||||||
|
error!("{e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
} else {
|
||||||
|
Self::OverboardError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for NekrochanError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::net::AddrParseError> for NekrochanError {
|
||||||
|
fn from(e: std::net::AddrParseError) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<std::sync::PoisonError<T>> for NekrochanError {
|
||||||
|
fn from(_: std::sync::PoisonError<T>) -> Self {
|
||||||
|
error!("CAPTCHA RwLock got poisoned or something.");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio::task::JoinError> for NekrochanError {
|
||||||
|
fn from(e: tokio::task::JoinError) -> Self {
|
||||||
|
error!("Internal server error: {e:#?}");
|
||||||
|
|
||||||
|
Self::InternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for NekrochanError {
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
NekrochanError::FileError(_, _) => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::UsernameFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::BoardNameFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::DescriptionFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::CapcodeFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::EmailFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::ContentFormatError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::BoardNotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
NekrochanError::AccountNotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
|
||||||
|
NekrochanError::InsufficientPermissionError => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::IncorrectCredentialError => StatusCode::UNAUTHORIZED,
|
||||||
|
NekrochanError::InvalidPageError => StatusCode::NOT_FOUND,
|
||||||
|
NekrochanError::InvalidAuthError => StatusCode::NOT_FOUND,
|
||||||
|
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
|
||||||
|
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::HeaderError(_) => StatusCode::BAD_GATEWAY,
|
||||||
|
NekrochanError::BoardLockError(_) => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::ThreadLockError => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::ReplyReplyError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::ReplyLimitError => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::NoContentError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::NoFileError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::EmptyPostError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::RequiredCaptchaError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
|
||||||
|
NekrochanError::SolvedCaptchaError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::NoPostsError => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
|
||||||
|
NekrochanError::IncorrectPasswordError(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
NekrochanError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
350
src/files.rs
Spustitelný soubor
@ -0,0 +1,350 @@
|
|||||||
|
use actix_multipart::form::tempfile::TempFile;
|
||||||
|
use anyhow::Error;
|
||||||
|
use chrono::Utc;
|
||||||
|
use glob::glob;
|
||||||
|
use image::io::Reader as ImageReader;
|
||||||
|
use std::{collections::HashSet, process::Command};
|
||||||
|
use tokio::{
|
||||||
|
fs::{remove_file, rename},
|
||||||
|
task::spawn_blocking,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cfg::Cfg,
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, File, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl File {
|
||||||
|
pub async fn new(
|
||||||
|
cfg: &Cfg,
|
||||||
|
temp_file: TempFile,
|
||||||
|
spoiler: bool,
|
||||||
|
thumb: bool,
|
||||||
|
) -> Result<Self, NekrochanError> {
|
||||||
|
let original_name = temp_file.file_name.unwrap_or_else(|| "unknown".into());
|
||||||
|
|
||||||
|
let mime = temp_file
|
||||||
|
.content_type
|
||||||
|
.ok_or(NekrochanError::FileError(
|
||||||
|
original_name.clone(),
|
||||||
|
"žádný mime typ",
|
||||||
|
))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (video, format) = match mime.as_str() {
|
||||||
|
"image/jpeg" => (false, "jpg"),
|
||||||
|
"image/pjpeg" => (false, "jpg"),
|
||||||
|
"image/png" => (false, "png"),
|
||||||
|
"image/bmp" => (false, "bmp"),
|
||||||
|
"image/gif" => (false, "gif"),
|
||||||
|
"image/webp" => (false, "webp"),
|
||||||
|
"image/apng" => (false, "apng"),
|
||||||
|
"video/mpeg" => (true, "mpeg"),
|
||||||
|
"video/quicktime" => (true, "mov"),
|
||||||
|
"video/mp4" => (true, "mp4"),
|
||||||
|
"video/webm" => (true, "webm"),
|
||||||
|
"video/x-matroska" => (true, "mkv"),
|
||||||
|
"video/ogg" => (true, "ogg"),
|
||||||
|
_ => {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"nepodporovaný formát",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if video && !cfg.files.videos {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"videa nejsou podporovaná",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = temp_file.size;
|
||||||
|
|
||||||
|
if size / 1_000_000 > cfg.files.max_size_mb {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"soubor je příliš velký",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = Utc::now().timestamp_micros();
|
||||||
|
let format = format.to_owned();
|
||||||
|
|
||||||
|
let new_name = format!("{timestamp}.{format}");
|
||||||
|
|
||||||
|
let (thumb_format, thumb_name) = if thumb {
|
||||||
|
let format = if video { "png".into() } else { format.clone() };
|
||||||
|
|
||||||
|
(Some(format.clone()), Some(format!("{timestamp}.{format}")))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
rename(temp_file.file.path(), format!("/tmp/{new_name}")).await?;
|
||||||
|
|
||||||
|
let (width, height) = if video {
|
||||||
|
process_video(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
|
||||||
|
} else {
|
||||||
|
process_image(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
rename(format!("/tmp/{new_name}"), format!("uploads/{new_name}")).await?;
|
||||||
|
|
||||||
|
let file = File {
|
||||||
|
original_name,
|
||||||
|
format,
|
||||||
|
thumb_format,
|
||||||
|
spoiler,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
timestamp,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self) {
|
||||||
|
remove_file(format!("./uploads/{}.{}", self.timestamp, self.format))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Some(thumb_format) = &self.thumb_format {
|
||||||
|
remove_file(format!(
|
||||||
|
"./uploads/thumb/{}.{}",
|
||||||
|
self.timestamp, thumb_format
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_url(&self) -> String {
|
||||||
|
format!("/uploads/{}.{}", self.timestamp, self.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thumb_url(&self) -> String {
|
||||||
|
if self.spoiler {
|
||||||
|
"/static/spoiler.png".into()
|
||||||
|
} else if let Some(thumb_format) = &self.thumb_format {
|
||||||
|
format!("/uploads/thumb/{}.{}", self.timestamp, thumb_format)
|
||||||
|
} else {
|
||||||
|
self.file_url()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_image(
|
||||||
|
cfg: &Cfg,
|
||||||
|
original_name: String,
|
||||||
|
new_name: String,
|
||||||
|
thumb_name: Option<String>,
|
||||||
|
) -> Result<(u32, u32), NekrochanError> {
|
||||||
|
let original_name_ = original_name.clone();
|
||||||
|
|
||||||
|
let img = spawn_blocking(move || {
|
||||||
|
ImageReader::open(format!("/tmp/{new_name}"))?
|
||||||
|
.decode()
|
||||||
|
.map_err(|_| {
|
||||||
|
NekrochanError::FileError(original_name_, "nepodařilo se dekódovat obrázek")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
let (width, height) = (img.width(), img.height());
|
||||||
|
|
||||||
|
if width > cfg.files.max_width || height > cfg.files.max_height {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"rozměry obrázku jsou příliš velké",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumb_name = match thumb_name {
|
||||||
|
Some(thumb_name) => thumb_name,
|
||||||
|
None => return Ok((width, height)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb_w = if width > cfg.files.thumb_size {
|
||||||
|
cfg.files.thumb_size
|
||||||
|
} else {
|
||||||
|
width
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb_h = if height > cfg.files.thumb_size {
|
||||||
|
cfg.files.thumb_size
|
||||||
|
} else {
|
||||||
|
height
|
||||||
|
};
|
||||||
|
|
||||||
|
spawn_blocking(move || {
|
||||||
|
let thumb = img.thumbnail(thumb_w, thumb_h);
|
||||||
|
|
||||||
|
thumb
|
||||||
|
.save(format!("./uploads/thumb/{thumb_name}"))
|
||||||
|
.map_err(|_| {
|
||||||
|
NekrochanError::FileError(original_name, "nepodařilo se vytvořit náhled obrázku")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
Ok((width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_video(
|
||||||
|
cfg: &Cfg,
|
||||||
|
original_name: String,
|
||||||
|
new_name: String,
|
||||||
|
thumb_name: Option<String>,
|
||||||
|
) -> Result<(u32, u32), NekrochanError> {
|
||||||
|
let new_name_ = new_name.clone();
|
||||||
|
|
||||||
|
let ffprobe_out = spawn_blocking(move || {
|
||||||
|
Command::new("ffprobe")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height",
|
||||||
|
"-of",
|
||||||
|
"csv=s=x:p=0",
|
||||||
|
&format!("/tmp/{new_name_}"),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
if !ffprobe_out.status.success() {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"nepodařilo se získat rozměry videa",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let invalid_dimensions = "ffprobe vrátil neplatné rozměry";
|
||||||
|
|
||||||
|
let out_string = String::from_utf8_lossy(&ffprobe_out.stdout);
|
||||||
|
|
||||||
|
let (width, height) = out_string
|
||||||
|
.trim()
|
||||||
|
.split_once('x')
|
||||||
|
.ok_or(NekrochanError::FileError(
|
||||||
|
original_name.clone(),
|
||||||
|
invalid_dimensions,
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let (width, height) = (
|
||||||
|
width
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?,
|
||||||
|
height
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
if width > cfg.files.max_width || height > cfg.files.max_height {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"rozměry videa jsou příliš velké",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumb_name = match thumb_name {
|
||||||
|
Some(thumb_name) => thumb_name,
|
||||||
|
None => return Ok((width, height)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumb_size = cfg.files.thumb_size;
|
||||||
|
|
||||||
|
let output = spawn_blocking(move || {
|
||||||
|
Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-i",
|
||||||
|
&format!("/tmp/{new_name}"),
|
||||||
|
"-ss",
|
||||||
|
"00:00:00.50",
|
||||||
|
"-vframes",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
&format!(
|
||||||
|
"scale={}",
|
||||||
|
if width > height {
|
||||||
|
format!("{thumb_size}:-2")
|
||||||
|
} else {
|
||||||
|
format!("-2:{thumb_size}")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
&format!("./uploads/thumb/{thumb_name}"),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(NekrochanError::FileError(
|
||||||
|
original_name,
|
||||||
|
"nepodařilo se vytvořit náhled videa",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup_files(ctx: &Ctx) -> Result<(), Error> {
|
||||||
|
let mut keep = HashSet::new();
|
||||||
|
let mut keep_thumbs = HashSet::new();
|
||||||
|
|
||||||
|
let boards = Board::read_all(ctx).await?;
|
||||||
|
|
||||||
|
for board in boards.into_iter() {
|
||||||
|
for file in board.banners.0 {
|
||||||
|
keep.insert(format!("{}.{}", file.timestamp, file.format));
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts = Post::read_all(ctx, board.id.clone()).await?;
|
||||||
|
|
||||||
|
for post in posts.into_iter() {
|
||||||
|
for file in post.files.0 {
|
||||||
|
keep.insert(format!("{}.{}", file.timestamp, file.format));
|
||||||
|
|
||||||
|
if let Some(thumb_format) = file.thumb_format {
|
||||||
|
keep_thumbs.insert(format!("{}.{}", file.timestamp, thumb_format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in glob("uploads/*.*")? {
|
||||||
|
let file = file?;
|
||||||
|
let file_name = file.file_name();
|
||||||
|
|
||||||
|
if let Some(file_name) = file_name {
|
||||||
|
let check = file_name.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if !keep.contains(&check) {
|
||||||
|
remove_file(file).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in glob("uploads/thumb/*.*")? {
|
||||||
|
let file = file?;
|
||||||
|
let file_name = file.file_name();
|
||||||
|
|
||||||
|
if let Some(file_name) = file_name {
|
||||||
|
let check = file_name.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if !keep_thumbs.contains(&check) {
|
||||||
|
remove_file(file).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
131
src/filters.rs
Normální soubor
@ -0,0 +1,131 @@
|
|||||||
|
use chrono::{DateTime, Locale, Utc};
|
||||||
|
use fancy_regex::{Captures, Regex};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::{collections::HashSet, fmt::Display};
|
||||||
|
|
||||||
|
use crate::markup::SPOILER_REGEX;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref MARKUP_QUOTE_REGEX: Regex =
|
||||||
|
Regex::new(r#"<a class="quote" href=".+">>>(\d+)<\/a>"#).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn czech_humantime(time: &DateTime<Utc>) -> askama::Result<String> {
|
||||||
|
let duration = (Utc::now() - *time).abs();
|
||||||
|
|
||||||
|
let minutes = duration.num_minutes();
|
||||||
|
let hours = duration.num_hours();
|
||||||
|
let days = duration.num_days();
|
||||||
|
let weeks = duration.num_weeks();
|
||||||
|
let months = duration.num_days() / 30;
|
||||||
|
let years = duration.num_days() / 365;
|
||||||
|
|
||||||
|
let mut time = "Teď".into();
|
||||||
|
|
||||||
|
if minutes > 0 {
|
||||||
|
time = format!(
|
||||||
|
"{} {}",
|
||||||
|
minutes,
|
||||||
|
czech_plural("minuta|minuty|minut", minutes)?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
time = format!("{} {}", hours, czech_plural("hodina|hodiny|hodin", hours)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
time = format!("{} {}", days, czech_plural("den|dny|dnů", days)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if weeks > 0 {
|
||||||
|
time = format!("{} {}", weeks, czech_plural("týden|týdny|týdnů", weeks)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if months > 0 {
|
||||||
|
time = format!(
|
||||||
|
"{} {}",
|
||||||
|
months,
|
||||||
|
czech_plural("měsíc|měsíce|měsíců", months)?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if years > 0 {
|
||||||
|
time = format!("{} {}", years, czech_plural("rok|roky|let", years)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn czech_datetime(time: &DateTime<Utc>) -> askama::Result<String> {
|
||||||
|
let time = time
|
||||||
|
.format_localized("%d.%m.%Y (%a) %H:%M:%S UTC", Locale::cs_CZ)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn czech_plural(plurals: &str, count: impl Display) -> askama::Result<String> {
|
||||||
|
let plurals = plurals.split('|').collect::<Vec<_>>();
|
||||||
|
let count = count.to_string().parse::<i64>().unwrap();
|
||||||
|
|
||||||
|
let one = plurals[0];
|
||||||
|
let few = plurals[1];
|
||||||
|
let other = plurals[2];
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
Ok(one.into())
|
||||||
|
} else if count < 5 {
|
||||||
|
Ok(few.into())
|
||||||
|
} else {
|
||||||
|
Ok(other.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inline_post(input: impl Display) -> askama::Result<String> {
|
||||||
|
let input = input.to_string();
|
||||||
|
|
||||||
|
if input.is_empty() {
|
||||||
|
return Ok("(bez obsahu)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = input.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let spoilered = SPOILER_REGEX
|
||||||
|
.replace_all(&collapsed, "(spoiler)")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let truncated = askama::filters::truncate(spoilered, 64)?;
|
||||||
|
|
||||||
|
Ok(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_page(input: &usize, page_size: &i64) -> askama::Result<i64> {
|
||||||
|
let page = crate::paginate(*page_size, *input as i64);
|
||||||
|
|
||||||
|
Ok(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_yous(
|
||||||
|
input: impl Display,
|
||||||
|
board: &String,
|
||||||
|
yous: &HashSet<String>,
|
||||||
|
) -> askama::Result<String> {
|
||||||
|
let input = input.to_string();
|
||||||
|
|
||||||
|
let output = MARKUP_QUOTE_REGEX.replace_all(&input, |captures: &Captures| {
|
||||||
|
let quote = &captures[0];
|
||||||
|
let id = &captures[1];
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
quote,
|
||||||
|
if yous.contains(&format!("{}/{}", board, id)) {
|
||||||
|
" <span class=\"small\">(Ty)</span>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(output.to_string())
|
||||||
|
}
|
43
src/lib.rs
Spustitelný soubor
@ -0,0 +1,43 @@
|
|||||||
|
use error::NekrochanError;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::{collections::HashMap, sync::RwLock};
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref CAPTCHA: RwLock<HashMap<(String, String, String), String>> =
|
||||||
|
RwLock::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod cfg;
|
||||||
|
pub mod ctx;
|
||||||
|
pub mod db;
|
||||||
|
pub mod error;
|
||||||
|
pub mod files;
|
||||||
|
pub mod filters;
|
||||||
|
pub mod markup;
|
||||||
|
pub mod perms;
|
||||||
|
pub mod qsform;
|
||||||
|
pub mod trip;
|
||||||
|
pub mod web;
|
||||||
|
|
||||||
|
pub fn paginate(page_size: i64, count: i64) -> i64 {
|
||||||
|
count / page_size + (count % page_size).signum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_page(
|
||||||
|
page: i64,
|
||||||
|
pages: i64,
|
||||||
|
page_limit: impl Into<Option<i64>>,
|
||||||
|
) -> Result<(), NekrochanError> {
|
||||||
|
if page <= 0 || (page > pages && page != 1) {
|
||||||
|
return Err(NekrochanError::InvalidPageError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(page_limit) = page_limit.into() {
|
||||||
|
if page > page_limit {
|
||||||
|
return Err(NekrochanError::InvalidPageError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
154
src/main.rs
Spustitelný soubor
@ -0,0 +1,154 @@
|
|||||||
|
use actix_files::{Files, NamedFile};
|
||||||
|
use actix_web::{
|
||||||
|
body::MessageBody,
|
||||||
|
dev::ServiceResponse,
|
||||||
|
get,
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::{ErrorHandlerResponse, ErrorHandlers},
|
||||||
|
post,
|
||||||
|
web::Data,
|
||||||
|
App, HttpResponse, HttpServer, ResponseError,
|
||||||
|
};
|
||||||
|
use anyhow::Error;
|
||||||
|
use askama::Template;
|
||||||
|
use log::{error, info};
|
||||||
|
use nekrochan::{
|
||||||
|
cfg::Cfg,
|
||||||
|
ctx::Ctx,
|
||||||
|
db::cache::init_cache,
|
||||||
|
error::NekrochanError,
|
||||||
|
files::cleanup_files,
|
||||||
|
web::{self, template_response},
|
||||||
|
};
|
||||||
|
use sqlx::migrate;
|
||||||
|
use std::{env::var, time::Duration};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
if let Err(err) = run().await {
|
||||||
|
error!("{err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), Error> {
|
||||||
|
let cfg_path = var("NEKROCHAN_CONFIG").unwrap_or_else(|_| "Nekrochan.toml".into());
|
||||||
|
|
||||||
|
let cfg = Cfg::load(&cfg_path).await?;
|
||||||
|
let ctx = Ctx::new(cfg).await?;
|
||||||
|
|
||||||
|
migrate!().run(ctx.db()).await?;
|
||||||
|
init_cache(&ctx).await?;
|
||||||
|
|
||||||
|
let ctx_ = ctx.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match cleanup_files(&ctx_).await {
|
||||||
|
Ok(()) => info!("Routine file cleanup successful."),
|
||||||
|
Err(err) => error!("Routine file cleanup failed: {err:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
sleep(Duration::from_secs(ctx_.cfg.files.cleanup_interval)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_addr = ctx.bind_addr();
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(Data::new(ctx.clone()))
|
||||||
|
.service(web::index::index)
|
||||||
|
.service(web::board::board)
|
||||||
|
.service(web::board_catalog::board_catalog)
|
||||||
|
.service(web::overboard::overboard)
|
||||||
|
.service(web::overboard_catalog::overboard_catalog)
|
||||||
|
.service(web::thread::thread)
|
||||||
|
.service(web::actions::create_post::create_post)
|
||||||
|
.service(web::actions::user_post_actions::user_post_actions)
|
||||||
|
.service(web::actions::staff_post_actions::staff_post_actions)
|
||||||
|
.service(web::actions::report_posts::report_posts)
|
||||||
|
.service(web::login::login_get)
|
||||||
|
.service(web::login::login_post)
|
||||||
|
.service(web::logout::logout)
|
||||||
|
.service(web::staff::account::account)
|
||||||
|
.service(web::staff::accounts::accounts)
|
||||||
|
.service(web::staff::boards::boards)
|
||||||
|
.service(web::staff::bans::bans)
|
||||||
|
.service(web::staff::reports::reports)
|
||||||
|
.service(web::staff::permissions::permissions)
|
||||||
|
.service(web::staff::banners::banners)
|
||||||
|
.service(web::staff::board_config::board_config)
|
||||||
|
.service(web::staff::actions::change_password::change_password)
|
||||||
|
.service(web::staff::actions::transfer_ownership::transfer_ownership)
|
||||||
|
.service(web::staff::actions::delete_account::delete_account)
|
||||||
|
.service(web::staff::actions::remove_accounts::remove_accounts)
|
||||||
|
.service(web::staff::actions::create_account::create_account)
|
||||||
|
.service(web::staff::actions::remove_boards::remove_boards)
|
||||||
|
.service(web::staff::actions::update_boards::update_boards)
|
||||||
|
.service(web::staff::actions::create_board::create_board)
|
||||||
|
.service(web::staff::actions::remove_bans::remove_bans)
|
||||||
|
.service(web::staff::actions::update_permissions::update_permissions)
|
||||||
|
.service(web::staff::actions::remove_banners::remove_banners)
|
||||||
|
.service(web::staff::actions::add_banners::add_banners)
|
||||||
|
.service(web::staff::actions::update_board_config::update_board_config)
|
||||||
|
.service(debug)
|
||||||
|
.service(favicon)
|
||||||
|
.service(Files::new("/static", "./static"))
|
||||||
|
.service(Files::new("/uploads", "./uploads").disable_content_disposition())
|
||||||
|
.wrap(ErrorHandlers::new().default_handler(error_handler))
|
||||||
|
})
|
||||||
|
.bind(bind_addr)?
|
||||||
|
.run()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/favicon.ico")]
|
||||||
|
async fn favicon() -> Result<NamedFile, NekrochanError> {
|
||||||
|
let favicon = NamedFile::open("./static/favicon.ico")?;
|
||||||
|
|
||||||
|
Ok(favicon)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/debug")]
|
||||||
|
async fn debug(req: String) -> HttpResponse {
|
||||||
|
println!("{req}");
|
||||||
|
|
||||||
|
HttpResponse::new(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "error.html")]
|
||||||
|
struct ErrorTempalate {
|
||||||
|
error_code: u16,
|
||||||
|
error_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_handler<B>(res: ServiceResponse<B>) -> actix_web::Result<ErrorHandlerResponse<B>>
|
||||||
|
where
|
||||||
|
B: MessageBody,
|
||||||
|
<B as MessageBody>::Error: ResponseError + 'static,
|
||||||
|
{
|
||||||
|
let (req, res) = res.into_parts();
|
||||||
|
|
||||||
|
let error_code = res.status().as_u16();
|
||||||
|
let error_message = match res.into_body().try_into_bytes().ok() {
|
||||||
|
Some(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
|
||||||
|
None => String::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = ErrorTempalate {
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = template_response(template)?;
|
||||||
|
let res = ServiceResponse::new(req, res).map_into_right_body();
|
||||||
|
|
||||||
|
Ok(ErrorHandlerResponse::Response(res))
|
||||||
|
}
|
216
src/markup.rs
Normální soubor
@ -0,0 +1,216 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use fancy_regex::{Captures, Regex};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Post,
|
||||||
|
error::NekrochanError,
|
||||||
|
perms::PermissionWrapper,
|
||||||
|
trip::{secure_tripcode, tripcode},
|
||||||
|
};
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref NAME_REGEX: Regex =
|
||||||
|
Regex::new(r"^([^#].*?)?(?:(##([^ ].*?)|#([^#].*?)))?(##( .*?)?)?$").unwrap();
|
||||||
|
pub static ref QUOTE_REGEX: Regex = Regex::new(r">>(\d+)").unwrap();
|
||||||
|
pub static ref GREENTEXT_REGEX: Regex =
|
||||||
|
Regex::new(r"(?m)^>((?!>\d+|>>/\w+(/\d*)?|>>#/).*)")
|
||||||
|
.unwrap();
|
||||||
|
pub static ref ORANGETEXT_REGEX: Regex = Regex::new(r"(?m)^<(.+)").unwrap();
|
||||||
|
pub static ref REDTEXT_REGEX: Regex = Regex::new(r"(?m)==(.+?)==").unwrap();
|
||||||
|
pub static ref BLUETEXT_REGEX: Regex = Regex::new(r"(?m)--(.+?)--").unwrap();
|
||||||
|
pub static ref GLOWTEXT_REGEX: Regex = Regex::new(r"(?m)\%\%(.+?)\%\%").unwrap();
|
||||||
|
pub static ref UH_OH_TEXT_REGEX: Regex = Regex::new(r"(?m)\(\(\((.+?)\)\)\)").unwrap();
|
||||||
|
pub static ref SPOILER_REGEX: Regex = Regex::new(r"(?m)\|\|([\s\S]+?)\|\|").unwrap();
|
||||||
|
pub static ref URL_REGEX: Regex =
|
||||||
|
Regex::new(r"https?\://[^\s<>\[\]{}|\\^]+").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_name(
|
||||||
|
ctx: &Ctx,
|
||||||
|
perms: &PermissionWrapper,
|
||||||
|
anon_name: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(String, Option<String>, Option<String>), NekrochanError> {
|
||||||
|
let captures = match NAME_REGEX.captures(name)? {
|
||||||
|
Some(captures) => captures,
|
||||||
|
None => return Ok((anon_name.to_owned(), None, None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = match captures.get(1) {
|
||||||
|
Some(name) => {
|
||||||
|
let name = name.as_str().to_owned();
|
||||||
|
|
||||||
|
if name.len() > 32 {
|
||||||
|
return Err(NekrochanError::PostNameFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
name
|
||||||
|
}
|
||||||
|
None => anon_name.to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tripcode = match captures.get(2) {
|
||||||
|
Some(_) => {
|
||||||
|
let strip = captures.get(3);
|
||||||
|
let itrip = captures.get(4);
|
||||||
|
|
||||||
|
if let Some(strip) = strip {
|
||||||
|
let trip = secure_tripcode(strip.as_str(), &ctx.cfg.secrets.secure_trip);
|
||||||
|
|
||||||
|
Some(format!("!!{trip}"))
|
||||||
|
} else if let Some(itrip) = itrip {
|
||||||
|
let trip = tripcode(itrip.as_str());
|
||||||
|
|
||||||
|
Some(format!("!{trip}"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(perms.owner() || perms.capcodes()) {
|
||||||
|
return Ok((name, tripcode, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capcode_fallback(owner: bool) -> Option<String> {
|
||||||
|
if owner {
|
||||||
|
Some("Admin".into())
|
||||||
|
} else {
|
||||||
|
Some("Uklízeč".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let capcode = match captures.get(5) {
|
||||||
|
Some(_) => match captures.get(6) {
|
||||||
|
Some(capcode) => {
|
||||||
|
let capcode: String = capcode.as_str().trim().into();
|
||||||
|
|
||||||
|
if capcode.is_empty() {
|
||||||
|
capcode_fallback(perms.owner())
|
||||||
|
} else {
|
||||||
|
if capcode.len() > 32 {
|
||||||
|
return Err(NekrochanError::CapcodeFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(capcode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => capcode_fallback(perms.owner()),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((name, tripcode, capcode))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn markup(
|
||||||
|
ctx: &Ctx,
|
||||||
|
board: &String,
|
||||||
|
op: Option<i32>,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<String, NekrochanError> {
|
||||||
|
let text = escape_html(text);
|
||||||
|
let quoted_posts = get_quoted_posts(ctx, board, &text).await?;
|
||||||
|
|
||||||
|
let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| {
|
||||||
|
let id_raw = &captures[1];
|
||||||
|
let id = match id_raw.parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return format!("<span class=\"dead-quote\">>>{id_raw}</span>"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let post = quoted_posts.get(&id);
|
||||||
|
|
||||||
|
match post {
|
||||||
|
Some(post) => format!(
|
||||||
|
"<a class=\"quote\" href=\"{}\">>>{}</a>{}",
|
||||||
|
post.post_url(),
|
||||||
|
post.id,
|
||||||
|
if op == Some(post.id) {
|
||||||
|
" <span class=\"small\">(OP)</span>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None => format!("<span class=\"dead-quote\">>>{id}</span>"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>")
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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<i32, Post>, NekrochanError> {
|
||||||
|
let mut quoted_ids: Vec<i32> = Vec::new();
|
||||||
|
|
||||||
|
for quote in QUOTE_REGEX.captures_iter(text) {
|
||||||
|
let id_raw = "e.unwrap()[1];
|
||||||
|
let id = match id_raw.parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
quoted_ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if quoted_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let in_list = quoted_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
let quoted_posts = query_as(&format!(
|
||||||
|
"SELECT * FROM posts_{} WHERE id IN ({})",
|
||||||
|
board, in_list
|
||||||
|
))
|
||||||
|
.fetch_all(ctx.db())
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|post: Post| (post.id, post))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
Ok(quoted_posts)
|
||||||
|
}
|
85
src/perms.rs
Spustitelný soubor
@ -0,0 +1,85 @@
|
|||||||
|
use enumflags2::{bitflags, BitFlags};
|
||||||
|
|
||||||
|
#[bitflags]
|
||||||
|
#[repr(u64)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||||
|
pub enum Permissions {
|
||||||
|
EditPosts,
|
||||||
|
ManagePosts,
|
||||||
|
Capcodes,
|
||||||
|
StaffLog,
|
||||||
|
Reports,
|
||||||
|
Bans,
|
||||||
|
BoardBanners,
|
||||||
|
BoardConfig,
|
||||||
|
BypassBans,
|
||||||
|
BypassBoardLock,
|
||||||
|
BypassThreadLock,
|
||||||
|
BypassCaptcha,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PermissionWrapper(BitFlags<Permissions>, bool);
|
||||||
|
|
||||||
|
impl PermissionWrapper {
|
||||||
|
pub fn new(perms: u64, owner: bool) -> Self {
|
||||||
|
Self(BitFlags::from_bits_truncate(perms), owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionWrapper {
|
||||||
|
pub fn integer(&self) -> u64 {
|
||||||
|
self.0.bits()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner(&self) -> bool {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_posts(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::EditPosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manage_posts(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::ManagePosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capcodes(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::Capcodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn staff_log(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::StaffLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reports(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::Reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bans(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::Bans)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn board_banners(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BoardBanners)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn board_config(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BoardConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bypass_bans(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BypassBans)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bypass_board_lock(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BypassBoardLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bypass_thread_lock(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BypassThreadLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bypass_captcha(&self) -> bool {
|
||||||
|
self.0.contains(Permissions::BypassCaptcha)
|
||||||
|
}
|
||||||
|
}
|
43
src/qsform.rs
Normální soubor
@ -0,0 +1,43 @@
|
|||||||
|
use actix_web::{dev::Payload, http::StatusCode, FromRequest, HttpRequest, ResponseError};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_qs::Config;
|
||||||
|
use std::{future::Future, pin::Pin};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub struct QsForm<T>(pub T)
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum QsFormError {
|
||||||
|
#[error("{}", .0)]
|
||||||
|
FutureError(#[from] actix_web::Error),
|
||||||
|
#[error("{}", .0)]
|
||||||
|
ParseError(#[from] serde_qs::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for QsFormError {
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FromRequest for QsForm<T>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
type Error = QsFormError;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<QsForm<T>, Self::Error>>>>;
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||||
|
let data = String::from_request(req, payload);
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
let data = data.await?;
|
||||||
|
let config = Config::new(10, false);
|
||||||
|
let form: T = config.deserialize_str(&data)?;
|
||||||
|
|
||||||
|
Ok(QsForm(form))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
54
src/trip.rs
Spustitelný soubor
@ -0,0 +1,54 @@
|
|||||||
|
use encoding::{all::WINDOWS_31J, EncoderTrap, Encoding};
|
||||||
|
use pwhash::{
|
||||||
|
bcrypt::{self, BcryptSetup},
|
||||||
|
unix::crypt,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn tripcode(password: &str) -> String {
|
||||||
|
let password = WINDOWS_31J.encode(password, EncoderTrap::Replace).unwrap();
|
||||||
|
|
||||||
|
let salt = [password.as_ref(), "H.".as_bytes()].concat();
|
||||||
|
let salt = &salt[1..3];
|
||||||
|
let salt = salt
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c {
|
||||||
|
46..=122 => *c,
|
||||||
|
_ => 46,
|
||||||
|
} as char)
|
||||||
|
.map(|c| match c {
|
||||||
|
':' => 'A',
|
||||||
|
';' => 'B',
|
||||||
|
'<' => 'C',
|
||||||
|
'=' => 'D',
|
||||||
|
'>' => 'E',
|
||||||
|
'?' => 'F',
|
||||||
|
'@' => 'G',
|
||||||
|
'[' => 'a',
|
||||||
|
'\\' => 'b',
|
||||||
|
']' => 'c',
|
||||||
|
'^' => 'd',
|
||||||
|
'_' => 'e',
|
||||||
|
'`' => 'f',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let trip = crypt(password, &salt).unwrap();
|
||||||
|
|
||||||
|
trip[3..].to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secure_tripcode(password: &str, tripcode_secret: &str) -> String {
|
||||||
|
let trip = bcrypt::hash_with(
|
||||||
|
BcryptSetup {
|
||||||
|
salt: Some(tripcode_secret),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let trip = &trip[trip.len() - 10..];
|
||||||
|
|
||||||
|
trip.into()
|
||||||
|
}
|
250
src/web/actions/create_post.rs
Normální soubor
@ -0,0 +1,250 @@
|
|||||||
|
use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm};
|
||||||
|
use actix_web::{
|
||||||
|
cookie::Cookie, http::StatusCode, post, web::Data, HttpRequest, HttpResponse,
|
||||||
|
HttpResponseBuilder,
|
||||||
|
};
|
||||||
|
use pwhash::bcrypt::hash;
|
||||||
|
use sha256::digest;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Ban, Board, File, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
markup::{markup, parse_name},
|
||||||
|
perms::PermissionWrapper,
|
||||||
|
web::{
|
||||||
|
ban_response,
|
||||||
|
tcx::{account_from_auth_opt, ip_from_req},
|
||||||
|
},
|
||||||
|
CAPTCHA,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(MultipartForm)]
|
||||||
|
pub struct PostForm {
|
||||||
|
pub board: Text<String>,
|
||||||
|
pub thread: Option<Text<i32>>,
|
||||||
|
pub name: Text<String>,
|
||||||
|
pub email: Text<String>,
|
||||||
|
pub content: Text<String>,
|
||||||
|
#[multipart(rename = "files[]")]
|
||||||
|
pub files: Vec<TempFile>,
|
||||||
|
pub spoiler_files: Option<Text<String>>,
|
||||||
|
pub password: Text<String>,
|
||||||
|
pub captcha_id: Option<Text<String>>,
|
||||||
|
pub captcha_solution: Option<Text<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/actions/create-post")]
|
||||||
|
pub async fn create_post(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
MultipartForm(form): MultipartForm<PostForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let perms = match account_from_auth_opt(&ctx, &req).await? {
|
||||||
|
Some(account) => account.perms(),
|
||||||
|
None => PermissionWrapper::new(0, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ip, country) = ip_from_req(&req)?;
|
||||||
|
|
||||||
|
let board = form.board.0;
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
if let Some(ban) = Ban::read(&ctx, board.id.clone(), ip).await? {
|
||||||
|
if !(perms.owner() || perms.bypass_bans()) {
|
||||||
|
return ban_response(&ctx, &req, ban).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if board.config.0.locked && !(perms.owner() || perms.bypass_board_lock()) {
|
||||||
|
return Err(NekrochanError::BoardLockError(board.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bumpy_bump = true;
|
||||||
|
let mut noko = true;
|
||||||
|
|
||||||
|
let thread = match form.thread {
|
||||||
|
Some(Text(thread)) => {
|
||||||
|
let thread = Post::read(&ctx, board.id.clone(), thread)
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::PostNotFound(board.id.clone(), thread))?;
|
||||||
|
|
||||||
|
if thread.thread.is_some() {
|
||||||
|
return Err(NekrochanError::ReplyReplyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if thread.locked && !(perms.owner() || perms.bypass_thread_lock()) {
|
||||||
|
return Err(NekrochanError::ThreadLockError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if thread.replies >= board.config.0.reply_limit {
|
||||||
|
return Err(NekrochanError::ReplyLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if thread.bumps >= board.config.0.bump_limit {
|
||||||
|
bumpy_bump = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(thread)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let difficulties = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
let difficulty = if thread.is_none() {
|
||||||
|
if difficulties.contains(&board.config.0.thread_captcha.as_str()) {
|
||||||
|
Some(board.config.0.thread_captcha.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if difficulties.contains(&board.config.0.reply_captcha.as_str()) {
|
||||||
|
Some(board.config.0.reply_captcha.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(difficulty) = difficulty {
|
||||||
|
let board = board.id.clone();
|
||||||
|
let id = form
|
||||||
|
.captcha_id
|
||||||
|
.ok_or(NekrochanError::RequiredCaptchaError)?
|
||||||
|
.0;
|
||||||
|
|
||||||
|
let key = (board, difficulty, id);
|
||||||
|
let solution = form
|
||||||
|
.captcha_solution
|
||||||
|
.ok_or(NekrochanError::RequiredCaptchaError)?;
|
||||||
|
|
||||||
|
let actual_solution = CAPTCHA
|
||||||
|
.write()?
|
||||||
|
.remove(&key)
|
||||||
|
.ok_or(NekrochanError::SolvedCaptchaError)?;
|
||||||
|
|
||||||
|
if solution.trim() != actual_solution {
|
||||||
|
return Err(NekrochanError::IncorrectCaptchaError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_raw = form.name.trim();
|
||||||
|
let (name, tripcode, capcode) = parse_name(&ctx, &perms, &board.config.0.anon_name, name_raw)?;
|
||||||
|
|
||||||
|
let email_raw = form.email.trim();
|
||||||
|
|
||||||
|
let email = if !email_raw.is_empty() {
|
||||||
|
if email_raw.len() > 256 {
|
||||||
|
return Err(NekrochanError::EmailFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if email_raw == "sage" || email_raw == "nonokosage" {
|
||||||
|
bumpy_bump = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if email_raw == "nonoko" || email_raw == "nonokosage" {
|
||||||
|
noko = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(email_raw.into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_nomarkup = form.content.0.trim().to_owned();
|
||||||
|
|
||||||
|
if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content)
|
||||||
|
|| (thread.is_some() && board.config.0.require_reply_content)
|
||||||
|
{
|
||||||
|
return Err(NekrochanError::NoContentError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_nomarkup.len() > 4000 {
|
||||||
|
return Err(NekrochanError::ContentFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = markup(
|
||||||
|
&ctx,
|
||||||
|
&board.id,
|
||||||
|
thread.as_ref().map(|t| t.id),
|
||||||
|
&content_nomarkup,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if form.files.len() > board.config.0.file_limit {
|
||||||
|
return Err(NekrochanError::FileLimitError(board.config.0.file_limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
for file in form.files.into_iter() {
|
||||||
|
if file.size == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spoiler = form.spoiler_files.is_some();
|
||||||
|
let file = File::new(&ctx.cfg, file, spoiler, true).await?;
|
||||||
|
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_nomarkup.is_empty() && files.is_empty() {
|
||||||
|
return Err(NekrochanError::EmptyPostError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_raw = form.password.trim();
|
||||||
|
|
||||||
|
if password_raw.len() < 8 {
|
||||||
|
return Err(NekrochanError::PasswordFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = hash(password_raw)?;
|
||||||
|
let thread_id = thread.as_ref().map(|t| t.id);
|
||||||
|
|
||||||
|
let post = Post::create(
|
||||||
|
&ctx,
|
||||||
|
&board,
|
||||||
|
thread_id,
|
||||||
|
name,
|
||||||
|
tripcode,
|
||||||
|
capcode,
|
||||||
|
email,
|
||||||
|
content,
|
||||||
|
content_nomarkup,
|
||||||
|
files,
|
||||||
|
password,
|
||||||
|
country,
|
||||||
|
ip,
|
||||||
|
bumpy_bump,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ts = thread
|
||||||
|
.as_ref()
|
||||||
|
.map(|thread| thread.created.timestamp_micros())
|
||||||
|
.unwrap_or_else(|| post.created.timestamp_micros());
|
||||||
|
|
||||||
|
let hash_input = format!("{}:{}:{}", ip, ts, ctx.cfg.secrets.user_id);
|
||||||
|
let user_hash = digest(hash_input);
|
||||||
|
let user_id = user_hash[..6].to_owned();
|
||||||
|
|
||||||
|
post.update_user_id(&ctx, user_id).await?;
|
||||||
|
|
||||||
|
let mut res = HttpResponseBuilder::new(StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
let name_cookie = Cookie::build("name", name_raw).path("/").finish();
|
||||||
|
let password_cookie = Cookie::build("password", password_raw).path("/").finish();
|
||||||
|
|
||||||
|
res.cookie(name_cookie);
|
||||||
|
res.cookie(password_cookie);
|
||||||
|
|
||||||
|
let res = if noko {
|
||||||
|
res.append_header(("Location", post.post_url().as_str()))
|
||||||
|
.finish()
|
||||||
|
} else {
|
||||||
|
res.append_header(("Location", format!("/boards/{}", post.board).as_str()))
|
||||||
|
.finish()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
38
src/web/actions/mod.rs
Normální soubor
@ -0,0 +1,38 @@
|
|||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use super::tcx::TemplateCtx;
|
||||||
|
use crate::{ctx::Ctx, db::models::Post};
|
||||||
|
|
||||||
|
pub mod create_post;
|
||||||
|
pub mod staff_post_actions;
|
||||||
|
pub mod report_posts;
|
||||||
|
pub mod user_post_actions;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "action.html")]
|
||||||
|
pub struct ActionTemplate {
|
||||||
|
pub tcx: TemplateCtx,
|
||||||
|
pub response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_posts_from_ids(ctx: &Ctx, ids: Vec<String>) -> Vec<Post> {
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
|
||||||
|
for id in ids.into_iter() {
|
||||||
|
if let Some((board, id)) = parse_id(id) {
|
||||||
|
if let Ok(Some(post)) = Post::read(ctx, board, id).await {
|
||||||
|
posts.push(post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id(id: String) -> Option<(String, i32)> {
|
||||||
|
let (board, id) = id.split_once('/')?;
|
||||||
|
let board = board.to_owned();
|
||||||
|
let id = id.parse().ok()?;
|
||||||
|
|
||||||
|
Some((board, id))
|
||||||
|
}
|
106
src/web/actions/report_posts.rs
Normální soubor
@ -0,0 +1,106 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Ban, Board},
|
||||||
|
error::NekrochanError,
|
||||||
|
qsform::QsForm,
|
||||||
|
web::{
|
||||||
|
actions::{get_posts_from_ids, ActionTemplate},
|
||||||
|
ban_response,
|
||||||
|
tcx::{ip_from_req, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReportPostsForm {
|
||||||
|
#[serde(default)]
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
pub report_reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/actions/report-posts")]
|
||||||
|
pub async fn report_posts(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<ReportPostsForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let (reporter_ip, reporter_country) = ip_from_req(&req)?;
|
||||||
|
let bans = Ban::read_by_ip(&ctx, reporter_ip).await?;
|
||||||
|
|
||||||
|
if let Some(ban) = bans.get(&None) {
|
||||||
|
if !(tcx.perms.owner() || tcx.perms.bypass_bans()) {
|
||||||
|
return ban_response(&ctx, &req, ban.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let boards = Board::read_all_map(&ctx).await?;
|
||||||
|
let posts = get_posts_from_ids(&ctx, form.posts).await;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
let mut posts_reported = 0;
|
||||||
|
|
||||||
|
let reason = form.report_reason.trim();
|
||||||
|
|
||||||
|
for post in posts.iter() {
|
||||||
|
let board = &boards[&post.board];
|
||||||
|
|
||||||
|
if bans.contains_key(&Some(board.id.clone()))
|
||||||
|
&& !(tcx.perms.owner() || tcx.perms.bypass_bans())
|
||||||
|
{
|
||||||
|
writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Chyba] {}",
|
||||||
|
NekrochanError::BoardLockError(board.id.to_owned())
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if post
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.any(|report| report.reporter_ip == reporter_ip)
|
||||||
|
{
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Chyba] Příspěvek #{} jsi už nahlásil.",
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
post.create_report(
|
||||||
|
&ctx,
|
||||||
|
reason.to_owned(),
|
||||||
|
reporter_country.clone(),
|
||||||
|
reporter_ip,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
posts_reported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts_reported != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Nahlášeny příspěvky: {posts_reported}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = ActionTemplate { tcx, response };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
219
src/web/actions/staff_post_actions.rs
Normální soubor
@ -0,0 +1,219 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{collections::HashSet, fmt::Write};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Ban,
|
||||||
|
error::NekrochanError,
|
||||||
|
qsform::QsForm,
|
||||||
|
web::{
|
||||||
|
actions::{get_posts_from_ids, ActionTemplate},
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct JannyPostActionsForm {
|
||||||
|
#[serde(default)]
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
#[serde(rename = "staff_remove_posts")]
|
||||||
|
pub remove_posts: Option<String>,
|
||||||
|
#[serde(rename = "staff_remove_files")]
|
||||||
|
pub remove_files: Option<String>,
|
||||||
|
#[serde(rename = "staff_toggle_spoiler")]
|
||||||
|
pub toggle_spoiler: Option<String>,
|
||||||
|
pub toggle_sticky: Option<String>,
|
||||||
|
pub toggle_lock: Option<String>,
|
||||||
|
pub ban_user: Option<String>,
|
||||||
|
pub global_ban: Option<String>,
|
||||||
|
pub unappealable_ban: Option<String>,
|
||||||
|
pub ban_reason: Option<String>,
|
||||||
|
pub ban_duration: Option<i64>,
|
||||||
|
pub ban_range: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/actions/staff-post-actions")]
|
||||||
|
pub async fn staff_post_actions(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<JannyPostActionsForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
let posts = get_posts_from_ids(&ctx, form.posts).await;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
|
||||||
|
let mut posts_removed = 0;
|
||||||
|
let mut files_removed = 0;
|
||||||
|
let mut spoilers_toggled = 0;
|
||||||
|
let mut stickies_toggled = 0;
|
||||||
|
let mut locks_toggled = 0;
|
||||||
|
let mut bans_issued = 0;
|
||||||
|
|
||||||
|
for post in posts.iter() {
|
||||||
|
if (form.remove_posts.is_some()
|
||||||
|
|| form.remove_files.is_some()
|
||||||
|
|| form.toggle_spoiler.is_some()
|
||||||
|
|| form.toggle_sticky.is_some()
|
||||||
|
|| form.toggle_lock.is_some())
|
||||||
|
&& !(account.perms().owner() || account.perms().manage_posts())
|
||||||
|
{
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Chyba] Nemáš oprávnění spravovat příspěvky."
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.remove_posts.is_some() {
|
||||||
|
post.delete(&ctx).await?;
|
||||||
|
posts_removed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.remove_files.is_some() {
|
||||||
|
post.delete_files(&ctx).await?;
|
||||||
|
files_removed += post.files.0.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.toggle_spoiler.is_some() {
|
||||||
|
post.update_spoiler(&ctx).await?;
|
||||||
|
spoilers_toggled += post.files.0.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.toggle_sticky.is_some() {
|
||||||
|
post.update_sticky(&ctx).await?;
|
||||||
|
stickies_toggled += post.files.0.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.toggle_lock.is_some() {
|
||||||
|
post.update_lock(&ctx).await?;
|
||||||
|
locks_toggled += post.files.0.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut already_banned = HashSet::new();
|
||||||
|
|
||||||
|
for post in posts.into_iter() {
|
||||||
|
if let (Some(_), Some(ban_reason), Some(ban_duration), Some(ban_range)) = (
|
||||||
|
form.ban_user.clone(),
|
||||||
|
form.ban_reason.clone(),
|
||||||
|
form.ban_duration,
|
||||||
|
form.ban_range.clone(),
|
||||||
|
) {
|
||||||
|
if !(account.perms().owner() || account.perms().bans()) {
|
||||||
|
writeln!(&mut response, "[Chyba] Nemáš oprávnění vydat ban.").ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if already_banned.contains(&post.ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = account.username.clone();
|
||||||
|
|
||||||
|
let board = if form.global_ban.is_none() {
|
||||||
|
Some(post.board.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if post.ip.is_ipv4() {
|
||||||
|
match ban_range.as_str() {
|
||||||
|
"ip" => 32,
|
||||||
|
"lan" => 24,
|
||||||
|
"isp" => 16,
|
||||||
|
_ => 32,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match ban_range.as_str() {
|
||||||
|
"ip" => 128,
|
||||||
|
"lan" => 48,
|
||||||
|
"isp" => 24,
|
||||||
|
_ => 128,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ip_range = IpNetwork::new(post.ip, prefix)?;
|
||||||
|
let reason = ban_reason.trim().into();
|
||||||
|
let appealable = form.unappealable_ban.is_none();
|
||||||
|
|
||||||
|
let expires = if ban_duration != 0 {
|
||||||
|
Some(Utc::now() + Duration::days(ban_duration))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ban::create(&ctx, account, board, ip_range, reason, appealable, expires).await?;
|
||||||
|
|
||||||
|
let content_nomarkup = format!(
|
||||||
|
"{}\n\n==(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)==",
|
||||||
|
post.content_nomarkup
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"{}\n\n<span class=\"redtext\">(UŽIVATEL BYL ZA TENTO PŘÍSPĚVEK ZABANOVÁN)</span>",
|
||||||
|
post.content
|
||||||
|
);
|
||||||
|
|
||||||
|
post.update_content(&ctx, content, content_nomarkup).await?;
|
||||||
|
|
||||||
|
already_banned.insert(post.ip);
|
||||||
|
bans_issued += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts_removed != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Odstraněny příspěvky: {posts_removed}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if files_removed != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Odstraněny soubory: {files_removed}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if spoilers_toggled != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Přepnuty spoilery: {spoilers_toggled}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if stickies_toggled != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Připnuta/odepnuta vlákna: {stickies_toggled}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if locks_toggled != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Zamčena/odemčena vlákna: {locks_toggled}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if bans_issued != 0 {
|
||||||
|
writeln!(&mut response, "[Úspěch] Uděleny bany: {bans_issued}").ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = ActionTemplate { tcx, response };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
135
src/web/actions/user_post_actions.rs
Normální soubor
@ -0,0 +1,135 @@
|
|||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use pwhash::bcrypt::verify;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Ban, Board},
|
||||||
|
error::NekrochanError,
|
||||||
|
qsform::QsForm,
|
||||||
|
web::{
|
||||||
|
actions::{get_posts_from_ids, ActionTemplate},
|
||||||
|
ban_response,
|
||||||
|
tcx::{ip_from_req, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserPostActionsForm {
|
||||||
|
#[serde(default)]
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
pub remove_posts: Option<String>,
|
||||||
|
pub remove_files: Option<String>,
|
||||||
|
pub toggle_spoiler: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/actions/user-post-actions")]
|
||||||
|
pub async fn user_post_actions(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<UserPostActionsForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let (ip, _) = ip_from_req(&req)?;
|
||||||
|
let bans = Ban::read_by_ip(&ctx, ip).await?;
|
||||||
|
|
||||||
|
if let Some(ban) = bans.get(&None) {
|
||||||
|
if !(tcx.perms.owner() || tcx.perms.bypass_bans()) {
|
||||||
|
return ban_response(&ctx, &req, ban.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts = get_posts_from_ids(&ctx, form.posts).await;
|
||||||
|
let boards = Board::read_all_map(&ctx).await?;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
|
||||||
|
let mut posts_removed = 0;
|
||||||
|
let mut files_removed = 0;
|
||||||
|
let mut spoilers_toggled = 0;
|
||||||
|
|
||||||
|
for post in posts.iter() {
|
||||||
|
let board = &boards[&post.board];
|
||||||
|
|
||||||
|
if bans.contains_key(&Some(board.id.clone()))
|
||||||
|
&& !(tcx.perms.owner() || tcx.perms.bypass_bans())
|
||||||
|
{
|
||||||
|
writeln!(&mut response, "[Chyba] Jsi zabanován z /{}/.", board.id).ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if board.config.0.locked && !(tcx.perms.owner() || tcx.perms.bypass_board_lock()) {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Chyba] {}",
|
||||||
|
NekrochanError::BoardLockError(board.id.to_owned())
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verify(&form.password, &post.password) {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Chyba] {}",
|
||||||
|
NekrochanError::IncorrectPasswordError(post.id)
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.remove_posts.is_some() {
|
||||||
|
post.delete(&ctx).await?;
|
||||||
|
posts_removed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.remove_files.is_some() {
|
||||||
|
if (post.thread.is_none() && board.config.0.require_thread_file)
|
||||||
|
|| (post.thread.is_some() && board.config.0.require_reply_file)
|
||||||
|
{
|
||||||
|
writeln!(&mut response, "[Chyba] Soubor je na tomto místě potřebný.").ok();
|
||||||
|
} else {
|
||||||
|
post.delete_files(&ctx).await?;
|
||||||
|
files_removed += post.files.0.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.toggle_spoiler.is_some() {
|
||||||
|
post.update_spoiler(&ctx).await?;
|
||||||
|
spoilers_toggled += post.files.0.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts_removed != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Odstraněny příspěvky: {posts_removed}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if files_removed != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Odstraněny soubory: {files_removed}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if spoilers_toggled != 0 {
|
||||||
|
writeln!(
|
||||||
|
&mut response,
|
||||||
|
"[Úspěch] Přepnuty spoilery: {spoilers_toggled}"
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = ActionTemplate { tcx, response };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
74
src/web/board.rs
Spustitelný soubor
@ -0,0 +1,74 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path, Query},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
use redis::AsyncCommands;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_page,
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
filters, paginate,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BoardQuery {
|
||||||
|
page: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "board.html")]
|
||||||
|
struct BoardTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
board: Board,
|
||||||
|
threads: Vec<(Post, Vec<Post>)>,
|
||||||
|
page: i64,
|
||||||
|
pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/boards/{board}")]
|
||||||
|
pub async fn board(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
path: Path<String>,
|
||||||
|
query: Option<Query<BoardQuery>>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
|
||||||
|
let board = path.into_inner();
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let count = ctx
|
||||||
|
.cache()
|
||||||
|
.get(format!("board_threads:{}", board.id))
|
||||||
|
.await?;
|
||||||
|
let page = query.map(|q| q.page).unwrap_or(1);
|
||||||
|
let pages = paginate(board.config.0.page_size, count);
|
||||||
|
|
||||||
|
check_page(page, pages, board.config.0.page_count)?;
|
||||||
|
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
|
||||||
|
for thread in Post::read_board_page(&ctx, &board, page).await?.into_iter() {
|
||||||
|
let replies = thread.read_replies(&ctx).await?;
|
||||||
|
|
||||||
|
threads.push((thread, replies))
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = BoardTemplate {
|
||||||
|
tcx,
|
||||||
|
board,
|
||||||
|
threads,
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
46
src/web/board_catalog.rs
Normální soubor
@ -0,0 +1,46 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "board_catalog.html")]
|
||||||
|
struct BoardCatalogTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
board: Board,
|
||||||
|
threads: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/boards/{board}/catalog")]
|
||||||
|
pub async fn board_catalog(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
path: Path<String>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
|
||||||
|
let board = path.into_inner();
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let threads = Post::read_board_catalog(&ctx, board.id.clone()).await?;
|
||||||
|
|
||||||
|
let template = BoardCatalogTemplate {
|
||||||
|
tcx,
|
||||||
|
board,
|
||||||
|
threads,
|
||||||
|
};
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
24
src/web/index.rs
Spustitelný soubor
@ -0,0 +1,24 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use super::tcx::TemplateCtx;
|
||||||
|
use crate::{ctx::Ctx, db::models::Post, error::NekrochanError, filters, web::template_response};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
posts: Vec<Post>,
|
||||||
|
files: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn index(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let posts = Post::read_latest(&ctx).await.unwrap_or_else(|_| Vec::new());
|
||||||
|
let files = Post::read_files(&ctx).await.unwrap_or_else(|_| Vec::new());
|
||||||
|
|
||||||
|
let template = IndexTemplate { tcx, posts, files };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
59
src/web/login.rs
Spustitelný soubor
@ -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()
|
||||||
|
}
|
45
src/web/mod.rs
Spustitelný soubor
@ -0,0 +1,45 @@
|
|||||||
|
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
pub mod actions;
|
||||||
|
pub mod board;
|
||||||
|
pub mod board_catalog;
|
||||||
|
pub mod index;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod overboard;
|
||||||
|
pub mod overboard_catalog;
|
||||||
|
pub mod staff;
|
||||||
|
pub mod tcx;
|
||||||
|
pub mod thread;
|
||||||
|
|
||||||
|
use self::tcx::TemplateCtx;
|
||||||
|
use crate::{ctx::Ctx, db::models::Ban, error::NekrochanError, filters};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "banned.html")]
|
||||||
|
struct BannedTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
ban: Ban,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ban_response(
|
||||||
|
ctx: &Ctx,
|
||||||
|
req: &HttpRequest,
|
||||||
|
ban: Ban,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(ctx, req).await?;
|
||||||
|
|
||||||
|
template_response(BannedTemplate { tcx, ban })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn template_response<T>(template: T) -> Result<HttpResponse, NekrochanError>
|
||||||
|
where
|
||||||
|
T: Template,
|
||||||
|
{
|
||||||
|
let res = HttpResponseBuilder::new(StatusCode::OK)
|
||||||
|
.append_header(("Content-Type", "text/html"))
|
||||||
|
.body(template.render()?);
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
67
src/web/overboard.rs
Normální soubor
@ -0,0 +1,67 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Query},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
use redis::AsyncCommands;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_page,
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
filters, paginate,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OverboardQuery {
|
||||||
|
page: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "overboard.html")]
|
||||||
|
struct OverboardTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
boards: HashMap<String, Board>,
|
||||||
|
threads: Vec<(Post, Vec<Post>)>,
|
||||||
|
page: i64,
|
||||||
|
pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/overboard")]
|
||||||
|
pub async fn overboard(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
query: Option<Query<OverboardQuery>>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let boards = Board::read_all_map(&ctx).await?;
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
|
||||||
|
let count = ctx.cache().get("total_threads").await?;
|
||||||
|
let page = query.map(|q| q.page).unwrap_or(1);
|
||||||
|
let pages = paginate(15, count);
|
||||||
|
|
||||||
|
check_page(page, pages, None)?;
|
||||||
|
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
|
||||||
|
for thread in Post::read_overboard_page(&ctx, page).await?.into_iter() {
|
||||||
|
let replies = thread.read_replies(&ctx).await?;
|
||||||
|
|
||||||
|
threads.push((thread, replies))
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = OverboardTemplate {
|
||||||
|
tcx,
|
||||||
|
boards,
|
||||||
|
threads,
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
30
src/web/overboard_catalog.rs
Normální soubor
@ -0,0 +1,30 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Post,
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "overboard_catalog.html")]
|
||||||
|
struct OverboardCatalogTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
threads: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/overboard/catalog")]
|
||||||
|
pub async fn overboard_catalog(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let threads = Post::read_overboard_catalog(&ctx).await?;
|
||||||
|
|
||||||
|
let template = OverboardCatalogTemplate { tcx, threads };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
28
src/web/staff/account.rs
Spustitelný soubor
@ -0,0 +1,28 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Account,
|
||||||
|
error::NekrochanError,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/account.html")]
|
||||||
|
struct AccountTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/account")]
|
||||||
|
pub async fn account(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
let template = AccountTemplate { tcx, account };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
26
src/web/staff/accounts.rs
Spustitelný soubor
@ -0,0 +1,26 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Account,
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/accounts.html")]
|
||||||
|
struct AccountsTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
accounts: Vec<Account>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/accounts")]
|
||||||
|
pub async fn accounts(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let accounts = Account::read_all(&ctx).await?;
|
||||||
|
let template = AccountsTemplate { tcx, accounts };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
55
src/web/staff/actions/add_banners.rs
Spustitelný soubor
@ -0,0 +1,55 @@
|
|||||||
|
use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm};
|
||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, File},
|
||||||
|
error::NekrochanError,
|
||||||
|
web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(MultipartForm)]
|
||||||
|
pub struct AddBannersForm {
|
||||||
|
board: Text<String>,
|
||||||
|
#[multipart(rename = "files[]")]
|
||||||
|
files: Vec<TempFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/add-banners")]
|
||||||
|
pub async fn add_banners(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
MultipartForm(form): MultipartForm<AddBannersForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().board_banners()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let board = form.board.0;
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let mut new_banners = board.banners.0.clone();
|
||||||
|
let added_banners = form.files;
|
||||||
|
|
||||||
|
let mut cfg = ctx.cfg.clone();
|
||||||
|
|
||||||
|
cfg.files.videos = false;
|
||||||
|
|
||||||
|
for banner in added_banners.into_iter() {
|
||||||
|
let file = File::new(&cfg, banner, false, false).await?;
|
||||||
|
|
||||||
|
new_banners.push(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
board.update_banners(&ctx, new_banners).await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", format!("/staff/banners/{}", board.id).as_str()))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
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)
|
||||||
|
}
|
48
src/web/staff/actions/create_account.rs
Spustitelný soubor
@ -0,0 +1,48 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use pwhash::bcrypt::hash;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx, db::models::Account, error::NekrochanError, qsform::QsForm,
|
||||||
|
web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateAccountForm {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/create-account")]
|
||||||
|
pub async fn create_account(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<CreateAccountForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !account.perms().owner() {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = form.username.trim().to_owned();
|
||||||
|
let password = form.password.trim().to_owned();
|
||||||
|
|
||||||
|
if username.is_empty() || username.len() > 32 {
|
||||||
|
return Err(NekrochanError::UsernameFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if password.len() < 8 {
|
||||||
|
return Err(NekrochanError::PasswordFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = hash(password)?;
|
||||||
|
|
||||||
|
let _ = Account::create(&ctx, username, password).await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/accounts"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
50
src/web/staff/actions/create_board.rs
Spustitelný soubor
@ -0,0 +1,50 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateBoardForm {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/create-board")]
|
||||||
|
pub async fn create_board(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<CreateBoardForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !account.perms().owner() {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = form.id.trim().to_owned();
|
||||||
|
let name = form.name.trim().to_owned();
|
||||||
|
let description = form.description.trim().to_owned();
|
||||||
|
|
||||||
|
if id.is_empty() || id.len() > 16 {
|
||||||
|
return Err(NekrochanError::IdFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.is_empty() || name.len() > 32 {
|
||||||
|
return Err(NekrochanError::BoardNameFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if description.is_empty() || description.len() > 128 {
|
||||||
|
return Err(NekrochanError::DescriptionFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = Board::create(&ctx, id, name, description).await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/boards"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
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)
|
||||||
|
}
|
13
src/web/staff/actions/mod.rs
Spustitelný soubor
@ -0,0 +1,13 @@
|
|||||||
|
pub mod add_banners;
|
||||||
|
pub mod change_password;
|
||||||
|
pub mod create_account;
|
||||||
|
pub mod create_board;
|
||||||
|
pub mod delete_account;
|
||||||
|
pub mod remove_accounts;
|
||||||
|
pub mod remove_banners;
|
||||||
|
pub mod remove_bans;
|
||||||
|
pub mod remove_boards;
|
||||||
|
pub mod transfer_ownership;
|
||||||
|
pub mod update_board_config;
|
||||||
|
pub mod update_boards;
|
||||||
|
pub mod update_permissions;
|
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.into_iter() {
|
||||||
|
if let Some(account) = Account::read(&ctx, account).await? {
|
||||||
|
if account.owner {
|
||||||
|
return Err(NekrochanError::OwnerDeletionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
account.delete(&ctx).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/accounts"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
50
src/web/staff/actions/remove_banners.rs
Spustitelný soubor
@ -0,0 +1,50 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RemoveBannersForm {
|
||||||
|
board: String,
|
||||||
|
#[serde(default)]
|
||||||
|
banners: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/remove-banners")]
|
||||||
|
pub async fn remove_banners(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<RemoveBannersForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().board_banners()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let board = form.board;
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let old_banners = board.banners.0.clone();
|
||||||
|
let mut new_banners = Vec::new();
|
||||||
|
|
||||||
|
for (i, banner) in old_banners.into_iter().enumerate() {
|
||||||
|
if form.banners.contains(&i) {
|
||||||
|
banner.delete().await;
|
||||||
|
} else {
|
||||||
|
new_banners.push(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board.update_banners(&ctx, new_banners).await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", format!("/staff/banners/{}", board.id).as_str()))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
37
src/web/staff/actions/remove_bans.rs
Spustitelný soubor
@ -0,0 +1,37 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx, db::models::Ban, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RemoveBansForm {
|
||||||
|
#[serde(default)]
|
||||||
|
bans: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/remove-bans")]
|
||||||
|
pub async fn remove_bans(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<RemoveBansForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().bans()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ban in form.bans.into_iter() {
|
||||||
|
if let Some(ban) = Ban::read_by_id(&ctx, ban).await? {
|
||||||
|
ban.delete(&ctx).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/bans"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
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.into_iter() {
|
||||||
|
if let Some(board) = Board::read(&ctx, board).await? {
|
||||||
|
board.delete(&ctx).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/boards"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
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)
|
||||||
|
}
|
87
src/web/staff/actions/update_board_config.rs
Spustitelný soubor
@ -0,0 +1,87 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cfg::BoardCfg, ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm,
|
||||||
|
web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateBoardConfigForm {
|
||||||
|
board: String,
|
||||||
|
anon_name: String,
|
||||||
|
page_size: i64,
|
||||||
|
page_count: i64,
|
||||||
|
file_limit: usize,
|
||||||
|
bump_limit: i32,
|
||||||
|
reply_limit: i32,
|
||||||
|
locked: Option<String>,
|
||||||
|
user_ids: Option<String>,
|
||||||
|
flags: Option<String>,
|
||||||
|
thread_captcha: String,
|
||||||
|
reply_captcha: String,
|
||||||
|
require_thread_content: Option<String>,
|
||||||
|
require_thread_file: Option<String>,
|
||||||
|
require_reply_content: Option<String>,
|
||||||
|
require_reply_file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/update-board-config")]
|
||||||
|
pub async fn update_board_config(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<UpdateBoardConfigForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().board_config()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let board = form.board;
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let anon_name = form.anon_name;
|
||||||
|
let page_size = form.page_size;
|
||||||
|
let page_count = form.page_count;
|
||||||
|
let file_limit = form.file_limit;
|
||||||
|
let bump_limit = form.bump_limit;
|
||||||
|
let reply_limit = form.reply_limit;
|
||||||
|
let locked = form.locked.is_some();
|
||||||
|
let user_ids = form.user_ids.is_some();
|
||||||
|
let flags = form.flags.is_some();
|
||||||
|
let thread_captcha = form.thread_captcha;
|
||||||
|
let reply_captcha = form.reply_captcha;
|
||||||
|
let require_thread_content = form.require_thread_content.is_some();
|
||||||
|
let require_thread_file = form.require_thread_file.is_some();
|
||||||
|
let require_reply_content = form.require_reply_content.is_some();
|
||||||
|
let require_reply_file = form.require_reply_file.is_some();
|
||||||
|
|
||||||
|
let config = BoardCfg {
|
||||||
|
anon_name,
|
||||||
|
page_size,
|
||||||
|
page_count,
|
||||||
|
file_limit,
|
||||||
|
bump_limit,
|
||||||
|
reply_limit,
|
||||||
|
locked,
|
||||||
|
user_ids,
|
||||||
|
flags,
|
||||||
|
thread_captcha,
|
||||||
|
reply_captcha,
|
||||||
|
require_thread_content,
|
||||||
|
require_thread_file,
|
||||||
|
require_reply_content,
|
||||||
|
require_reply_file,
|
||||||
|
};
|
||||||
|
|
||||||
|
board.update_config(&ctx, config).await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/boards"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
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.is_empty() || description.len() > 128 {
|
||||||
|
return Err(NekrochanError::DescriptionFormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
for board in form.boards.into_iter() {
|
||||||
|
if let Some(board) = Board::read(&ctx, board).await? {
|
||||||
|
board.update_name(&ctx, name.clone()).await?;
|
||||||
|
board.update_description(&ctx, description.clone()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/boards"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
103
src/web/staff/actions/update_permissions.rs
Spustitelný soubor
@ -0,0 +1,103 @@
|
|||||||
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use enumflags2::BitFlags;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx, db::models::Account, error::NekrochanError, perms::Permissions, qsform::QsForm,
|
||||||
|
web::tcx::account_from_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdatePermissionsForm {
|
||||||
|
account: String,
|
||||||
|
edit_posts: Option<String>,
|
||||||
|
manage_posts: Option<String>,
|
||||||
|
capcodes: Option<String>,
|
||||||
|
staff_log: Option<String>,
|
||||||
|
reports: Option<String>,
|
||||||
|
bans: Option<String>,
|
||||||
|
board_banners: Option<String>,
|
||||||
|
board_config: Option<String>,
|
||||||
|
bypass_bans: Option<String>,
|
||||||
|
bypass_board_lock: Option<String>,
|
||||||
|
bypass_thread_lock: Option<String>,
|
||||||
|
bypass_captcha: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/staff/actions/update-permissions")]
|
||||||
|
pub async fn update_permissions(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
QsForm(form): QsForm<UpdatePermissionsForm>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !account.perms().owner() {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated_account = form.account;
|
||||||
|
let updated_account = Account::read(&ctx, updated_account.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::AccountNotFound(updated_account))?;
|
||||||
|
|
||||||
|
let mut permissions = BitFlags::empty();
|
||||||
|
|
||||||
|
if form.edit_posts.is_some() {
|
||||||
|
permissions |= Permissions::EditPosts
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.manage_posts.is_some() {
|
||||||
|
permissions |= Permissions::ManagePosts
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.capcodes.is_some() {
|
||||||
|
permissions |= Permissions::Capcodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.staff_log.is_some() {
|
||||||
|
permissions |= Permissions::StaffLog
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.reports.is_some() {
|
||||||
|
permissions |= Permissions::Reports
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.bans.is_some() {
|
||||||
|
permissions |= Permissions::Bans
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.board_banners.is_some() {
|
||||||
|
permissions |= Permissions::BoardBanners
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.board_config.is_some() {
|
||||||
|
permissions |= Permissions::BoardConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.bypass_bans.is_some() {
|
||||||
|
permissions |= Permissions::BypassBans
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.bypass_board_lock.is_some() {
|
||||||
|
permissions |= Permissions::BypassBoardLock
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.bypass_thread_lock.is_some() {
|
||||||
|
permissions |= Permissions::BypassThreadLock
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.bypass_captcha.is_some() {
|
||||||
|
permissions |= Permissions::BypassCaptcha
|
||||||
|
}
|
||||||
|
|
||||||
|
updated_account
|
||||||
|
.update_permissions(&ctx, permissions.bits())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let res = HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/staff/accounts"))
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
46
src/web/staff/banners.rs
Spustitelný soubor
@ -0,0 +1,46 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Board,
|
||||||
|
error::NekrochanError,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/banners.html")]
|
||||||
|
struct BannersTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
board: Board,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/banners/{board}")]
|
||||||
|
pub async fn banners(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
board: Path<String>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().board_banners()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let board = board.into_inner();
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let template = BannersTemplate { tcx, board };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
35
src/web/staff/bans.rs
Spustitelný soubor
@ -0,0 +1,35 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Ban,
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/bans.html")]
|
||||||
|
struct BansTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
bans: Vec<Ban>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/bans")]
|
||||||
|
pub async fn bans(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().bans()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bans = Ban::read_all(&ctx).await?;
|
||||||
|
let template = BansTemplate { tcx, bans };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
46
src/web/staff/board_config.rs
Spustitelný soubor
@ -0,0 +1,46 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Board,
|
||||||
|
error::NekrochanError,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/board-config.html")]
|
||||||
|
struct BannersTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
board: Board,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/board-config/{board}")]
|
||||||
|
pub async fn board_config(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
board: Path<String>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner() || account.perms().board_config()) {
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let board = board.into_inner();
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let template = BannersTemplate { tcx, board };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
38
src/web/staff/boards.rs
Spustitelný soubor
@ -0,0 +1,38 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Board,
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/boards.html")]
|
||||||
|
struct BoardsTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
boards: Vec<Board>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/boards")]
|
||||||
|
pub async fn boards(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let account = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
if !(account.perms().owner()
|
||||||
|
|| account.perms().board_config()
|
||||||
|
|| account.perms().board_banners())
|
||||||
|
{
|
||||||
|
return Err(NekrochanError::InsufficientPermissionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let boards = Board::read_all(&ctx).await?;
|
||||||
|
let template = BoardsTemplate { tcx, boards };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
9
src/web/staff/mod.rs
Spustitelný soubor
@ -0,0 +1,9 @@
|
|||||||
|
pub mod account;
|
||||||
|
pub mod accounts;
|
||||||
|
pub mod actions;
|
||||||
|
pub mod banners;
|
||||||
|
pub mod bans;
|
||||||
|
pub mod board_config;
|
||||||
|
pub mod boards;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod reports;
|
42
src/web/staff/permissions.rs
Spustitelný soubor
@ -0,0 +1,42 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::Account,
|
||||||
|
error::NekrochanError,
|
||||||
|
web::{
|
||||||
|
tcx::{account_from_auth, TemplateCtx},
|
||||||
|
template_response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/permissions.html")]
|
||||||
|
struct PermissionsTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/permissions/{account}")]
|
||||||
|
pub async fn permissions(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
path: Path<String>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let _ = account_from_auth(&ctx, &req).await?;
|
||||||
|
|
||||||
|
let account = path.into_inner();
|
||||||
|
let account = Account::read(&ctx, account.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::AccountNotFound(account))?;
|
||||||
|
|
||||||
|
let template = PermissionsTemplate { tcx, account };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
23
src/web/staff/reports.rs
Spustitelný soubor
@ -0,0 +1,23 @@
|
|||||||
|
use actix_web::{get, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
error::NekrochanError,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "staff/reports.html")]
|
||||||
|
struct ReportsTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/staff/reports")]
|
||||||
|
async fn reports(ctx: Data<Ctx>, req: HttpRequest) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let template = ReportsTemplate { tcx };
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
113
src/web/tcx.rs
Spustitelný soubor
@ -0,0 +1,113 @@
|
|||||||
|
use actix_web::HttpRequest;
|
||||||
|
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||||
|
use redis::AsyncCommands;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
net::{IpAddr, Ipv4Addr},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::Claims, cfg::Cfg, ctx::Ctx, db::models::Account, error::NekrochanError,
|
||||||
|
perms::PermissionWrapper,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct TemplateCtx {
|
||||||
|
pub cfg: Cfg,
|
||||||
|
pub boards: Vec<String>,
|
||||||
|
pub logged_in: bool,
|
||||||
|
pub perms: PermissionWrapper,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub yous: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateCtx {
|
||||||
|
pub async fn new(ctx: &Ctx, req: &HttpRequest) -> Result<TemplateCtx, NekrochanError> {
|
||||||
|
let cfg = ctx.cfg.to_owned();
|
||||||
|
let boards = ctx.cache().lrange("board_ids", 0, -1).await?;
|
||||||
|
|
||||||
|
let account = account_from_auth_opt(ctx, req).await?;
|
||||||
|
let logged_in = account.is_some();
|
||||||
|
|
||||||
|
let perms = match &account {
|
||||||
|
Some(account) => account.perms(),
|
||||||
|
None => PermissionWrapper::new(0, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = req.cookie("name").map(|cookie| cookie.value().into());
|
||||||
|
let password_cookie = req.cookie("password").map(|cookie| cookie.value().into());
|
||||||
|
|
||||||
|
let password: String = match password_cookie {
|
||||||
|
Some(password) => password,
|
||||||
|
None => thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(8)
|
||||||
|
.map(char::from)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ip, _) = ip_from_req(req)?;
|
||||||
|
let yous = ctx.cache().zrange(format!("yous:{ip}"), 0, -1).await?;
|
||||||
|
|
||||||
|
let tcx = Self {
|
||||||
|
cfg,
|
||||||
|
boards,
|
||||||
|
logged_in,
|
||||||
|
perms,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
ip,
|
||||||
|
yous,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(tcx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account_from_auth(ctx: &Ctx, req: &HttpRequest) -> Result<Account, NekrochanError> {
|
||||||
|
let account = account_from_auth_opt(ctx, req)
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::NotLoggedInError)?;
|
||||||
|
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn account_from_auth_opt(
|
||||||
|
ctx: &Ctx,
|
||||||
|
req: &HttpRequest,
|
||||||
|
) -> Result<Option<Account>, NekrochanError> {
|
||||||
|
let account = match req.cookie("auth") {
|
||||||
|
Some(auth) => {
|
||||||
|
let claims = Claims::decode(ctx, auth.value())?;
|
||||||
|
let account = Account::read(ctx, claims.sub)
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::InvalidAuthError)?;
|
||||||
|
|
||||||
|
Some(account)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ip_from_req(req: &HttpRequest) -> Result<(IpAddr, String), NekrochanError> {
|
||||||
|
let ip = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
|
||||||
|
|
||||||
|
// let ip = req
|
||||||
|
// .headers()
|
||||||
|
// .get("X-Real-IP")
|
||||||
|
// .ok_or(NekrochanError::HeaderError("X-Real-IP"))?
|
||||||
|
// .to_str()
|
||||||
|
// .map_err(|_| NekrochanError::HeaderError("X-Real-IP"))?
|
||||||
|
// .parse::<IpAddr>()?;
|
||||||
|
|
||||||
|
let country = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Country-Code")
|
||||||
|
.map(|hdr| hdr.to_str().unwrap_or("xx").to_ascii_lowercase())
|
||||||
|
.unwrap_or_else(|| "xx".into());
|
||||||
|
|
||||||
|
Ok((ip, country))
|
||||||
|
}
|
59
src/web/thread.rs
Normální soubor
@ -0,0 +1,59 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
http::StatusCode,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
filters,
|
||||||
|
web::{tcx::TemplateCtx, template_response},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "thread.html")]
|
||||||
|
struct ThreadTemplate {
|
||||||
|
tcx: TemplateCtx,
|
||||||
|
board: Board,
|
||||||
|
thread: Post,
|
||||||
|
replies: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/boards/{board}/{thread}")]
|
||||||
|
pub async fn thread(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
path: Path<(String, i32)>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
|
||||||
|
let (board, id) = path.into_inner();
|
||||||
|
let board = Board::read(&ctx, board.clone())
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||||
|
|
||||||
|
let thread = Post::read(&ctx, board.id.clone(), id)
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
|
||||||
|
|
||||||
|
if thread.thread.is_some() {
|
||||||
|
return Ok(HttpResponse::build(StatusCode::PERMANENT_REDIRECT)
|
||||||
|
.append_header(("Location", thread.post_url()))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let replies = thread.read_replies(&ctx).await?;
|
||||||
|
|
||||||
|
let template = ThreadTemplate {
|
||||||
|
tcx,
|
||||||
|
board,
|
||||||
|
thread,
|
||||||
|
replies,
|
||||||
|
};
|
||||||
|
|
||||||
|
template_response(template)
|
||||||
|
}
|
binární
static/banner.gif
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 206 KiB |
binární
static/favicon.ico
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 15 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 |
binární
static/flags/an.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 488 B |
binární
static/flags/ao.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 428 B |
binární
static/flags/ar.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 506 B |
binární
static/flags/as.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 647 B |
binární
static/flags/at.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 403 B |
binární
static/flags/au.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 673 B |
binární
static/flags/aw.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 524 B |
binární
static/flags/ax.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 663 B |
binární
static/flags/az.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 589 B |
binární
static/flags/ba.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 593 B |
binární
static/flags/bb.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 585 B |
binární
static/flags/bd.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 504 B |
binární
static/flags/be.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 449 B |
binární
static/flags/bf.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 497 B |
binární
static/flags/bg.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 462 B |
binární
static/flags/bh.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 457 B |
binární
static/flags/bi.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 675 B |
binární
static/flags/bj.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 486 B |
binární
static/flags/bm.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 611 B |
binární
static/flags/bn.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 639 B |
binární
static/flags/bo.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 500 B |
binární
static/flags/br.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 593 B |
binární
static/flags/bs.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 526 B |
binární
static/flags/bt.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 631 B |
binární
static/flags/bv.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 512 B |
binární
static/flags/bw.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 443 B |
binární
static/flags/by.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 514 B |
binární
static/flags/bz.png
Spustitelný soubor
Za Šířka: | Výška: | Velikost: 600 B |