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
/templates_min
/uploads
Nekrochan.toml
.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ý přestože je kód anglicky...
> 100% český přestože je kód anglicky...
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,
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 name: String,
pub description: String,
pub default_noko: bool,
pub theme: String,
pub links: Vec<Vec<(String, String)>>,
pub noko: bool,
}
#[derive(Deserialize, Debug, Clone)]
@ -73,4 +75,5 @@ pub struct BoardCfg {
pub antispam_ip: i64,
pub antispam_content: i64,
pub antispam_both: i64,
pub thread_cooldown: i64,
}

Zobrazit soubor

@ -40,10 +40,11 @@ impl Board {
ip INET NOT NULL,
bumps INT NOT NULL DEFAULT 0,
replies INT NOT NULL DEFAULT 0,
quotes BIGINT[] NOT NULL DEFAULT '{{}}',
sticky BOOLEAN NOT NULL DEFAULT false,
locked BOOLEAN NOT NULL DEFAULT false,
reported TIMESTAMPTZ DEFAULT NULL,
reports JSONB NOT NULL DEFAULT '[]'::json,
reports JSONB NOT NULL DEFAULT '[]'::jsonb,
bumped 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 {
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 score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?;
if post.thread.is_none() {
let key = format!("last_thread:{}", post.ip);
let last_thread = ctx
.cache()
.get::<_, Option<i64>>(&key)
.await?
.unwrap_or_default();
let timestamp = post.created.timestamp_micros();
if timestamp > last_thread {
ctx.cache().set(key, timestamp).await?;
}
}
}
}

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

@ -1,6 +1,9 @@
use askama::Template;
use db::models::{Board, Post};
use error::NekrochanError;
use web::tcx::TemplateCtx;
const GENERIC_PAGE_SIZE: i64 = 15;
const GENERIC_PAGE_SIZE: i64 = 10;
pub mod auth;
pub mod cfg;
@ -45,3 +48,14 @@ pub fn check_page(
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::{
db::models::{Board, Post},
filters,
web::tcx::TemplateCtx,
PostTemplate,
};
#[derive(Message)]
#[rtype(result = "()")]
pub struct SessionMessage(pub String);
pub enum SessionMessage {
Data(String),
Stop,
}
#[derive(Message)]
#[rtype(result = "()")]
@ -56,17 +59,6 @@ pub struct PostRemovedMessage {
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 cache: Connection,
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
@ -120,6 +112,10 @@ impl Handler<DisconnectMessage> for LiveHub {
.filter(|uuid| **uuid != msg.uuid)
.map(Uuid::clone)
.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();
let post = post.clone();
let tcx = &tcx;
let board = &board;
let post = &post;
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()
.unwrap_or_default();
recv.do_send(SessionMessage(
recv.do_send(SessionMessage::Data(
json!({ "type": "created", "id": id, "html": html }).to_string(),
));
}
@ -175,18 +171,20 @@ impl Handler<TargetedPostCreatedMessage> for LiveHub {
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;
};
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()
.unwrap_or_default();
recv.do_send(SessionMessage(
recv.do_send(SessionMessage::Data(
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();
let post = post.clone();
let id = post.id;
let tcx = tcx.clone();
let board = board.clone();
let tcx = &tcx;
let board = &board;
let post = &post;
let html = PostTemplate { tcx, post, board }
.render()
.unwrap_or_default();
recv.do_send(SessionMessage(
recv.do_send(SessionMessage::Data(
json!({ "type": "updated", "id": id, "html": html }).to_string(),
));
}
@ -249,12 +247,24 @@ impl Handler<PostRemovedMessage> for LiveHub {
None => return,
};
if post.thread.is_none() {
for uuid in uuids {
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
continue;
};
recv.do_send(SessionMessage(
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(),
));
}

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

@ -111,10 +111,10 @@ pub async fn markup(
board: Option<String>,
op: Option<i64>,
text: &str,
) -> Result<String, NekrochanError> {
) -> Result<(String, Vec<Post>), NekrochanError> {
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 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 {
text
(text, Vec::new())
};
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">&gt;$1</span>");
@ -173,7 +178,7 @@ pub async fn markup(
text
};
Ok(text.to_string())
Ok((text.to_string(), quoted_posts))
}
fn escape_html(text: &str) -> String {

Zobrazit soubor

@ -67,7 +67,7 @@ pub async fn create_post(
}
let mut bump = true;
let mut noko = ctx.cfg.site.default_noko;
let mut noko = ctx.cfg.site.noko;
let thread = match form.thread {
Some(Text(thread)) => {
@ -143,11 +143,11 @@ pub async fn create_post(
bump = false;
}
if !ctx.cfg.site.default_noko && email_lower == "noko" {
if !ctx.cfg.site.noko && email_lower == "noko" {
noko = true
}
if ctx.cfg.site.default_noko {
if ctx.cfg.site.noko {
if email_lower == "nonoko" {
noko = false;
}
@ -170,30 +170,13 @@ pub async fn create_post(
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)
|| (thread.is_some() && board.config.0.require_reply_content)
{
return Err(NekrochanError::NoContentError);
if password_raw.len() < 8 {
return Err(NekrochanError::PasswordFormatError);
}
if content_nomarkup.len() > 10000 {
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?;
}
let password = hash(password_raw)?;
if form.files.len() > 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);
}
let thread_id = thread.as_ref().map(|t| t.id);
let content_nomarkup = form.content.0.trim().to_owned();
if board.config.antispam && !(perms.owner() || perms.bypass_antispam()) {
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
}
if content_nomarkup.is_empty() && files.is_empty() {
return Err(NekrochanError::EmptyPostError);
}
let password_raw = form.password.trim();
if password_raw.len() < 8 {
return Err(NekrochanError::PasswordFormatError);
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);
}
let password = hash(password_raw)?;
let thread_id = thread.as_ref().map(|t| t.id);
if content_nomarkup.len() > 10000 {
return Err(NekrochanError::ContentFormatError);
}
let (content, quoted_posts) = markup(
&ctx,
&perms,
Some(board.id.clone()),
thread.as_ref().map(|t| t.id),
&content_nomarkup,
)
.await?;
let post = Post::create(
&ctx,
@ -243,6 +243,10 @@ pub async fn create_post(
)
.await?;
for quoted_post in quoted_posts {
quoted_post.update_quotes(&ctx, post.id).await?;
}
let ts = thread.as_ref().map_or_else(
|| post.created.timestamp_micros(),
|thread| thread.created.timestamp_micros(),
@ -320,5 +324,16 @@ pub async fn check_spam(
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(())
}

Zobrazit soubor

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

Zobrazit soubor

@ -1,4 +1,5 @@
use askama::Template;
use sqlx::query_as;
use super::tcx::TemplateCtx;
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 {
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);
}
}

Zobrazit soubor

@ -37,7 +37,7 @@ pub async fn captcha(
_ => 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 board = board.id;

Zobrazit soubor

@ -11,9 +11,11 @@ pub mod logout;
pub mod news;
pub mod overboard;
pub mod overboard_catalog;
pub mod page;
pub mod staff;
pub mod tcx;
pub mod thread;
pub mod thread_json;
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
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)]
pub struct CreateAccountForm {
username: String,
#[serde(rename = "account_password")]
password: String,
}

Zobrazit soubor

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

Zobrazit soubor

@ -36,7 +36,7 @@ pub async fn create_news(
}
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?;

Zobrazit soubor

@ -52,11 +52,12 @@ pub async fn edit_news(
}
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
.update(&ctx, content, content_nomarkup.into())
.await?;
news_edited += 1;
}

Zobrazit soubor

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

Zobrazit soubor

@ -38,7 +38,7 @@ pub async fn update_boards(
return Err(NekrochanError::BoardNameFormatError);
}
if description.is_empty() || description.len() > 128 {
if description.len() > 128 {
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 () {
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() {
$(this).click(function() {
let src_link = $(this).attr("href");
@ -22,9 +25,9 @@ function update_expandable(elements) {
return false;
})
})
}
}
function toggle_image(parent, src_link) {
function toggle_image(parent, src_link) {
let thumb = parent.find(".thumb");
let src = parent.find(".src");
@ -51,9 +54,9 @@ function toggle_image(parent, src_link) {
src.toggle();
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 thumb = parent.find(".thumb");
let src = parent.parent().find(".src");
@ -65,7 +68,7 @@ function toggle_video(parent, src_link) {
parent
.parent()
.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");
@ -76,6 +79,7 @@ function toggle_video(parent, src_link) {
thumb.removeClass("loading");
thumb.hide();
src.show();
src.get(0).play();
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.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 () {
let main = $(".thread + hr");
let main = $(".thread + hr + .pagination + hr");
main.after(
'<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 = new WebSocket(ws_location);
let interval;
ws.addEventListener("open", function (_) {
$("#live-indicator").css("background-color", "lime");
$("#live-status").text("Připojeno pro nové příspěvky");
interval = setInterval(function () {
ws.send('{"type":"ping"}');
}, 10000);
});
ws.addEventListener("message", function (msg) {
@ -33,27 +38,28 @@ $(function () {
switch (data.type) {
case "created":
$(".thread").append(data.html + "<br>");
update_expandable($(`#${data.id} .expandable`));
update_reltimes($(`#${data.id}`).find("time"));
update_quote_links($(`#${data.id}`).find(".quote-link"));
$(window).trigger({
type: "setup_post_events",
id: data.id,
});
break;
case "updated":
$(`#${data.id}`).replaceWith(data.html);
update_expandable($(`#${data.id} .expandable`));
update_reltimes($(`#${data.id}`).find("time"));
update_quote_links($(`#${data.id}`)).find(".quote-link");
$(window).trigger({
type: "setup_post_events",
id: data.id,
});
break;
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}`).remove();
break;
case "thread_removed":
setTimeout(function () {
$("#live-indicator").css("background-color", "red");
$("#live-status").text("Vlákno bylo odstraněno");
}, 100);
break;
default:
break;
}
@ -62,5 +68,6 @@ $(function () {
ws.addEventListener("close", function (_) {
$("#live-indicator").css("background-color", "red");
$("#live-status").text("Odpojeno, obnov stránku");
clearInterval(interval);
});
});

Zobrazit soubor

@ -15,3 +15,158 @@ $(function () {
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");
if (quoted_post) {
@ -7,11 +7,14 @@ $(function() {
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) {
elements.each(function() {
setup_events($(".quote-link"));
function setup_events(elements) {
elements.each(function () {
$(this).click(function () {
let post_id = $(this).text();
let thread_url = $(this).attr("data-thread-url");
@ -28,5 +31,6 @@ function update_quote_links(elements) {
return false;
});
})
}
});
}
});

Zobrazit soubor

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

Zobrazit soubor

@ -56,14 +56,21 @@ summary {
padding: 0;
}
.container > form:not(#post-form) > .form-table {
margin: 8px auto;
}
#post-form {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
visibility: hidden;
position: fixed;
right: 0;
top: 3rem;
top: 0;
background-color: var(--box-color);
border: 1px solid var(--box-border);
margin: 4px;
padding: 4px;
}
#post-form:target,
@ -79,6 +86,12 @@ summary {
cursor: move;
}
#post-form-handle::after {
content: "";
display: block;
clear: right;
}
.edit-box {
display: block;
width: 100%;
@ -109,6 +122,10 @@ summary {
margin: 0 auto;
}
.form-table input[type="file"] {
width: 100%;
}
.form-table textarea,
.edit-box {
height: 8rem;
@ -156,7 +173,8 @@ summary {
padding: 8px;
}
.box:target {
.box:target,
.box.highlighted {
background-color: var(--hl-box-color);
border-right: 1px solid var(--hl-box-border);
border-bottom: 1px solid var(--hl-box-border);
@ -297,12 +315,6 @@ summary {
margin-bottom: 0;
}
.post::after {
content: "";
display: block;
clear: both;
}
.board-links a,
.pagination a,
.post-number a {
@ -373,8 +385,8 @@ summary {
}
.thumb {
max-width: 200px;
max-height: 200px;
max-width: 150px;
max-height: 150px;
}
.post-content {
@ -384,10 +396,6 @@ summary {
margin: 0;
}
.post .post-content {
margin: 1rem 2rem;
}
.post-content a {
color: var(--post-link-color);
}
@ -396,6 +404,14 @@ summary {
color: var(--post-link-hover);
}
.clearfix {
clear: both;
}
.post .post-content {
margin: 1rem 2rem;
}
.dead-quote {
color: var(--dead-quote-color);
text-decoration: line-through;
@ -466,7 +482,7 @@ summary {
.post.box {
display: block;
min-width: unset;
min-width: auto;
}
.thread > br {
@ -511,3 +527,9 @@ summary {
vertical-align: middle;
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">
<title>{% block title %}{% endblock %}</title>
<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">
<script src="/static/js/jquery.min.js"></script>
<!-- UX scripts -->
<script src="/static/js/autofill.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/time.js"></script>
{% block scripts %}{% endblock %}
</head>
<body>
@ -25,8 +26,18 @@
<span class="link-group">
<a href="/overboard">nadnástěnka</a>
<span class="link-separator"></span>
<a href="/news">novinky</a></span>
<a href="/news">novinky</a>
</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">
{% if tcx.account.is_some() %}
<span class="link-group">

Zobrazit soubor

@ -4,7 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>

Zobrazit soubor

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

Zobrazit soubor

@ -12,7 +12,7 @@
{% else %}
Nové vlákno
{% endif %}
<a class="close-post-form float-r" href="#x">X</a>
<a class="close-post-form float-r" href="#">[X]</a>
</td>
</tr>
<tr>
@ -90,7 +90,7 @@
</td>
</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>
</tr>
<tr>

Zobrazit soubor

@ -39,7 +39,7 @@
{% endif %}
{% if !boxed %}
<a href="{{ post.post_url_notarget() }}">[Odpovědět]</a>&#32;
<a href="{{ post.post_url_notarget() }}">[Otevřít]</a>&#32;
{% endif %}
</div>
{% if !post.files.0.is_empty() %}
@ -64,5 +64,14 @@
</div>
{% endif %}
<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>
{% endmacro %}

Zobrazit soubor

@ -1,5 +1,5 @@
{% 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/accounts">Účty</a>]&#32;

Zobrazit soubor

@ -1,5 +1,5 @@
{% macro static_pagination(base, current) %}
<div class="box inline-block pagination">
<div class="pagination box inline-block">
{% if current == 1 %}
[Předchozí]
{% 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>
</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>
<td colspan="2"><input class="button" type="submit" value="Uložit"></td>
</tr>

Zobrazit soubor

@ -43,7 +43,7 @@
</tr>
<tr>
<td class="label">Popis</td>
<td><input name="description" type="text" required=""></td>
<td><input name="description" type="text"></td>
</tr>
<tr>
<td colspan="2"><input class="button" type="submit" formaction="/staff/actions/update-boards" value="Upravit vybrané"></td>
@ -65,7 +65,7 @@
</tr>
<tr>
<td class="label">Popis</td>
<td><input name="description" type="text" required=""></td>
<td><input name="description" type="text"></td>
</tr>
<tr>
<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) %}
</div>
<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">
<div class="thread">
{% call post::post(board, thread, false) %}
@ -38,6 +44,12 @@
{% endfor %}
</div>
<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() %}
</form>
{% endblock %}

Zobrazit soubor

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