Merdžnout větve nebo něco

Tento commit je obsažen v:
sneedmaster 2024-03-05 17:50:05 +01:00
revize de5906414d
49 změnil soubory, kde provedl 1214 přidání a 372 odebrání

3
.gitignore vendorováno
Zobrazit soubor

@ -1,7 +1,6 @@
/pages/*.html
/target /target
/templates_min /templates_min
/uploads /uploads
Nekrochan.toml Nekrochan.toml
.env .env
cloud-run.sh

47
Nekrochan.toml.template Spustitelný soubor
Zobrazit soubor

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

Zobrazit soubor

@ -2,6 +2,102 @@
100% český imidžbórdový skript 100% český imidžbórdový skript
>100% český přestože je kód anglicky... > 100% český přestože je kód anglicky...
Brzy dostupný na https://czchan.org/. Brzy dostupný na https://czchan.org/.
## Tutoriál nebo něco
Pravděpodobně to běží jenom na Linuxu, ale nikdo na serverech Windows stejně nepoužívá. Tutoriál počítá se systémem Ubuntu a je možné, že je nekompletní.
### Nainstaluj Rust
Ne, nejsem transka (zatím).
```
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### Nainstaluj ostatní požadavky
```
# Potřebné ke kompilaci
sudo apt install binutils build-essential libssl-dev libpq-dev postgresql
# Potřebné k funkci
sudo apt install imagemagick ffmpeg
```
### Vytvoř databázi
```
sudo adduser nekrochan --system --disabled-login --no-create-home --group
sudo passwd nekrochan # Nastavíme heslo pro systémového uživatele
sudo -iu postgres psql -c "CREATE USER nekrochan WITH PASSWORD 'password';"
sudo -iu postgres psql -c "CREATE DATABASE nekrochan WITH OWNER nekrochan;"
```
### Automatická konfigurace
```
chmod +x ./configure.sh
./configure.sh
```
### Nastartuj server
```
cargo run --release
```
### Vytvoř nástěnku
Po kompilaci by se měl spustit server na https://localhost:7000/. Stránka ti pravděpodobně řekne, že ještě nebyla inicializována domovní stránka. Je potřeba vytvořit nástěnku. Nejdříve je ale potřeba vytvořit administátorský účet.
Heslo v příkladu je "password", můžeš použít příklad a heslo změnit potom v administrátorském rozhraní.
```
sudo -iu postgres psql -d nekrochan -c "INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$jHl27pbYNvgpQxksmF.N/O0IHrfFBDY1Tg/qBX/UwrMa3j7owkiQm', true, '131072'::jsonb);"
```
Po příkazu budeš muset restartovat server, aby se změna projevila v mezipaměti.
Nástěnka lze vytvořit po přihlášení na https://localhost:7000/login na stránce https://localhost:7000/staff/boards
### Automatický start
Nejprve vytvoříme složku pro nekrochan a zkopírujeme tam potřebné soubory.
```
sudo mkdir -p /srv/nekrochan
sudo chown nekrochan:nekrochan /srv/nekrochan
sudo cp -r ./target/release/nekrochan Nekrochan.toml ./pages ./static ./uploads /srv/nekrochan/
```
Nyní vytvoříme skript pro systemd, aby server automaticky nastartovat po zapnutí počítače. Uložíme ho jako `/etc/systemd/system/nekrochan.service`.
```
[Unit]
Description=Nekrochan
After=network.target
[Service]
User=nekrochan
ExecStart=/srv/nekrochan/nekrochan
WorkingDirectory=/srv/nekrochan
Environment=RUST_LOG="info"
Restart=on-failure
ProtectSystem=yes
PrivateTmp=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
```
### Další konfigurace
Většinu možností najdeš v souboru `Nekrochan.toml`. Vlastní stránky (např. pravidla, faq apod.) můžeš nahrávat do složky `pages`.
Také budeš pravděpodobně chtít nastavit reverzní proxy, např. NGINX. IP adresu posílej serveru v hlavičce `X-Forwarded-For` a kód země (potřebný pro vlajky) v hlavičce `X-Country-Code`.

68
configure.sh Spustitelný soubor
Zobrazit soubor

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

Zobrazit soubor

@ -26,5 +26,3 @@ CREATE TABLE bans (
expires TIMESTAMPTZ DEFAULT NULL, expires TIMESTAMPTZ DEFAULT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$XcxAe19B1eWC15sfnDRyiuiNLZIhdL7PMTnTmtTfglJIz0zOpN3oa', true, '16383'::jsonb);

Zobrazit soubor

@ -31,7 +31,9 @@ pub struct ServerCfg {
pub struct SiteCfg { pub struct SiteCfg {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub default_noko: bool, pub theme: String,
pub links: Vec<Vec<(String, String)>>,
pub noko: bool,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@ -73,4 +75,5 @@ pub struct BoardCfg {
pub antispam_ip: i64, pub antispam_ip: i64,
pub antispam_content: i64, pub antispam_content: i64,
pub antispam_both: i64, pub antispam_both: i64,
pub thread_cooldown: i64,
} }

Zobrazit soubor

@ -40,10 +40,11 @@ impl Board {
ip INET NOT NULL, ip INET NOT NULL,
bumps INT NOT NULL DEFAULT 0, bumps INT NOT NULL DEFAULT 0,
replies INT NOT NULL DEFAULT 0, replies INT NOT NULL DEFAULT 0,
quotes BIGINT[] NOT NULL DEFAULT '{{}}',
sticky BOOLEAN NOT NULL DEFAULT false, sticky BOOLEAN NOT NULL DEFAULT false,
locked BOOLEAN NOT NULL DEFAULT false, locked BOOLEAN NOT NULL DEFAULT false,
reported TIMESTAMPTZ DEFAULT NULL, reported TIMESTAMPTZ DEFAULT NULL,
reports JSONB NOT NULL DEFAULT '[]'::json, reports JSONB NOT NULL DEFAULT '[]'::jsonb,
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)"#, )"#,

Zobrazit soubor

@ -68,13 +68,31 @@ pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> {
for post in posts { for post in posts {
let ip_key = format!("by_ip:{}", post.ip); let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup)); let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id); let member = format!("{}/{}", post.board, post.id);
let score = post.created.timestamp_micros(); let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?; ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?; ctx.cache().zadd(content_key, &member, score).await?;
if post.thread.is_none() {
let key = format!("last_thread:{}", post.ip);
let last_thread = ctx
.cache()
.get::<_, Option<i64>>(&key)
.await?
.unwrap_or_default();
let timestamp = post.created.timestamp_micros();
if timestamp > last_thread {
ctx.cache().set(key, timestamp).await?;
}
}
} }
} }

Zobrazit soubor

@ -63,6 +63,7 @@ pub struct Post {
pub ip: IpAddr, pub ip: IpAddr,
pub bumps: i32, pub bumps: i32,
pub replies: i32, pub replies: i32,
pub quotes: Vec<i64>,
pub sticky: bool, pub sticky: bool,
pub locked: bool, pub locked: bool,
pub reported: Option<DateTime<Utc>>, pub reported: Option<DateTime<Utc>>,

Zobrazit soubor

@ -78,13 +78,23 @@ impl Post {
} }
let ip_key = format!("by_ip:{ip}"); let ip_key = format!("by_ip:{ip}");
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", board.id, post.id); let member = format!("{}/{}", board.id, post.id);
let score = post.created.timestamp_micros(); let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?; ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?; ctx.cache().zadd(content_key, &member, score).await?;
if thread.is_none() {
ctx.cache()
.set(format!("last_thread:{ip}"), post.created.timestamp_micros())
.await?;
}
Ok(post) Ok(post)
} }
@ -116,8 +126,7 @@ impl Post {
} }
pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> { pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> {
let post = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2") let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board))
.bind(board)
.bind(id) .bind(id)
.fetch_optional(ctx.db()) .fetch_optional(ctx.db())
.await?; .await?;
@ -324,11 +333,16 @@ impl Post {
.bind(content) .bind(content)
.bind(&content_nomarkup) .bind(&content_nomarkup)
.bind(self.id) .bind(self.id)
.fetch_one(ctx.db()) .fetch_optional(ctx.db())
.await?; .await?;
let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes())); let Some(post) = post else { return Ok(()) };
let new_key = format!("by_content:{}", digest(content_nomarkup.as_bytes()));
let old_key = format!(
"by_content:{}",
digest(self.content_nomarkup.to_lowercase())
);
let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase()));
let member = format!("{}/{}", self.board, self.id); let member = format!("{}/{}", self.board, self.id);
let score = Utc::now().timestamp_micros(); let score = Utc::now().timestamp_micros();
@ -339,6 +353,21 @@ impl Post {
Ok(()) Ok(())
} }
pub async fn update_quotes(&self, ctx: &Ctx, id: i64) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET quotes = array_append(quotes, $1) WHERE id = $2 RETURNING *",
self.board
))
.bind(id)
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> { pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let mut files = self.files.clone(); let mut files = self.files.clone();
@ -362,7 +391,7 @@ impl Post {
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> { pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let to_be_deleted: Vec<Post> = query_as(&format!( let to_be_deleted: Vec<Post> = query_as(&format!(
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1", "SELECT * FROM posts_{} WHERE id = $1 OR thread = $1 ORDER BY id ASC",
self.board self.board
)) ))
.bind(self.id) .bind(self.id)
@ -380,17 +409,36 @@ impl Post {
let live_quote = format!("<a class=\"quote\" href=\"{url}\">&gt;&gt;{id}</a>"); let live_quote = format!("<a class=\"quote\" href=\"{url}\">&gt;&gt;{id}</a>");
let dead_quote = format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>"); let dead_quote = format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>");
query(&format!( let posts = query_as(&format!(
"UPDATE posts_{} SET content = REPLACE(content, $1, $2)", "UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *",
self.board self.board, live_quote
)) ))
.bind(live_quote) .bind(live_quote)
.bind(dead_quote) .bind(dead_quote)
.execute(ctx.db()) .fetch_all(ctx.db())
.await?; .await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let posts = query_as(&format!(
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes) RETURNING *",
self.board
))
.bind(id)
.fetch_all(ctx.db())
.await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let ip_key = format!("by_ip:{}", post.ip); let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id); let member = format!("{}/{}", post.board, post.id);

Zobrazit soubor

@ -24,7 +24,7 @@ pub enum NekrochanError {
CapcodeFormatError, CapcodeFormatError,
#[error("Obsah nesmí mít více než 10000 znaků.")] #[error("Obsah nesmí mít více než 10000 znaků.")]
ContentFormatError, ContentFormatError,
#[error("Popis musí mít 1-128 znaků.")] #[error("Popis nesmí mít více než 128 znaků.")]
DescriptionFormatError, DescriptionFormatError,
#[error("E-mail nesmí mít více než 256 znaků.")] #[error("E-mail nesmí mít více než 256 znaků.")]
EmailFormatError, EmailFormatError,
@ -36,11 +36,9 @@ pub enum NekrochanError {
FileLimitError(usize), FileLimitError(usize),
#[error("Tvůj příspěvek vypadá jako spam.")] #[error("Tvůj příspěvek vypadá jako spam.")]
FloodError, FloodError,
#[error("Reverzní proxy nevrátilo vyžadovanou hlavičku '{}'.", .0)]
HeaderError(&'static str),
#[error("Domovní stránka vznikne po vytvoření nástěnky.")] #[error("Domovní stránka vznikne po vytvoření nástěnky.")]
HomePageError, HomePageError,
#[error("ID musí mít 1-16 znaků.")] #[error("ID musí mít 1-16 znaků a obsahovat pouze alfanumerické znaky.")]
IdFormatError, IdFormatError,
#[error("Nesprávné řešení CAPTCHA.")] #[error("Nesprávné řešení CAPTCHA.")]
IncorrectCaptchaError, IncorrectCaptchaError,
@ -78,6 +76,8 @@ pub enum NekrochanError {
OverboardError, OverboardError,
#[error("Účet vlastníka nemůže být vymazán.")] #[error("Účet vlastníka nemůže být vymazán.")]
OwnerDeletionError, OwnerDeletionError,
#[error("Stránka {} neexistuje", .0)]
PageNotFound(String),
#[error("Heslo musí mít alespoň 8 znaků.")] #[error("Heslo musí mít alespoň 8 znaků.")]
PasswordFormatError, PasswordFormatError,
#[error("Jméno nesmí mít více než 32 znaků.")] #[error("Jméno nesmí mít více než 32 znaků.")]
@ -231,7 +231,6 @@ impl ResponseError for NekrochanError {
NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY, NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY,
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST, NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS, NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS,
NekrochanError::HeaderError(_) => StatusCode::BAD_GATEWAY,
NekrochanError::HomePageError => StatusCode::NOT_FOUND, NekrochanError::HomePageError => StatusCode::NOT_FOUND,
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST, NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED, NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
@ -252,6 +251,7 @@ impl ResponseError for NekrochanError {
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED, NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR, NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN, NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST, NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST, NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND, NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,

Zobrazit soubor

@ -1,9 +1,8 @@
use std::process::Command;
use actix_multipart::form::tempfile::TempFile; use actix_multipart::form::tempfile::TempFile;
use chrono::Utc; use chrono::Utc;
use std::process::Command;
use tokio::{ use tokio::{
fs::{remove_file, rename}, fs::{copy, remove_file},
task::spawn_blocking, task::spawn_blocking,
}; };
@ -67,8 +66,6 @@ impl File {
let timestamp = Utc::now().timestamp_micros(); let timestamp = Utc::now().timestamp_micros();
let format = format.to_owned(); let format = format.to_owned();
let new_name = format!("{timestamp}.{format}");
let (thumb_format, thumb_name) = if thumb { let (thumb_format, thumb_name) = if thumb {
let format = if video { "png".into() } else { format.clone() }; let format = if video { "png".into() } else { format.clone() };
@ -77,15 +74,15 @@ impl File {
(None, None) (None, None)
}; };
rename(temp_file.file.path(), format!("/tmp/{new_name}")).await?; let path = temp_file.file.path().to_string_lossy().to_string();
let (width, height) = if video { let (width, height) = if video {
process_video(cfg, original_name.clone(), new_name.clone(), thumb_name).await? process_video(cfg, original_name.clone(), path.clone(), thumb_name).await?
} else { } else {
process_image(cfg, original_name.clone(), new_name.clone(), thumb_name).await? process_image(cfg, original_name.clone(), path.clone(), thumb_name).await?
}; };
rename(format!("/tmp/{new_name}"), format!("./uploads/{new_name}")).await?; copy(path, format!("./uploads/{timestamp}.{format}")).await?;
let file = File { let file = File {
original_name, original_name,
@ -134,14 +131,14 @@ impl File {
async fn process_image( async fn process_image(
cfg: &Cfg, cfg: &Cfg,
original_name: String, original_name: String,
new_name: String, path: String,
thumb_name: Option<String>, thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> { ) -> Result<(u32, u32), NekrochanError> {
let new_name_ = new_name.clone(); let path_ = path.clone();
let identify_out = spawn_blocking(move || { let identify_out = spawn_blocking(move || {
Command::new("identify") Command::new("identify")
.args(["-format", "%wx%h", &format!("/tmp/{new_name_}[0]")]) .args(["-format", "%wx%h", &format!("{path_}[0]")])
.output() .output()
}) })
.await??; .await??;
@ -181,7 +178,7 @@ async fn process_image(
let output = spawn_blocking(move || { let output = spawn_blocking(move || {
Command::new("convert") Command::new("convert")
.arg(&format!("/tmp/{new_name}")) .arg(path)
.arg("-coalesce") .arg("-coalesce")
.arg("-thumbnail") .arg("-thumbnail")
.arg(&format!("{thumb_size}x{thumb_size}>")) .arg(&format!("{thumb_size}x{thumb_size}>"))
@ -205,10 +202,10 @@ async fn process_image(
async fn process_video( async fn process_video(
cfg: &Cfg, cfg: &Cfg,
original_name: String, original_name: String,
new_name: String, path: String,
thumb_name: Option<String>, thumb_name: Option<String>,
) -> Result<(u32, u32), NekrochanError> { ) -> Result<(u32, u32), NekrochanError> {
let new_name_ = new_name.clone(); let path_ = path.clone();
let ffprobe_out = spawn_blocking(move || { let ffprobe_out = spawn_blocking(move || {
Command::new("ffprobe") Command::new("ffprobe")
@ -221,7 +218,7 @@ async fn process_video(
"stream=width,height", "stream=width,height",
"-of", "-of",
"csv=s=x:p=0", "csv=s=x:p=0",
&format!("/tmp/{new_name_}"), &path_,
]) ])
.output() .output()
}) })
@ -271,7 +268,7 @@ async fn process_video(
Command::new("ffmpeg") Command::new("ffmpeg")
.args([ .args([
"-i", "-i",
&format!("/tmp/{new_name}"), &path,
"-ss", "-ss",
"00:00:00.50", "00:00:00.50",
"-vframes", "-vframes",

Zobrazit soubor

@ -1,6 +1,9 @@
use askama::Template;
use db::models::{Board, Post};
use error::NekrochanError; use error::NekrochanError;
use web::tcx::TemplateCtx;
const GENERIC_PAGE_SIZE: i64 = 15; const GENERIC_PAGE_SIZE: i64 = 10;
pub mod auth; pub mod auth;
pub mod cfg; pub mod cfg;
@ -45,3 +48,14 @@ pub fn check_page(
Ok(()) Ok(())
} }
#[derive(Template)]
#[template(
ext = "html",
source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}"
)]
pub struct PostTemplate<'a> {
tcx: &'a TemplateCtx,
board: &'a Board,
post: &'a Post,
}

Zobrazit soubor

@ -7,13 +7,16 @@ use uuid::Uuid;
use crate::{ use crate::{
db::models::{Board, Post}, db::models::{Board, Post},
filters,
web::tcx::TemplateCtx, web::tcx::TemplateCtx,
PostTemplate,
}; };
#[derive(Message)] #[derive(Message)]
#[rtype(result = "()")] #[rtype(result = "()")]
pub struct SessionMessage(pub String); pub enum SessionMessage {
Data(String),
Stop,
}
#[derive(Message)] #[derive(Message)]
#[rtype(result = "()")] #[rtype(result = "()")]
@ -56,17 +59,6 @@ pub struct PostRemovedMessage {
pub post: Post, pub post: Post,
} }
#[derive(Template)]
#[template(
ext = "html",
source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}"
)]
struct PostTemplate {
tcx: TemplateCtx,
post: Post,
board: Board,
}
pub struct LiveHub { pub struct LiveHub {
pub cache: Connection, pub cache: Connection,
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>, pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
@ -120,6 +112,10 @@ impl Handler<DisconnectMessage> for LiveHub {
.filter(|uuid| **uuid != msg.uuid) .filter(|uuid| **uuid != msg.uuid)
.map(Uuid::clone) .map(Uuid::clone)
.collect(); .collect();
if recv_by_thread.is_empty() {
self.recv_by_thread.remove(&msg.thread);
}
} }
} }
@ -149,16 +145,16 @@ impl Handler<PostCreatedMessage> for LiveHub {
tcx.update_yous(&mut self.cache).ok(); tcx.update_yous(&mut self.cache).ok();
let post = post.clone(); let tcx = &tcx;
let board = &board;
let post = &post;
let id = post.id; let id = post.id;
let tcx = tcx.clone();
let board = board.clone();
let html = PostTemplate { tcx, post, board } let html = PostTemplate { tcx, board, post }
.render() .render()
.unwrap_or_default(); .unwrap_or_default();
recv.do_send(SessionMessage( recv.do_send(SessionMessage::Data(
json!({ "type": "created", "id": id, "html": html }).to_string(), json!({ "type": "created", "id": id, "html": html }).to_string(),
)); ));
} }
@ -175,18 +171,20 @@ impl Handler<TargetedPostCreatedMessage> for LiveHub {
return; return;
}; };
let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid)else { let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid) else {
return; return;
}; };
let id = post.id; let id = post.id;
let tcx = tcx.clone(); let tcx = &tcx;
let board = &board;
let post = &post;
let html = PostTemplate { tcx, post, board } let html = PostTemplate { tcx, board, post }
.render() .render()
.unwrap_or_default(); .unwrap_or_default();
recv.do_send(SessionMessage( recv.do_send(SessionMessage::Data(
json!({ "type": "created", "id": id, "html": html }).to_string(), json!({ "type": "created", "id": id, "html": html }).to_string(),
)); ));
} }
@ -218,16 +216,16 @@ impl Handler<PostUpdatedMessage> for LiveHub {
tcx.update_yous(&mut self.cache).ok(); tcx.update_yous(&mut self.cache).ok();
let post = post.clone();
let id = post.id; let id = post.id;
let tcx = tcx.clone(); let tcx = &tcx;
let board = board.clone(); let board = &board;
let post = &post;
let html = PostTemplate { tcx, post, board } let html = PostTemplate { tcx, post, board }
.render() .render()
.unwrap_or_default(); .unwrap_or_default();
recv.do_send(SessionMessage( recv.do_send(SessionMessage::Data(
json!({ "type": "updated", "id": id, "html": html }).to_string(), json!({ "type": "updated", "id": id, "html": html }).to_string(),
)); ));
} }
@ -249,12 +247,24 @@ impl Handler<PostRemovedMessage> for LiveHub {
None => return, None => return,
}; };
if post.thread.is_none() {
for uuid in uuids { for uuid in uuids {
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else { let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
continue; continue;
}; };
recv.do_send(SessionMessage( recv.do_send(SessionMessage::Stop);
}
return;
}
for uuid in uuids {
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
continue;
};
recv.do_send(SessionMessage::Data(
json!({ "type": "removed", "id": post.id }).to_string(), json!({ "type": "removed", "id": post.id }).to_string(),
)); ));
} }

Zobrazit soubor

@ -1,5 +1,6 @@
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler}; use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext}; use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -21,12 +22,14 @@ impl Actor for LiveSession {
impl Handler<SessionMessage> for LiveSession { impl Handler<SessionMessage> for LiveSession {
type Result = (); type Result = ();
fn handle( fn handle(&mut self, msg: SessionMessage, ctx: &mut Self::Context) -> Self::Result {
&mut self, match msg {
SessionMessage(msg): SessionMessage, SessionMessage::Data(data) => ctx.text(data),
ctx: &mut Self::Context, SessionMessage::Stop => {
) -> Self::Result { ctx.text(json!({ "type": "thread_removed" }).to_string());
ctx.text(msg) self.finished(ctx)
}
};
} }
} }
@ -47,6 +50,11 @@ impl StreamHandler<Result<WsMessage, ProtocolError>> for LiveSession {
fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) { fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) {
match msg { match msg {
Ok(WsMessage::Text(text)) => {
if text == "{\"type\":\"ping\"}" {
ctx.text("{\"type\":\"pong\"}");
}
}
Ok(WsMessage::Ping(data)) => ctx.pong(&data), Ok(WsMessage::Ping(data)) => ctx.pong(&data),
Ok(WsMessage::Close(_)) => self.finished(ctx), Ok(WsMessage::Close(_)) => self.finished(ctx),
_ => (), _ => (),

Zobrazit soubor

@ -73,6 +73,8 @@ async fn run() -> Result<(), Error> {
.service(web::news::news) .service(web::news::news)
.service(web::overboard::overboard) .service(web::overboard::overboard)
.service(web::overboard_catalog::overboard_catalog) .service(web::overboard_catalog::overboard_catalog)
.service(web::page::page)
.service(web::thread_json::thread_json)
.service(web::thread::thread) .service(web::thread::thread)
.service(web::actions::appeal_ban::appeal_ban) .service(web::actions::appeal_ban::appeal_ban)
.service(web::actions::create_post::create_post) .service(web::actions::create_post::create_post)

Zobrazit soubor

@ -111,10 +111,10 @@ pub async fn markup(
board: Option<String>, board: Option<String>,
op: Option<i64>, op: Option<i64>,
text: &str, text: &str,
) -> Result<String, NekrochanError> { ) -> Result<(String, Vec<Post>), NekrochanError> {
let text = escape_html(text); let text = escape_html(text);
let text = if let Some(board) = board { let (text, quoted_posts) = if let Some(board) = board {
let quoted_posts = get_quoted_posts(ctx, &board, &text).await?; let quoted_posts = get_quoted_posts(ctx, &board, &text).await?;
let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| { let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| {
@ -142,9 +142,14 @@ pub async fn markup(
} }
}); });
text.to_string() let quoted_posts = quoted_posts
.into_values()
.filter(|post| op == Some(post.thread.unwrap_or(post.id)))
.collect();
(text.to_string(), quoted_posts)
} else { } else {
text (text, Vec::new())
}; };
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">&gt;$1</span>"); let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">&gt;$1</span>");
@ -173,7 +178,7 @@ pub async fn markup(
text text
}; };
Ok(text.to_string()) Ok((text.to_string(), quoted_posts))
} }
fn escape_html(text: &str) -> String { fn escape_html(text: &str) -> String {

Zobrazit soubor

@ -67,7 +67,7 @@ pub async fn create_post(
} }
let mut bump = true; let mut bump = true;
let mut noko = ctx.cfg.site.default_noko; let mut noko = ctx.cfg.site.noko;
let thread = match form.thread { let thread = match form.thread {
Some(Text(thread)) => { Some(Text(thread)) => {
@ -143,11 +143,11 @@ pub async fn create_post(
bump = false; bump = false;
} }
if !ctx.cfg.site.default_noko && email_lower == "noko" { if !ctx.cfg.site.noko && email_lower == "noko" {
noko = true noko = true
} }
if ctx.cfg.site.default_noko { if ctx.cfg.site.noko {
if email_lower == "nonoko" { if email_lower == "nonoko" {
noko = false; noko = false;
} }
@ -170,30 +170,13 @@ pub async fn create_post(
Some(email_raw.into()) Some(email_raw.into())
}; };
let content_nomarkup = form.content.0.trim().to_owned(); let password_raw = form.password.trim();
if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content) if password_raw.len() < 8 {
|| (thread.is_some() && board.config.0.require_reply_content) return Err(NekrochanError::PasswordFormatError);
{
return Err(NekrochanError::NoContentError);
} }
if content_nomarkup.len() > 10000 { let password = hash(password_raw)?;
return Err(NekrochanError::ContentFormatError);
}
let content = markup(
&ctx,
&perms,
Some(board.id.clone()),
thread.as_ref().map(|t| t.id),
&content_nomarkup,
)
.await?;
if board.config.antispam {
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
}
if form.files.len() > board.config.0.file_limit { if form.files.len() > board.config.0.file_limit {
return Err(NekrochanError::FileLimitError(board.config.0.file_limit)); return Err(NekrochanError::FileLimitError(board.config.0.file_limit));
@ -212,18 +195,35 @@ pub async fn create_post(
files.push(file); files.push(file);
} }
let thread_id = thread.as_ref().map(|t| t.id);
let content_nomarkup = form.content.0.trim().to_owned();
if board.config.antispam && !(perms.owner() || perms.bypass_antispam()) {
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
}
if content_nomarkup.is_empty() && files.is_empty() { if content_nomarkup.is_empty() && files.is_empty() {
return Err(NekrochanError::EmptyPostError); return Err(NekrochanError::EmptyPostError);
} }
let password_raw = form.password.trim(); if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content)
|| (thread.is_some() && board.config.0.require_reply_content)
if password_raw.len() < 8 { {
return Err(NekrochanError::PasswordFormatError); return Err(NekrochanError::NoContentError);
} }
let password = hash(password_raw)?; if content_nomarkup.len() > 10000 {
let thread_id = thread.as_ref().map(|t| t.id); return Err(NekrochanError::ContentFormatError);
}
let (content, quoted_posts) = markup(
&ctx,
&perms,
Some(board.id.clone()),
thread.as_ref().map(|t| t.id),
&content_nomarkup,
)
.await?;
let post = Post::create( let post = Post::create(
&ctx, &ctx,
@ -243,6 +243,10 @@ pub async fn create_post(
) )
.await?; .await?;
for quoted_post in quoted_posts {
quoted_post.update_quotes(&ctx, post.id).await?;
}
let ts = thread.as_ref().map_or_else( let ts = thread.as_ref().map_or_else(
|| post.created.timestamp_micros(), || post.created.timestamp_micros(),
|thread| thread.created.timestamp_micros(), |thread| thread.created.timestamp_micros(),
@ -320,5 +324,16 @@ pub async fn check_spam(
return Err(NekrochanError::FloodError); return Err(NekrochanError::FloodError);
} }
let last_thread: Option<i64> = ctx.cache().get(format!("last_thread:{ip}")).await?;
if let Some(last_thread) = last_thread {
let since_last_thread = Utc::now().timestamp_micros() - last_thread;
let since_last_thread = Duration::microseconds(since_last_thread);
if since_last_thread.num_seconds() < board.config.thread_cooldown {
return Err(NekrochanError::FloodError);
}
}
Ok(()) Ok(())
} }

Zobrazit soubor

@ -1,4 +1,5 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse}; use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use sqlx::query;
use std::{collections::HashMap, fmt::Write}; use std::{collections::HashMap, fmt::Write};
use crate::{ use crate::{
@ -38,7 +39,7 @@ pub async fn edit_posts(
for (key, content_nomarkup) in edits { for (key, content_nomarkup) in edits {
let post = &posts[&key]; let post = &posts[&key];
let content_nomarkup = content_nomarkup.trim(); let content_nomarkup = content_nomarkup.trim();
let content = markup( let (content, quoted_posts) = markup(
&ctx, &ctx,
&tcx.perms, &tcx.perms,
Some(post.board.clone()), Some(post.board.clone()),
@ -49,6 +50,19 @@ pub async fn edit_posts(
post.update_content(&ctx, content, content_nomarkup.into()) post.update_content(&ctx, content, content_nomarkup.into())
.await?; .await?;
query(&format!(
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes)",
post.board
))
.bind(post.id)
.execute(ctx.db())
.await?;
for quoted_post in quoted_posts {
quoted_post.update_quotes(&ctx, post.id).await?;
}
posts_edited += 1; posts_edited += 1;
} }

Zobrazit soubor

@ -1,4 +1,5 @@
use askama::Template; use askama::Template;
use sqlx::query_as;
use super::tcx::TemplateCtx; use super::tcx::TemplateCtx;
use crate::{ctx::Ctx, db::models::Post}; use crate::{ctx::Ctx, db::models::Post};
@ -22,7 +23,12 @@ pub async fn get_posts_from_ids(ctx: &Ctx, ids: &Vec<String>) -> Vec<Post> {
for id in ids { for id in ids {
if let Some((board, id)) = parse_id(id) { if let Some((board, id)) = parse_id(id) {
if let Ok(Some(post)) = Post::read(ctx, board, id).await { if let Ok(Some(post)) = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
.bind(board)
.bind(id)
.fetch_optional(ctx.db())
.await
{
posts.push(post); posts.push(post);
} }
} }

Zobrazit soubor

@ -37,7 +37,7 @@ pub async fn captcha(
_ => return Err(NekrochanError::NoCaptchaError), _ => return Err(NekrochanError::NoCaptchaError),
}; };
// >NOOOOOOOO YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE NOOOOOOOOOO // >YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE OR HOWEVER THE TRANS CHILDREN ARE PROTECTED
let png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?; let png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?;
let board = board.id; let board = board.id;

Zobrazit soubor

@ -11,9 +11,11 @@ pub mod logout;
pub mod news; pub mod news;
pub mod overboard; pub mod overboard;
pub mod overboard_catalog; pub mod overboard_catalog;
pub mod page;
pub mod staff; pub mod staff;
pub mod tcx; pub mod tcx;
pub mod thread; pub mod thread;
pub mod thread_json;
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder}; use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
use askama::Template; use askama::Template;

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

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

Zobrazit soubor

@ -10,6 +10,7 @@ use crate::{
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateAccountForm { pub struct CreateAccountForm {
username: String, username: String,
#[serde(rename = "account_password")]
password: String, password: String,
} }

Zobrazit soubor

@ -1,10 +1,16 @@
use actix_web::{post, web::Data, HttpRequest, HttpResponse}; use actix_web::{post, web::Data, HttpRequest, HttpResponse};
use lazy_static::lazy_static;
use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth, ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
}; };
lazy_static! {
static ref ID_REGEX: Regex = Regex::new(r"^\w{1,16}$").unwrap();
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateBoardForm { pub struct CreateBoardForm {
id: String, id: String,
@ -28,7 +34,7 @@ pub async fn create_board(
let name = form.name.trim().to_owned(); let name = form.name.trim().to_owned();
let description = form.description.trim().to_owned(); let description = form.description.trim().to_owned();
if id.is_empty() || id.len() > 16 { if !ID_REGEX.is_match(&id) {
return Err(NekrochanError::IdFormatError); return Err(NekrochanError::IdFormatError);
} }
@ -36,7 +42,7 @@ pub async fn create_board(
return Err(NekrochanError::BoardNameFormatError); return Err(NekrochanError::BoardNameFormatError);
} }
if description.is_empty() || description.len() > 128 { if description.len() > 128 {
return Err(NekrochanError::DescriptionFormatError); return Err(NekrochanError::DescriptionFormatError);
} }

Zobrazit soubor

@ -36,7 +36,7 @@ pub async fn create_news(
} }
let content_nomarkup = content; let content_nomarkup = content;
let content = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?; let (content, _) = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?;
NewsPost::create(&ctx, title, content, content_nomarkup, account.username).await?; NewsPost::create(&ctx, title, content, content_nomarkup, account.username).await?;

Zobrazit soubor

@ -52,11 +52,12 @@ pub async fn edit_news(
} }
let content_nomarkup = content_nomarkup.trim(); let content_nomarkup = content_nomarkup.trim();
let content = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?; let (content, _) = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?;
newspost newspost
.update(&ctx, content, content_nomarkup.into()) .update(&ctx, content, content_nomarkup.into())
.await?; .await?;
news_edited += 1; news_edited += 1;
} }

Zobrazit soubor

@ -29,6 +29,7 @@ pub struct UpdateBoardConfigForm {
antispam_ip: i64, antispam_ip: i64,
antispam_content: i64, antispam_content: i64,
antispam_both: i64, antispam_both: i64,
thread_cooldown: i64,
} }
#[post("/staff/actions/update-board-config")] #[post("/staff/actions/update-board-config")]
@ -68,6 +69,7 @@ pub async fn update_board_config(
let antispam_ip = form.antispam_ip; let antispam_ip = form.antispam_ip;
let antispam_content = form.antispam_content; let antispam_content = form.antispam_content;
let antispam_both = form.antispam_both; let antispam_both = form.antispam_both;
let thread_cooldown = form.thread_cooldown;
let config = BoardCfg { let config = BoardCfg {
anon_name, anon_name,
@ -90,6 +92,7 @@ pub async fn update_board_config(
antispam_ip, antispam_ip,
antispam_content, antispam_content,
antispam_both, antispam_both,
thread_cooldown,
}; };
board.update_config(&ctx, config).await?; board.update_config(&ctx, config).await?;

Zobrazit soubor

@ -38,7 +38,7 @@ pub async fn update_boards(
return Err(NekrochanError::BoardNameFormatError); return Err(NekrochanError::BoardNameFormatError);
} }
if description.is_empty() || description.len() > 128 { if description.len() > 128 {
return Err(NekrochanError::DescriptionFormatError); return Err(NekrochanError::DescriptionFormatError);
} }

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

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

Zobrazit soubor

@ -1,8 +1,11 @@
$(function () { $(function () {
update_expandable($(".expandable")); $(window).on("setup_post_events", function (event) {
}); setup_events($(`#${event.id}`).find(".expandable"));
});
function update_expandable(elements) { setup_events($(".expandable"));
function setup_events(elements) {
elements.each(function() { elements.each(function() {
$(this).click(function() { $(this).click(function() {
let src_link = $(this).attr("href"); let src_link = $(this).attr("href");
@ -22,9 +25,9 @@ function update_expandable(elements) {
return false; return false;
}) })
}) })
} }
function toggle_image(parent, src_link) { function toggle_image(parent, src_link) {
let thumb = parent.find(".thumb"); let thumb = parent.find(".thumb");
let src = parent.find(".src"); let src = parent.find(".src");
@ -51,9 +54,9 @@ function toggle_image(parent, src_link) {
src.toggle(); src.toggle();
parent.closest(".post-files").toggleClass("float-none-b"); parent.closest(".post-files").toggleClass("float-none-b");
} }
function toggle_video(parent, src_link) { function toggle_video(parent, src_link) {
let expanded = parent.hasClass("expanded"); let expanded = parent.hasClass("expanded");
let thumb = parent.find(".thumb"); let thumb = parent.find(".thumb");
let src = parent.parent().find(".src"); let src = parent.parent().find(".src");
@ -65,7 +68,7 @@ function toggle_video(parent, src_link) {
parent parent
.parent() .parent()
.append( .append(
`<video class="src" src="${src_link}" autoplay="" controls="" loop=""></video>` `<video class="src" src="${src_link}" controls="" loop=""></video>`
); );
let src = parent.parent().find(".src"); let src = parent.parent().find(".src");
@ -76,6 +79,7 @@ function toggle_video(parent, src_link) {
thumb.removeClass("loading"); thumb.removeClass("loading");
thumb.hide(); thumb.hide();
src.show(); src.show();
src.get(0).play();
parent.closest(".post-files").addClass("float-none-b"); parent.closest(".post-files").addClass("float-none-b");
}); });
@ -95,4 +99,5 @@ function toggle_video(parent, src_link) {
parent.closest(".post-files").toggleClass("float-none-b"); parent.closest(".post-files").toggleClass("float-none-b");
parent.find(".closer").toggle(); parent.find(".closer").toggle();
} }
});

138
static/js/hover.js Normální soubor
Zobrazit soubor

@ -0,0 +1,138 @@
$.fn.isInViewport = function () {
let element_top = $(this).offset().top;
let element_bottom = element_top + $(this).outerHeight();
let viewport_top = $(window).scrollTop();
let viewport_bottom = viewport_top + $(window).height();
return element_bottom > viewport_top && element_top < viewport_bottom;
};
$(function () {
let cache = {};
let hovering = false;
let preview_w = 0;
let preview_h = 0;
$(window).on("setup_post_events", function (event) {
setup_events($(`#${event.id}`).find(".quote"));
});
setup_events($(".quote"));
function setup_events(elements) {
elements.on("mouseover", function (event) {
toggle_hover($(this), event);
});
elements.on("mouseout", function (event) {
toggle_hover($(this), event);
});
elements.on("click", function (event) {
toggle_hover($(this), event);
});
elements.on("mousemove", move_preview);
}
function toggle_hover(quote, event) {
hovering = event.type === "mouseover";
if ($("#preview").length !== 0 && !hovering) {
remove_preview();
return;
}
let path_segments = quote.prop("pathname").split("/");
let board = path_segments[2];
let thread = path_segments[3];
let id = quote.prop("hash").slice(1);
let post = $(`#${id}[data-board="${board}"]`);
if (post.length !== 0 && post.isInViewport()) {
post.toggleClass("highlighted", hovering);
return;
}
if (post.length !== 0 && hovering) {
create_preview(post.clone(), event.clientX, event.clientY);
return;
}
let html;
let cached_thread = cache[`${board}/${thread}`];
if (cached_thread) {
html = cached_thread[id];
post = $($.parseHTML(html));
create_preview(post, event.clientX, event.clientY);
return;
}
quote.css("cursor", "wait");
try {
$.get(`/thread-json/${board}/${thread}`, function (data) {
quote.css("cursor", "");
cache[`${board}/${thread}`] = data;
html = data[id];
post = $($.parseHTML(html));
create_preview(post, event.clientX, event.clientY);
});
} catch (e) {
quote.css("cursor", "");
console.error(e);
}
}
function move_preview(event) {
position_preview($("#preview"), event.clientX, event.clientY);
}
function create_preview(preview, x, y) {
if (!hovering) {
return;
}
preview.attr("id", "preview");
preview.addClass("box");
preview.removeClass("highlighted");
preview.css("position", "fixed");
let existing = $("#preview");
if (existing.length !== 0) {
existing.replaceWith(preview);
} else {
preview.appendTo("body");
}
preview_w = preview.outerWidth();
preview_h = preview.outerHeight();
position_preview(preview, x, y);
$(window).trigger({ type: "setup_post_events", id: "preview" });
}
function remove_preview() {
$("#preview").remove();
}
function position_preview(preview, x, y) {
let ww = $(window).width();
let wh = $(window).height();
preview.css("left", `${Math.min(x + 4, ww - preview_w)}px`);
if (preview_h + y < wh) {
preview.css("top", `${y + 4}px`);
preview.css("bottom", "");
} else {
preview.css("bottom", `${wh - y + 4}px`);
preview.css("top", "");
}
}
});

Zobrazit soubor

@ -1,5 +1,5 @@
$(function () { $(function () {
let main = $(".thread + hr"); let main = $(".thread + hr + .pagination + hr");
main.after( main.after(
'<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>' '<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>'
@ -21,10 +21,15 @@ $(function () {
let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`; let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`;
let ws = new WebSocket(ws_location); let ws = new WebSocket(ws_location);
let interval;
ws.addEventListener("open", function (_) { ws.addEventListener("open", function (_) {
$("#live-indicator").css("background-color", "lime"); $("#live-indicator").css("background-color", "lime");
$("#live-status").text("Připojeno pro nové příspěvky"); $("#live-status").text("Připojeno pro nové příspěvky");
interval = setInterval(function () {
ws.send('{"type":"ping"}');
}, 10000);
}); });
ws.addEventListener("message", function (msg) { ws.addEventListener("message", function (msg) {
@ -33,27 +38,28 @@ $(function () {
switch (data.type) { switch (data.type) {
case "created": case "created":
$(".thread").append(data.html + "<br>"); $(".thread").append(data.html + "<br>");
update_expandable($(`#${data.id} .expandable`)); $(window).trigger({
update_reltimes($(`#${data.id}`).find("time")); type: "setup_post_events",
update_quote_links($(`#${data.id}`).find(".quote-link")); id: data.id,
});
break; break;
case "updated": case "updated":
$(`#${data.id}`).replaceWith(data.html); $(`#${data.id}`).replaceWith(data.html);
update_expandable($(`#${data.id} .expandable`)); $(window).trigger({
update_reltimes($(`#${data.id}`).find("time")); type: "setup_post_events",
update_quote_links($(`#${data.id}`)).find(".quote-link"); id: data.id,
});
break; break;
case "removed": case "removed":
if (data.id === parseInt(thread[1])) {
ws.close();
$("#live-indicator").css("background-color", "red");
$("#live-status").text("Vlákno bylo odstraněno");
return;
}
$(`#${data.id}`).next("br").remove(); $(`#${data.id}`).next("br").remove();
$(`#${data.id}`).remove(); $(`#${data.id}`).remove();
break; break;
case "thread_removed":
setTimeout(function () {
$("#live-indicator").css("background-color", "red");
$("#live-status").text("Vlákno bylo odstraněno");
}, 100);
break;
default: default:
break; break;
} }
@ -62,5 +68,6 @@ $(function () {
ws.addEventListener("close", function (_) { ws.addEventListener("close", function (_) {
$("#live-indicator").css("background-color", "red"); $("#live-indicator").css("background-color", "red");
$("#live-status").text("Odpojeno, obnov stránku"); $("#live-status").text("Odpojeno, obnov stránku");
clearInterval(interval);
}); });
}); });

Zobrazit soubor

@ -15,3 +15,158 @@ $(function () {
return false; return false;
}); });
}); });
// Stolen and modified code from jschan
$(function () {
let dragging = false;
let x_offset = 0;
let y_offset = 0;
let form = $("#post-form");
let handle = $("#post-form-handle");
let saved_top = window.localStorage.getItem("post_form_top");
let saved_left = window.localStorage.getItem("post_form_left");
if (saved_top) {
form.css("top", saved_top);
}
if (saved_left) {
form.css("left", saved_left);
form.css("right", "auto")
}
handle.on("mousedown", start);
handle.get(0).addEventListener("touchstart", start, { passive: true });
$(document).on("mouseup", stop);
$(document).on("touchend", stop);
$(window).on("resize", update_max);
$(window).on("orientationchange", update_max);
function start(event) {
dragging = true;
const rect = form.get(0).getBoundingClientRect();
switch (event.type) {
case "mousedown":
x_offset = event.clientX - rect.left;
y_offset = event.clientY - rect.top;
$(window).on("mousemove", drag);
break;
case "touchstart":
event.preventDefault();
event.stopPropagation();
x_offset = event.targetTouches[0].clientX - rect.left;
y_offset = event.targetTouches[0].clientY - rect.top;
$(window).on("touchmove", drag);
break;
default:
break;
}
}
function drag(event) {
if (!dragging) {
return;
}
update_max(event);
switch (event.type) {
case "mousemove":
form.css(
"left",
`${in_bounds(
event.clientX,
x_offset,
form.outerWidth(),
document.documentElement.clientWidth
)}px`
);
form.css(
"top",
`${in_bounds(
event.clientY,
y_offset,
form.outerHeight(),
document.documentElement.clientHeight
)}px`
);
break;
case "touchmove":
form.css(
"left",
`${in_bounds(
event.targetTouches[0].clientX,
x_offset,
form.outerWidth(),
document.documentElement.clientWidth
)}px`
);
form.css(
"top",
`${in_bounds(
event.targetTouches[0].clientY,
y_offset,
form.outerHeight(),
document.documentElement.clientHeight
)}px`
);
break;
default:
break;
}
form.css("right", "auto");
window.localStorage.setItem("post_form_top", form.css("top"));
window.localStorage.setItem("post_form_left", form.css("left"));
}
function stop() {
if (dragging) {
dragging = false;
$(window).off("mousemove");
$(window).off("touchmove");
}
}
function update_max() {
let rect = form.get(0).getBoundingClientRect();
if (rect.width === 0) {
return;
}
if (rect.right > document.documentElement.clientWidth) {
form.css("left", 0);
}
if (rect.bottom > document.documentElement.clientHeight) {
form.css("top", 0);
}
rect = form.get(0).getBoundingClientRect();
form.css(
"max-height",
`${document.documentElement.clientHeight - rect.top}px`
);
form.css(
"max-width",
`${document.documentElement.clientWidth - rect.left}px`
);
}
function in_bounds(pos, offset, size, limit) {
if (pos - offset <= 0) {
return 0;
} else if (pos - offset + size > limit) {
return limit - size;
} else {
return pos - offset;
}
}
});

Zobrazit soubor

@ -1,4 +1,4 @@
$(function() { $(function () {
let quoted_post = window.localStorage.getItem("quoted_post"); let quoted_post = window.localStorage.getItem("quoted_post");
if (quoted_post) { if (quoted_post) {
@ -7,11 +7,14 @@ $(function() {
window.localStorage.removeItem("quoted_post"); window.localStorage.removeItem("quoted_post");
} }
update_quote_links($(".quote-link")); $(window).on("setup_post_events", function (event) {
}); setup_events($(`#${event.id}`).find(".quote-link"));
});
function update_quote_links(elements) { setup_events($(".quote-link"));
elements.each(function() {
function setup_events(elements) {
elements.each(function () {
$(this).click(function () { $(this).click(function () {
let post_id = $(this).text(); let post_id = $(this).text();
let thread_url = $(this).attr("data-thread-url"); let thread_url = $(this).attr("data-thread-url");
@ -28,5 +31,6 @@ function update_quote_links(elements) {
return false; return false;
}); });
}) });
} }
});

Zobrazit soubor

@ -1,12 +1,22 @@
const MINUTE = 60000,
HOUR = 3600000,
DAY = 86400000,
WEEK = 604800000,
MONTH = 2592000000,
YEAR = 31536000000;
$(function () { $(function () {
update_reltimes($("time")); $(window).on("setup_post_events", function (event) {
setup_events($(`#${event.id}`).find("time"));
});
setup_events($("time"));
setInterval(() => { setInterval(() => {
update_reltimes($("time")); setup_events($("time"));
}, 60000); }, 60000);
});
function update_reltimes(elements) { function setup_events(elements) {
elements.each(function () { elements.each(function () {
let title = $(this).attr("title"); let title = $(this).attr("title");
@ -18,16 +28,9 @@ function update_reltimes(elements) {
$(this).text(rel); $(this).text(rel);
}); });
} }
const MINUTE = 60000, function reltime(date) {
HOUR = 3600000,
DAY = 86400000,
WEEK = 604800000,
MONTH = 2592000000,
YEAR = 31536000000;
function reltime(date) {
let delta = Date.now() - Date.parse(date); let delta = Date.now() - Date.parse(date);
let fut = false; let fut = false;
@ -49,7 +52,10 @@ function reltime(date) {
if (fut) { if (fut) {
rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`; rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`;
} else { } else {
rt = `před ${minutes} ${plural("minutou|minutami|minutami", minutes)}`; rt = `před ${minutes} ${plural(
"minutou|minutami|minutami",
minutes
)}`;
} }
} }
@ -57,7 +63,10 @@ function reltime(date) {
if (fut) { if (fut) {
rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`; rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`;
} else { } else {
rt = `před ${hours} ${plural("hodinou|hodinami|hodinami", hours)}`; rt = `před ${hours} ${plural(
"hodinou|hodinami|hodinami",
hours
)}`;
} }
} }
@ -81,7 +90,10 @@ function reltime(date) {
if (fut) { if (fut) {
rt = `za ${months} ${plural("měsíc|měsíce|měsíců", months)}`; rt = `za ${months} ${plural("měsíc|měsíce|měsíců", months)}`;
} else { } else {
rt = `před ${months} ${plural("měsícem|měsíci|měsíci", months)}`; rt = `před ${months} ${plural(
"měsícem|měsíci|měsíci",
months
)}`;
} }
} }
@ -94,9 +106,9 @@ function reltime(date) {
} }
return rt; return rt;
} }
function plural(plurals, count) { function plural(plurals, count) {
let plurals_arr = plurals.split("|"); let plurals_arr = plurals.split("|");
let one = plurals_arr[0]; let one = plurals_arr[0];
let few = plurals_arr[1]; let few = plurals_arr[1];
@ -109,4 +121,5 @@ function plural(plurals, count) {
} else { } else {
return other; return other;
} }
} }
});

Zobrazit soubor

@ -56,14 +56,21 @@ summary {
padding: 0; padding: 0;
} }
.container > form:not(#post-form) > .form-table {
margin: 8px auto;
}
#post-form { #post-form {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
visibility: hidden; visibility: hidden;
position: fixed; position: fixed;
right: 0; right: 0;
top: 3rem; top: 0;
background-color: var(--box-color); background-color: var(--box-color);
border: 1px solid var(--box-border); padding: 4px;
margin: 4px;
} }
#post-form:target, #post-form:target,
@ -79,6 +86,12 @@ summary {
cursor: move; cursor: move;
} }
#post-form-handle::after {
content: "";
display: block;
clear: right;
}
.edit-box { .edit-box {
display: block; display: block;
width: 100%; width: 100%;
@ -109,6 +122,10 @@ summary {
margin: 0 auto; margin: 0 auto;
} }
.form-table input[type="file"] {
width: 100%;
}
.form-table textarea, .form-table textarea,
.edit-box { .edit-box {
height: 8rem; height: 8rem;
@ -156,7 +173,8 @@ summary {
padding: 8px; padding: 8px;
} }
.box:target { .box:target,
.box.highlighted {
background-color: var(--hl-box-color); background-color: var(--hl-box-color);
border-right: 1px solid var(--hl-box-border); border-right: 1px solid var(--hl-box-border);
border-bottom: 1px solid var(--hl-box-border); border-bottom: 1px solid var(--hl-box-border);
@ -297,12 +315,6 @@ summary {
margin-bottom: 0; margin-bottom: 0;
} }
.post::after {
content: "";
display: block;
clear: both;
}
.board-links a, .board-links a,
.pagination a, .pagination a,
.post-number a { .post-number a {
@ -373,8 +385,8 @@ summary {
} }
.thumb { .thumb {
max-width: 200px; max-width: 150px;
max-height: 200px; max-height: 150px;
} }
.post-content { .post-content {
@ -384,10 +396,6 @@ summary {
margin: 0; margin: 0;
} }
.post .post-content {
margin: 1rem 2rem;
}
.post-content a { .post-content a {
color: var(--post-link-color); color: var(--post-link-color);
} }
@ -396,6 +404,14 @@ summary {
color: var(--post-link-hover); color: var(--post-link-hover);
} }
.clearfix {
clear: both;
}
.post .post-content {
margin: 1rem 2rem;
}
.dead-quote { .dead-quote {
color: var(--dead-quote-color); color: var(--dead-quote-color);
text-decoration: line-through; text-decoration: line-through;
@ -466,7 +482,7 @@ summary {
.post.box { .post.box {
display: block; display: block;
min-width: unset; min-width: auto;
} }
.thread > br { .thread > br {
@ -511,3 +527,9 @@ summary {
vertical-align: middle; vertical-align: middle;
border-radius: 50%; border-radius: 50%;
} }
#preview {
-webkit-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
-moz-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
}

Zobrazit soubor

@ -7,14 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<meta name="description" content="{{ tcx.cfg.site.description }}"> <meta name="description" content="{{ tcx.cfg.site.description }}">
<link rel="stylesheet" href='/static/themes/{% block theme %}{% include "../theme.txt" %}{% endblock %}'> <link rel="stylesheet" href="/static/themes/{% block theme %}{{ tcx.cfg.site.theme }}{% endblock %}">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/js/jquery.min.js"></script> <script src="/static/js/jquery.min.js"></script>
<!-- UX scripts --> <!-- UX scripts -->
<script src="/static/js/autofill.js"></script> <script src="/static/js/autofill.js"></script>
<script src="/static/js/expand.js"></script> <script src="/static/js/expand.js"></script>
<script src="/static/js/time.js"></script> <script src="/static/js/hover.js"></script>
<script src="/static/js/quote.js"></script> <script src="/static/js/quote.js"></script>
<script src="/static/js/time.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</head> </head>
<body> <body>
@ -25,8 +26,18 @@
<span class="link-group"> <span class="link-group">
<a href="/overboard">nadnástěnka</a> <a href="/overboard">nadnástěnka</a>
<span class="link-separator"></span> <span class="link-separator"></span>
<a href="/news">novinky</a></span> <a href="/news">novinky</a>
</span> </span>
{% for group in tcx.cfg.site.links %}
<span class="link-group">
{% for (link, href) in group %}
<a href="{{ href }}">{{ link }}</a>
{% if !loop.last %}
<span class="link-separator"></span>
{% endif %}
{% endfor %}
</span>
{% endfor %}
<span class="float-r"> <span class="float-r">
{% if tcx.account.is_some() %} {% if tcx.account.is_some() %}
<span class="link-group"> <span class="link-group">

Zobrazit soubor

@ -4,7 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chyba</title> <title>Chyba</title>
<link rel="stylesheet" href='/static/themes/{% include "../theme.txt" %}'> {# Error pages are yotsuba (they just are, okay?) #}
{# Go ahead and edit this manually if you actually care #}
<link rel="stylesheet" href="/static/themes/yotsuba.css">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>

Zobrazit soubor

@ -1,5 +1,5 @@
{% macro pagination(base, pages, current) %} {% macro pagination(base, pages, current) %}
<div class="box inline-block pagination"> <div class="pagination box inline-block">
{% if current == 1 %} {% if current == 1 %}
[Předchozí] [Předchozí]
{% else %} {% else %}
@ -13,8 +13,8 @@
{% else %} {% else %}
[<a href="{{ base }}?page={{ page }}">{{ page }}</a>] [<a href="{{ base }}?page={{ page }}">{{ page }}</a>]
{% endif %} {% endif %}
{% endfor %}
&#32; &#32;
{% endfor %}
{% if current == pages %} {% if current == pages %}
[Další] [Další]

Zobrazit soubor

@ -12,7 +12,7 @@
{% else %} {% else %}
Nové vlákno Nové vlákno
{% endif %} {% endif %}
<a class="close-post-form float-r" href="#x">X</a> <a class="close-post-form float-r" href="#">[X]</a>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -90,7 +90,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="label">Heslo <span class="small">(na odstranění)</span></td> <td class="label">Heslo</td>
<td><input name="post_password" type="password" required=""></td> <td><input name="post_password" type="password" required=""></td>
</tr> </tr>
<tr> <tr>

Zobrazit soubor

@ -39,7 +39,7 @@
{% endif %} {% endif %}
{% if !boxed %} {% if !boxed %}
<a href="{{ post.post_url_notarget() }}">[Odpovědět]</a>&#32; <a href="{{ post.post_url_notarget() }}">[Otevřít]</a>&#32;
{% endif %} {% endif %}
</div> </div>
{% if !post.files.0.is_empty() %} {% if !post.files.0.is_empty() %}
@ -64,5 +64,14 @@
</div> </div>
{% endif %} {% endif %}
<div class="post-content">{{ post.content|add_yous(post.board, tcx.yous)|safe }}</div> <div class="post-content">{{ post.content|add_yous(post.board, tcx.yous)|safe }}</div>
<div class="clearfix"></div>
{% if !post.quotes.is_empty() %}
<div class="small">
Odpovědi:&#32;
{% for quote in post.quotes %}
<a class="quote" href="{{ post.thread_url() }}#{{ quote }}">&gt;&gt;{{ quote }}</a>&#32;
{% endfor %}
</div>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}

Zobrazit soubor

@ -1,5 +1,5 @@
{% macro staff_nav() %} {% macro staff_nav() %}
<div class="box inline-block pagination"> <div class="pagination box inline-block">
[<a href="/staff/account">Účet</a>]&#32; [<a href="/staff/account">Účet</a>]&#32;
[<a href="/staff/accounts">Účty</a>]&#32; [<a href="/staff/accounts">Účty</a>]&#32;

Zobrazit soubor

@ -1,5 +1,5 @@
{% macro static_pagination(base, current) %} {% macro static_pagination(base, current) %}
<div class="box inline-block pagination"> <div class="pagination box inline-block">
{% if current == 1 %} {% if current == 1 %}
[Předchozí] [Předchozí]
{% else %} {% else %}

3
templates/page.html Normální soubor
Zobrazit soubor

@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}{{ tcx.cfg.site.name }} ({{ name }}){% endblock %}
{% block content %}{{ content|safe }}{% endblock %}

Zobrazit soubor

@ -174,6 +174,11 @@
<td><input name="antispam_both" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td> <td><input name="antispam_both" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td>
</tr> </tr>
<tr>
<td class="label">Interval mezi vlákny (IP)</td>
<td><input name="thread_cooldown" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td>
</tr>
<tr> <tr>
<td colspan="2"><input class="button" type="submit" value="Uložit"></td> <td colspan="2"><input class="button" type="submit" value="Uložit"></td>
</tr> </tr>

Zobrazit soubor

@ -43,7 +43,7 @@
</tr> </tr>
<tr> <tr>
<td class="label">Popis</td> <td class="label">Popis</td>
<td><input name="description" type="text" required=""></td> <td><input name="description" type="text"></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"><input class="button" type="submit" formaction="/staff/actions/update-boards" value="Upravit vybrané"></td> <td colspan="2"><input class="button" type="submit" formaction="/staff/actions/update-boards" value="Upravit vybrané"></td>
@ -65,7 +65,7 @@
</tr> </tr>
<tr> <tr>
<td class="label">Popis</td> <td class="label">Popis</td>
<td><input name="description" type="text" required=""></td> <td><input name="description" type="text"></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"><input class="button" type="submit" value="Vytvořit nástěnku"></td> <td colspan="2"><input class="button" type="submit" value="Vytvořit nástěnku"></td>

Zobrazit soubor

@ -29,6 +29,12 @@
{% call post_form::post_form(board, true, thread.id) %} {% call post_form::post_form(board, true, thread.id) %}
</div> </div>
<hr> <hr>
<div class="pagination box inline-block">
<a href="/boards/{{ board.id }}">[Zpět]</a>&#32;
<a href="#bottom">[Dolů]</a>&#32;
<a href="/boards/{{ board.id }}/catalog">[Katalog]</a>
</div>
<hr>
<form method="post"> <form method="post">
<div class="thread"> <div class="thread">
{% call post::post(board, thread, false) %} {% call post::post(board, thread, false) %}
@ -38,6 +44,12 @@
{% endfor %} {% endfor %}
</div> </div>
<hr> <hr>
<div class="pagination box inline-block">
<a href="/boards/{{ board.id }}">[Zpět]</a>&#32;
<a href="#top">[Nahoru]</a>&#32;
<a href="/boards/{{ board.id }}/catalog">[Katalog]</a>
</div>
<hr>
{% call post_actions::post_actions() %} {% call post_actions::post_actions() %}
</form> </form>
{% endblock %} {% endblock %}

Zobrazit soubor

@ -1 +0,0 @@
yotsuba.css