Vlastní stránky a odkazy + readme

Tento commit je obsažen v:
sneedmaster 2024-03-03 21:02:17 +01:00
rodič a96c86a722
revize 9b75c72ee8
23 změnil soubory, kde provedl 344 přidání a 30 odebrání

3
.gitignore vendorováno
Zobrazit soubor

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

47
Nekrochan.toml.template Spustitelný soubor
Zobrazit soubor

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

Zobrazit soubor

@ -2,6 +2,102 @@
100% český imidžbórdový skript 100% český imidžbórdový skript
>100% český přestože je kód anglicky... > 100% český přestože je kód anglicky...
Brzy dostupný na https://czchan.org/. Brzy dostupný na https://czchan.org/.
## Tutoriál nebo něco
Pravděpodobně to běží jenom na Linuxu, ale nikdo na serverech Windows stejně nepoužívá. Tutoriál počítá se systémem Ubuntu a je možné, že je nekompletní.
### Nainstaluj Rust
Ne, nejsem transka (zatím).
```
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### Nainstaluj ostatní požadavky
```
# Potřebné ke kompilaci
sudo apt install binutils build-essential libssl-dev libpq-dev postgresql
# Potřebné k funkci
sudo apt install imagemagick ffmpeg
```
### Vytvoř databázi
```
sudo adduser nekrochan --system --disabled-login --no-create-home --group
sudo passwd nekrochan # Nastavíme heslo pro systémového uživatele
sudo -iu postgres psql -c "CREATE USER nekrochan WITH PASSWORD 'password';"
sudo -iu postgres psql -c "CREATE DATABASE nekrochan WITH OWNER nekrochan;"
```
### Automatická konfigurace
```
chmod +x ./configure.sh
./configure.sh
```
### Nastartuj server
```
cargo run --release
```
### Vytvoř nástěnku
Po kompilaci by se měl spustit server na https://localhost:7000/. Stránka ti pravděpodobně řekne, že ještě nebyla inicializována domovní stránka. Je potřeba vytvořit nástěnku. Nejdříve je ale potřeba vytvořit administátorský účet.
Heslo v příkladu je "password", můžeš použít příklad a heslo změnit potom v administrátorském rozhraní.
```
sudo -iu nekrochan psql -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 [ -z ${db_user} ]
then
echo "Uživatelské jméno je povinné"
exit 1
fi
read -p "Heslo: " db_password
if [ -z ${db_password} ]
then
echo "Heslo je povinné"
exit 1
fi
read -p "Jméno databáze: " db_name
if [ -z ${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 [ -z ${site_name} ]
then
echo "Jméno stránky je povinné"
exit 1
fi
read -p "Popis stránky: " site_description
if [ -z ${site_description} ]
then
echo "Popis stránky je povinný"
exit 1
fi
export PORT=${port:-7000}
export DB_HOST=${db_host:-localhost}
export DB_PORT=${db_host:-5432}
export DB_USER=${db_user}
export DB_PASSWORD=${db_password}
export DB_NAME=${db_name}
export REDIS_HOST=${redis_host:-localhost}
export REDIS_DB=${redis_db:-0}
export REDIS_HOST=${redis_host:-localhost}
export SITE_NAME=${site_name}
export SITE_DESCRIPTION=${site_description}
export AUTH_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
export TRIP_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
export UID_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
envsubst < Nekrochan.toml.template > Nekrochan.toml
mkdir -p ./uploads/thumb

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

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

Zobrazit soubor

@ -78,13 +78,23 @@ impl Post {
} }
let ip_key = format!("by_ip:{ip}"); let ip_key = format!("by_ip:{ip}");
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", board.id, post.id); let member = format!("{}/{}", board.id, post.id);
let score = post.created.timestamp_micros(); let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?; ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?; ctx.cache().zadd(content_key, &member, score).await?;
if thread.is_none() {
ctx.cache()
.set(format!("last_thread:{ip}"), post.created.timestamp_micros())
.await?;
}
Ok(post) Ok(post)
} }
@ -116,13 +126,10 @@ impl Post {
} }
pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> { pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> {
let post = query_as(&format!( let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board))
"SELECT * FROM posts_{} WHERE id = $1", .bind(id)
board .fetch_optional(ctx.db())
)) .await?;
.bind(id)
.fetch_optional(ctx.db())
.await?;
Ok(post) Ok(post)
} }
@ -329,8 +336,11 @@ impl Post {
.fetch_one(ctx.db()) .fetch_one(ctx.db())
.await?; .await?;
let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes())); let old_key = format!(
let new_key = format!("by_content:{}", digest(content_nomarkup.as_bytes())); "by_content:{}",
digest(self.content_nomarkup.to_lowercase())
);
let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase()));
let member = format!("{}/{}", self.board, self.id); let member = format!("{}/{}", self.board, self.id);
let score = Utc::now().timestamp_micros(); let score = Utc::now().timestamp_micros();
@ -423,7 +433,10 @@ impl Post {
} }
let ip_key = format!("by_ip:{}", post.ip); let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes())); let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id); let member = format!("{}/{}", post.board, post.id);

Zobrazit soubor

@ -76,6 +76,8 @@ pub enum NekrochanError {
OverboardError, OverboardError,
#[error("Účet vlastníka nemůže být vymazán.")] #[error("Účet vlastníka nemůže být vymazán.")]
OwnerDeletionError, OwnerDeletionError,
#[error("Stránka {} neexistuje", .0)]
PageNotFound(String),
#[error("Heslo musí mít alespoň 8 znaků.")] #[error("Heslo musí mít alespoň 8 znaků.")]
PasswordFormatError, PasswordFormatError,
#[error("Jméno nesmí mít více než 32 znaků.")] #[error("Jméno nesmí mít více než 32 znaků.")]
@ -249,6 +251,7 @@ impl ResponseError for NekrochanError {
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED, NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR, NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN, NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND,
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST, NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST, NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND, NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,

Zobrazit soubor

@ -3,7 +3,7 @@ use db::models::{Board, Post};
use error::NekrochanError; use error::NekrochanError;
use web::tcx::TemplateCtx; use web::tcx::TemplateCtx;
const GENERIC_PAGE_SIZE: i64 = 15; const GENERIC_PAGE_SIZE: i64 = 10;
pub mod auth; pub mod auth;
pub mod cfg; pub mod cfg;

Zobrazit soubor

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

Zobrazit soubor

@ -65,7 +65,7 @@ pub async fn create_post(
} }
let mut bump = true; let mut bump = true;
let mut noko = ctx.cfg.site.default_noko; let mut noko = ctx.cfg.site.noko;
let thread = match form.thread { let thread = match form.thread {
Some(Text(thread)) => { Some(Text(thread)) => {
@ -141,11 +141,11 @@ pub async fn create_post(
bump = false; bump = false;
} }
if !ctx.cfg.site.default_noko && email_lower == "noko" { if !ctx.cfg.site.noko && email_lower == "noko" {
noko = true noko = true
} }
if ctx.cfg.site.default_noko { if ctx.cfg.site.noko {
if email_lower == "nonoko" { if email_lower == "nonoko" {
noko = false; noko = false;
} }
@ -322,5 +322,14 @@ pub async fn check_spam(
return Err(NekrochanError::FloodError); return Err(NekrochanError::FloodError);
} }
let last_thread: i64 = ctx.cache().get(format!("last_thread:{ip}")).await?;
let since_last_thread = Utc::now().timestamp_micros() - last_thread;
let since_last_thread = Duration::microseconds(since_last_thread);
if since_last_thread.num_seconds() < board.config.thread_cooldown {
return Err(NekrochanError::FloodError);
}
Ok(()) Ok(())
} }

Zobrazit soubor

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

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

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

Zobrazit soubor

@ -8,7 +8,7 @@ use crate::{
}; };
lazy_static! { lazy_static! {
static ref ID_REGEX: Regex = Regex::new(r#"^\w{1,16}$"#).unwrap(); static ref ID_REGEX: Regex = Regex::new(r"^\w{1,16}$").unwrap();
} }
#[derive(Deserialize)] #[derive(Deserialize)]

Zobrazit soubor

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

Zobrazit soubor

@ -39,7 +39,7 @@ pub async fn thread_json(
let mut res = HashMap::new(); let mut res = HashMap::new();
let replies = thread.read_replies(&ctx).await?; let replies = thread.read_replies(&ctx).await?;
let posts = vec![vec![thread], replies].concat(); let posts = [vec![thread], replies].concat();
for post in posts { for post in posts {
let id = post.id; let id = post.id;

Zobrazit soubor

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

Zobrazit soubor

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

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

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

Zobrazit soubor

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

Zobrazit soubor

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