Merdžnout větve nebo něco
Tento commit je obsažen v:
revize
de5906414d
3
.gitignore
vendorováno
3
.gitignore
vendorováno
@ -1,7 +1,6 @@
|
||||
/pages/*.html
|
||||
/target
|
||||
/templates_min
|
||||
/uploads
|
||||
Nekrochan.toml
|
||||
.env
|
||||
|
||||
cloud-run.sh
|
||||
|
47
Nekrochan.toml.template
Spustitelný soubor
47
Nekrochan.toml.template
Spustitelný soubor
@ -0,0 +1,47 @@
|
||||
[server]
|
||||
port = ${PORT}
|
||||
database_url = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
cache_url = "redis://${REDIS_HOST}/${REDIS_DB}"
|
||||
|
||||
[site]
|
||||
name = "${SITE_NAME}"
|
||||
description = "${SITE_DESCRIPTION}"
|
||||
theme = "yotsuba.css"
|
||||
links = []
|
||||
noko = true
|
||||
|
||||
[secrets]
|
||||
auth_token = "${AUTH_SECRET}"
|
||||
secure_trip = "${TRIP_SECRET}"
|
||||
user_id = "${UID_SECRET}"
|
||||
|
||||
[files]
|
||||
videos = true
|
||||
thumb_size = 150
|
||||
max_size_mb = 50
|
||||
max_height = 10000
|
||||
max_width = 10000
|
||||
cleanup_interval = 3600
|
||||
|
||||
[board_defaults]
|
||||
anon_name = "Anonym"
|
||||
page_size = 10
|
||||
page_count = 20
|
||||
file_limit = 1
|
||||
bump_limit = 500
|
||||
reply_limit = 1000
|
||||
locked = false
|
||||
user_ids = false
|
||||
flags = false
|
||||
thread_captcha = "off"
|
||||
reply_captcha = "off"
|
||||
board_theme = "yotsuba.css"
|
||||
require_thread_content = true
|
||||
require_thread_file = true
|
||||
require_reply_content = false
|
||||
require_reply_file = false
|
||||
antispam = true
|
||||
antispam_ip = 5
|
||||
antispam_content = 10
|
||||
antispam_both = 60
|
||||
thread_cooldown = 60
|
98
README.md
98
README.md
@ -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
68
configure.sh
Spustitelný soubor
@ -0,0 +1,68 @@
|
||||
set -e
|
||||
|
||||
echo "# Výběr portu"
|
||||
read -p "Port serveru [7000]: " port
|
||||
|
||||
echo "# Konfigurace databáze"
|
||||
read -p "Host databáze [localhost]: " db_host
|
||||
read -p "Port databáze [5432]: " db_port
|
||||
|
||||
read -p "Uživatelské jméno: " db_user
|
||||
if [ "$db_user" == "" ]
|
||||
then
|
||||
echo "Uživatelské jméno je povinné"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -p "Heslo: " db_password
|
||||
if [ "$db_user" == "" ]
|
||||
then
|
||||
echo "Heslo je povinné"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -p "Jméno databáze: " db_name
|
||||
if [ "$db_name" == "" ]
|
||||
then
|
||||
echo "Jméno databáze je povinné"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "# Konfigurace redisu"
|
||||
read -p "Host redisu [localhost]: " redis_host
|
||||
read -p "Číslo databáze [0]: " redis_db
|
||||
|
||||
echo "# Konfigurace stránky"
|
||||
|
||||
read -p "Jméno stránky: " site_name
|
||||
if [ "$site_name" == "" ]
|
||||
then
|
||||
echo "Jméno stránky je povinné"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -p "Popis stránky: " site_description
|
||||
if [ "$site_description" == "" ]
|
||||
then
|
||||
echo "Popis stránky je povinný"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PORT=${port:-7000}
|
||||
export DB_HOST=${db_host:-localhost}
|
||||
export DB_PORT=${db_host:-5432}
|
||||
export DB_USER=${db_user}
|
||||
export DB_PASSWORD=${db_password}
|
||||
export DB_NAME=${db_name}
|
||||
export REDIS_HOST=${redis_host:-localhost}
|
||||
export REDIS_DB=${redis_db:-0}
|
||||
export REDIS_HOST=${redis_host:-localhost}
|
||||
export SITE_NAME=${site_name}
|
||||
export SITE_DESCRIPTION=${site_description}
|
||||
|
||||
export AUTH_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||
export TRIP_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||
export UID_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||
|
||||
envsubst < Nekrochan.toml.template > Nekrochan.toml
|
||||
mkdir -p ./uploads/thumb
|
@ -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);
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
)"#,
|
||||
|
@ -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?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>>,
|
||||
|
@ -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}\">>>{id}</a>");
|
||||
let dead_quote = format!("<span class=\"dead-quote\">>>{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);
|
||||
|
||||
|
10
src/error.rs
10
src/error.rs
@ -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,
|
||||
|
31
src/files.rs
31
src/files.rs
@ -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",
|
||||
|
16
src/lib.rs
16
src/lib.rs
@ -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,
|
||||
}
|
||||
|
@ -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(),
|
||||
));
|
||||
}
|
||||
|
@ -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),
|
||||
_ => (),
|
||||
|
@ -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)
|
||||
|
@ -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\">>$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 {
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
36
src/web/page.rs
Normální soubor
@ -0,0 +1,36 @@
|
||||
use actix_web::{
|
||||
get,
|
||||
web::{Data, Path},
|
||||
HttpRequest, HttpResponse,
|
||||
};
|
||||
use askama::Template;
|
||||
use tokio::fs::read_to_string;
|
||||
|
||||
use crate::{ctx::Ctx, error::NekrochanError, web::template_response};
|
||||
|
||||
use super::tcx::TemplateCtx;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "page.html")]
|
||||
struct PageTemplate {
|
||||
pub tcx: TemplateCtx,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[get("/page/{name}")]
|
||||
pub async fn page(
|
||||
ctx: Data<Ctx>,
|
||||
req: HttpRequest,
|
||||
name: Path<String>,
|
||||
) -> Result<HttpResponse, NekrochanError> {
|
||||
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||
let name = name.into_inner();
|
||||
let content = read_to_string(format!("./pages/{name}.html"))
|
||||
.await
|
||||
.map_err(|_| NekrochanError::PageNotFound(name.clone()))?;
|
||||
|
||||
let template = PageTemplate { tcx, name, content };
|
||||
|
||||
template_response(&template)
|
||||
}
|
@ -10,6 +10,7 @@ use crate::{
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAccountForm {
|
||||
username: String,
|
||||
#[serde(rename = "account_password")]
|
||||
password: String,
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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?;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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?;
|
||||
|
@ -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
58
src/web/thread_json.rs
Normální soubor
@ -0,0 +1,58 @@
|
||||
use actix_web::{
|
||||
get,
|
||||
web::{Data, Json, Path},
|
||||
HttpRequest,
|
||||
};
|
||||
use askama::Template;
|
||||
use std::{collections::HashMap, vec};
|
||||
|
||||
use crate::{
|
||||
ctx::Ctx,
|
||||
db::models::{Board, Post},
|
||||
error::NekrochanError,
|
||||
web::tcx::TemplateCtx,
|
||||
PostTemplate,
|
||||
};
|
||||
|
||||
#[get("/thread-json/{board}/{id}")]
|
||||
pub async fn thread_json(
|
||||
ctx: Data<Ctx>,
|
||||
req: HttpRequest,
|
||||
path: Path<(String, i64)>,
|
||||
) -> Result<Json<HashMap<i64, String>>, NekrochanError> {
|
||||
let (board, id) = path.into_inner();
|
||||
|
||||
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||
|
||||
let board = Board::read(&ctx, board.clone())
|
||||
.await?
|
||||
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||
|
||||
let thread = Post::read(&ctx, board.id.clone(), id)
|
||||
.await?
|
||||
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
|
||||
|
||||
if thread.thread.is_some() {
|
||||
return Err(NekrochanError::IsReplyError);
|
||||
}
|
||||
|
||||
let mut res = HashMap::new();
|
||||
|
||||
let replies = thread.read_replies(&ctx).await?;
|
||||
let posts = [vec![thread], replies].concat();
|
||||
|
||||
for post in posts {
|
||||
let id = post.id;
|
||||
let tcx = &tcx;
|
||||
let board = &board;
|
||||
let post = &post;
|
||||
|
||||
let html = PostTemplate { tcx, board, post }
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
res.insert(id, html);
|
||||
}
|
||||
|
||||
Ok(Json(res))
|
||||
}
|
@ -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
138
static/js/hover.js
Normální 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", "");
|
||||
}
|
||||
}
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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;
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
 
|
||||
{% endfor %}
|
||||
|
||||
{% if current == pages %}
|
||||
[Další]
|
||||
|
@ -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>
|
||||
|
@ -39,7 +39,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if !boxed %}
|
||||
<a href="{{ post.post_url_notarget() }}">[Odpovědět]</a> 
|
||||
<a href="{{ post.post_url_notarget() }}">[Otevřít]</a> 
|
||||
{% 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: 
|
||||
{% for quote in post.quotes %}
|
||||
<a class="quote" href="{{ post.thread_url() }}#{{ quote }}">>>{{ quote }}</a> 
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -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>] 
|
||||
[<a href="/staff/accounts">Účty</a>] 
|
||||
|
||||
|
@ -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
3
templates/page.html
Normální soubor
@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ tcx.cfg.site.name }} ({{ name }}){% endblock %}
|
||||
{% block content %}{{ content|safe }}{% endblock %}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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> 
|
||||
<a href="#bottom">[Dolů]</a> 
|
||||
<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> 
|
||||
<a href="#top">[Nahoru]</a> 
|
||||
<a href="/boards/{{ board.id }}/catalog">[Katalog]</a>
|
||||
</div>
|
||||
<hr>
|
||||
{% call post_actions::post_actions() %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
@ -1 +0,0 @@
|
||||
yotsuba.css
|
Načítá se…
Odkázat v novém úkolu
Zablokovat Uživatele