Skript na živé aktualizace vláken + relativní časy na frontendu
Tento commit je obsažen v:
rodič
cc6921d4ed
revize
3cce9a32e1
82
Cargo.lock
vygenerováno
82
Cargo.lock
vygenerováno
@ -2,6 +2,31 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "actix"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb72882332b6d6282f428b77ba0358cb2687e61a6f6df6a6d3871e8a177c2d4f"
|
||||
dependencies = [
|
||||
"actix-macros",
|
||||
"actix-rt",
|
||||
"actix_derive",
|
||||
"bitflags 2.3.3",
|
||||
"bytes",
|
||||
"crossbeam-channel",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-codec"
|
||||
version = "0.5.1"
|
||||
@ -232,6 +257,24 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-web-actors"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "420b001bb709d8510c3e2659dae046e54509ff9528018d09c78381e765a1f9fa"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-codec",
|
||||
"actix-http",
|
||||
"actix-web",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-web-codegen"
|
||||
version = "4.2.0"
|
||||
@ -244,6 +287,17 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix_derive"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c7db3d5a9718568e4cf4a537cfd7070e6e6ff7481510d0237fb529ac850f6d3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.28",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.20.0"
|
||||
@ -753,6 +807,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.8"
|
||||
@ -765,12 +828,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.16"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
@ -1732,9 +1792,11 @@ dependencies = [
|
||||
name = "nekrochan"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-files",
|
||||
"actix-multipart",
|
||||
"actix-web",
|
||||
"actix-web-actors",
|
||||
"anyhow",
|
||||
"askama",
|
||||
"captcha",
|
||||
@ -1764,6 +1826,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3052,6 +3115,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
@ -4,9 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.13.3"
|
||||
actix-files = "0.6.2"
|
||||
actix-multipart = "0.6.0"
|
||||
actix-web = { version = "4.3.1", features = ["cookies"] }
|
||||
actix-web-actors = "4.3.0"
|
||||
askama = "0.12.0"
|
||||
anyhow = "1.0.71"
|
||||
captcha = "0.0.9"
|
||||
@ -40,6 +42,7 @@ sqlx = { version = "0.7.0", features = [
|
||||
thiserror = "1.0.41"
|
||||
tokio = { version = "1.29.1", features = ["rt-multi-thread", "macros"] }
|
||||
toml = "0.8.6"
|
||||
uuid = { version = "1.7.0", features = ["v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "1.0.74"
|
||||
|
12
src/cfg.rs
12
src/cfg.rs
@ -2,7 +2,7 @@ use anyhow::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs::read_to_string;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Cfg {
|
||||
pub server: ServerCfg,
|
||||
pub site: SiteCfg,
|
||||
@ -20,28 +20,28 @@ impl Cfg {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ServerCfg {
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub cache_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct SiteCfg {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub default_noko: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct SecretsCfg {
|
||||
pub auth_token: String,
|
||||
pub secure_trip: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct FilesCfg {
|
||||
pub videos: bool,
|
||||
pub thumb_size: u32,
|
||||
@ -51,7 +51,7 @@ pub struct FilesCfg {
|
||||
pub cleanup_interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BoardCfg {
|
||||
pub anon_name: String,
|
||||
pub page_size: i64,
|
||||
|
22
src/ctx.rs
22
src/ctx.rs
@ -1,25 +1,33 @@
|
||||
use actix::{Actor, Addr};
|
||||
use anyhow::Error;
|
||||
use redis::{aio::MultiplexedConnection, Client};
|
||||
use sqlx::PgPool;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::cfg::Cfg;
|
||||
use crate::{cfg::Cfg, live_hub::LiveHub};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Ctx {
|
||||
pub cfg: Cfg,
|
||||
db: PgPool,
|
||||
cache: MultiplexedConnection,
|
||||
hub: Addr<LiveHub>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
pub async fn new(cfg: Cfg) -> Result<Self, Error> {
|
||||
let db = PgPool::connect(&cfg.server.database_url).await?;
|
||||
let cache = Client::open(cfg.server.cache_url.as_str())?
|
||||
.get_multiplexed_async_connection()
|
||||
.await?;
|
||||
let client = Client::open(cfg.server.cache_url.as_str())?;
|
||||
let cache = client.get_multiplexed_async_connection().await?;
|
||||
let sync_cache = client.get_connection()?;
|
||||
let hub = LiveHub::new(sync_cache).start();
|
||||
|
||||
Ok(Self { cfg, db, cache })
|
||||
Ok(Self {
|
||||
cfg,
|
||||
db,
|
||||
cache,
|
||||
hub,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bind_addr(&self) -> SocketAddr {
|
||||
@ -33,4 +41,8 @@ impl Ctx {
|
||||
pub fn cache(&self) -> MultiplexedConnection {
|
||||
self.cache.clone()
|
||||
}
|
||||
|
||||
pub fn hub(&self) -> Addr<LiveHub> {
|
||||
self.hub.clone()
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
use redis::{cmd, AsyncCommands, JsonAsyncCommands};
|
||||
use redis::{cmd, AsyncCommands, Connection, JsonAsyncCommands, JsonCommands};
|
||||
use sqlx::{query, query_as, types::Json};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@ -86,6 +86,17 @@ impl Board {
|
||||
Ok(board)
|
||||
}
|
||||
|
||||
pub fn read_sync(cache: &mut Connection, id: String) -> Result<Option<Self>, NekrochanError> {
|
||||
let board: Option<String> = cache.json_get(format!("boards:{id}"), ".")?;
|
||||
|
||||
let board = match board {
|
||||
Some(json) => Some(serde_json::from_str(&json)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(board)
|
||||
}
|
||||
|
||||
pub async fn read_all(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
|
||||
let mut boards = Vec::new();
|
||||
let ids: Vec<String> = ctx.cache().lrange("board_ids", 0, -1).await?;
|
||||
|
@ -16,7 +16,7 @@ pub struct Account {
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
pub struct Board {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@ -25,7 +25,7 @@ pub struct Board {
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
pub struct Ban {
|
||||
pub id: i32,
|
||||
pub ip_range: IpNetwork,
|
||||
@ -45,7 +45,7 @@ pub struct Report {
|
||||
pub reporter_ip: IpAddr,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
pub struct Post {
|
||||
pub id: i64,
|
||||
pub board: String,
|
||||
|
@ -5,7 +5,12 @@ use sqlx::{query, query_as, types::Json};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use super::models::{Board, File, Post, Report};
|
||||
use crate::{ctx::Ctx, error::NekrochanError, GENERIC_PAGE_SIZE};
|
||||
use crate::{
|
||||
ctx::Ctx,
|
||||
error::NekrochanError,
|
||||
live_hub::{PostCreatedMessage, PostRemovedMessage, PostUpdatedMessage},
|
||||
GENERIC_PAGE_SIZE,
|
||||
};
|
||||
|
||||
impl Post {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@ -23,7 +28,7 @@ impl Post {
|
||||
password: String,
|
||||
country: String,
|
||||
ip: IpAddr,
|
||||
bumpy_bump: bool,
|
||||
bump: bool,
|
||||
) -> Result<Self, NekrochanError> {
|
||||
let post: Post = query_as(&format!(
|
||||
r#"INSERT INTO posts_{}
|
||||
@ -54,7 +59,7 @@ impl Post {
|
||||
.execute(ctx.db())
|
||||
.await?;
|
||||
|
||||
if bumpy_bump {
|
||||
if bump {
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET bumps = bumps + 1, bumped = CURRENT_TIMESTAMP WHERE id = $1",
|
||||
board.id
|
||||
@ -230,6 +235,23 @@ impl Post {
|
||||
Ok(replies)
|
||||
}
|
||||
|
||||
pub async fn read_replies_after(
|
||||
&self,
|
||||
ctx: &Ctx,
|
||||
last: i64,
|
||||
) -> Result<Vec<Self>, NekrochanError> {
|
||||
let replies = query_as(&format!(
|
||||
"SELECT * FROM posts_{} WHERE thread = $1 AND id > $2 ORDER BY created ASC",
|
||||
self.board
|
||||
))
|
||||
.bind(self.id)
|
||||
.bind(last)
|
||||
.fetch_all(ctx.db())
|
||||
.await?;
|
||||
|
||||
Ok(replies)
|
||||
}
|
||||
|
||||
pub async fn read_all(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
|
||||
let posts = query_as(&format!("SELECT * FROM posts_{board}"))
|
||||
.fetch_all(ctx.db())
|
||||
@ -247,39 +269,45 @@ impl Post {
|
||||
}
|
||||
|
||||
pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> {
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET user_id = $1 WHERE id = $2",
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET user_id = $1 WHERE id = $2 RETURNING *",
|
||||
self.board,
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(self.id)
|
||||
.execute(ctx.db())
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub().send(PostCreatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1",
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1 RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(self.id)
|
||||
.execute(ctx.db())
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1",
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1 RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(self.id)
|
||||
.execute(ctx.db())
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -289,14 +317,14 @@ impl Post {
|
||||
content: String,
|
||||
content_nomarkup: String,
|
||||
) -> Result<(), NekrochanError> {
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3",
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET content = $1, content_nomarkup = $2 WHERE id = $3 RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(content)
|
||||
.bind(&content_nomarkup)
|
||||
.bind(self.id)
|
||||
.execute(ctx.db())
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes()));
|
||||
@ -306,6 +334,7 @@ impl Post {
|
||||
|
||||
ctx.cache().zrem(old_key, &member).await?;
|
||||
ctx.cache().zadd(new_key, &member, score).await?;
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -317,15 +346,17 @@ impl Post {
|
||||
file.spoiler = !file.spoiler;
|
||||
}
|
||||
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET files = $1 WHERE id = $2",
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET files = $1 WHERE id = $2 RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(Json(files))
|
||||
.bind(self.id)
|
||||
.execute(ctx.db())
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -365,6 +396,9 @@ impl Post {
|
||||
|
||||
ctx.cache().zrem(ip_key, &member).await?;
|
||||
ctx.cache().zrem(content_key, &member).await?;
|
||||
ctx.hub()
|
||||
.send(PostRemovedMessage { post: post.clone() })
|
||||
.await?;
|
||||
}
|
||||
|
||||
let in_list = to_be_deleted
|
||||
@ -411,6 +445,10 @@ impl Post {
|
||||
.execute(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub()
|
||||
.send(PostUpdatedMessage { post: self.clone() })
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
35
src/error.rs
35
src/error.rs
@ -58,6 +58,8 @@ pub enum NekrochanError {
|
||||
InvalidCaptchaError,
|
||||
#[error("Neplatná strana.")]
|
||||
InvalidPageError,
|
||||
#[error("Tento příspěvek není vlákno.")]
|
||||
IsReplyError,
|
||||
#[error("Obsah musí mít 1-20000 znaků.")]
|
||||
NewsContentFormatError,
|
||||
#[error("Titulek musí mít 1-100 znaků.")]
|
||||
@ -86,8 +88,6 @@ pub enum NekrochanError {
|
||||
ReplyLimitError,
|
||||
#[error("Hlášení můsí mít 1-200 znaků.")]
|
||||
ReportFormatError,
|
||||
#[error("Nelze vytvořit odpověď na odpověď.")]
|
||||
ReplyReplyError,
|
||||
#[error("Na této nástěnce se musí vyplnit CAPTCHA.")]
|
||||
RequiredCaptchaError,
|
||||
#[error("Toto vlákno je uzamčené.")]
|
||||
@ -98,10 +98,23 @@ pub enum NekrochanError {
|
||||
UsernameFormatError,
|
||||
}
|
||||
|
||||
impl From<actix::MailboxError> for NekrochanError {
|
||||
fn from(e: actix::MailboxError) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
|
||||
impl From<actix_web::Error> for NekrochanError {
|
||||
fn from(e: actix_web::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
|
||||
impl From<askama::Error> for NekrochanError {
|
||||
fn from(e: askama::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -109,7 +122,6 @@ impl From<askama::Error> for NekrochanError {
|
||||
impl From<ipnetwork::IpNetworkError> for NekrochanError {
|
||||
fn from(e: ipnetwork::IpNetworkError) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -117,7 +129,6 @@ impl From<ipnetwork::IpNetworkError> for NekrochanError {
|
||||
impl From<jsonwebtoken::errors::Error> for NekrochanError {
|
||||
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -125,7 +136,6 @@ impl From<jsonwebtoken::errors::Error> for NekrochanError {
|
||||
impl From<pwhash::error::Error> for NekrochanError {
|
||||
fn from(e: pwhash::error::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -133,7 +143,6 @@ impl From<pwhash::error::Error> for NekrochanError {
|
||||
impl From<regex::Error> for NekrochanError {
|
||||
fn from(e: regex::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -141,7 +150,6 @@ impl From<regex::Error> for NekrochanError {
|
||||
impl From<redis::RedisError> for NekrochanError {
|
||||
fn from(e: redis::RedisError) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -149,7 +157,6 @@ impl From<redis::RedisError> for NekrochanError {
|
||||
impl From<serde_json::Error> for NekrochanError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -157,7 +164,6 @@ impl From<serde_json::Error> for NekrochanError {
|
||||
impl From<serde_qs::Error> for NekrochanError {
|
||||
fn from(e: serde_qs::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -173,7 +179,6 @@ impl From<sqlx::Error> for NekrochanError {
|
||||
Self::OverboardError
|
||||
} else {
|
||||
error!("{e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -182,7 +187,6 @@ impl From<sqlx::Error> for NekrochanError {
|
||||
impl From<std::io::Error> for NekrochanError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -190,15 +194,13 @@ impl From<std::io::Error> for NekrochanError {
|
||||
impl From<std::net::AddrParseError> for NekrochanError {
|
||||
fn from(e: std::net::AddrParseError) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<std::sync::PoisonError<T>> for NekrochanError {
|
||||
fn from(_: std::sync::PoisonError<T>) -> Self {
|
||||
error!("CAPTCHA RwLock got poisoned or something.");
|
||||
|
||||
error!("Some Mutex or Lock got poisoned or something");
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -206,7 +208,6 @@ impl<T> From<std::sync::PoisonError<T>> for NekrochanError {
|
||||
impl From<tokio::task::JoinError> for NekrochanError {
|
||||
fn from(e: tokio::task::JoinError) -> Self {
|
||||
error!("Internal server error: {e:#?}");
|
||||
|
||||
Self::InternalError
|
||||
}
|
||||
}
|
||||
@ -241,6 +242,7 @@ impl ResponseError for NekrochanError {
|
||||
NekrochanError::InvalidAuthError => StatusCode::UNAUTHORIZED,
|
||||
NekrochanError::InvalidCaptchaError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::InvalidPageError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::IsReplyError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::NewsContentFormatError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::NewsTitleFormatError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::NoCaptchaError => StatusCode::NOT_FOUND,
|
||||
@ -254,7 +256,6 @@ impl ResponseError for NekrochanError {
|
||||
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
|
||||
NekrochanError::ReplyLimitError => StatusCode::FORBIDDEN,
|
||||
NekrochanError::ReplyReplyError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::ReportFormatError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::RequiredCaptchaError => StatusCode::UNAUTHORIZED,
|
||||
NekrochanError::ThreadLockError => StatusCode::FORBIDDEN,
|
||||
|
@ -11,62 +11,6 @@ lazy_static! {
|
||||
Regex::new(r#"<a class="quote" href=".+">>>(\d+)<\/a>"#).unwrap();
|
||||
}
|
||||
|
||||
pub fn czech_humantime(time: &DateTime<Utc>) -> askama::Result<String> {
|
||||
let duration = (Utc::now() - *time).abs();
|
||||
|
||||
let seconds = duration.num_seconds();
|
||||
let minutes = duration.num_minutes();
|
||||
let hours = duration.num_hours();
|
||||
let days = duration.num_days();
|
||||
let weeks = duration.num_weeks();
|
||||
let months = duration.num_days() / 30;
|
||||
let years = duration.num_days() / 365;
|
||||
|
||||
let mut time = "Teď".into();
|
||||
|
||||
if seconds > 0 {
|
||||
time = format!(
|
||||
"{} {}",
|
||||
seconds,
|
||||
czech_plural("sekunda|sekundy|sekund", seconds)?
|
||||
);
|
||||
}
|
||||
|
||||
if minutes > 0 {
|
||||
time = format!(
|
||||
"{} {}",
|
||||
minutes,
|
||||
czech_plural("minuta|minuty|minut", minutes)?
|
||||
);
|
||||
}
|
||||
|
||||
if hours > 0 {
|
||||
time = format!("{} {}", hours, czech_plural("hodina|hodiny|hodin", hours)?);
|
||||
}
|
||||
|
||||
if days > 0 {
|
||||
time = format!("{} {}", days, czech_plural("den|dny|dnů", days)?);
|
||||
}
|
||||
|
||||
if weeks > 0 {
|
||||
time = format!("{} {}", weeks, czech_plural("týden|týdny|týdnů", weeks)?);
|
||||
}
|
||||
|
||||
if months > 0 {
|
||||
time = format!(
|
||||
"{} {}",
|
||||
months,
|
||||
czech_plural("měsíc|měsíce|měsíců", months)?
|
||||
);
|
||||
}
|
||||
|
||||
if years > 0 {
|
||||
time = format!("{} {}", years, czech_plural("rok|roky|let", years)?);
|
||||
}
|
||||
|
||||
Ok(time)
|
||||
}
|
||||
|
||||
pub fn czech_datetime(utc: &DateTime<Utc>) -> askama::Result<String> {
|
||||
let time = Prague.from_utc_datetime(&utc.naive_utc());
|
||||
|
||||
|
@ -9,6 +9,8 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod files;
|
||||
pub mod filters;
|
||||
pub mod live_hub;
|
||||
pub mod live_session;
|
||||
pub mod markup;
|
||||
pub mod perms;
|
||||
pub mod qsform;
|
||||
@ -20,9 +22,9 @@ pub fn paginate(page_size: i64, count: i64) -> i64 {
|
||||
let pages = count / page_size + (count % page_size).signum();
|
||||
|
||||
if pages == 0 {
|
||||
return 1;
|
||||
1
|
||||
} else {
|
||||
return pages;
|
||||
pages
|
||||
}
|
||||
}
|
||||
|
||||
|
262
src/live_hub.rs
Normální soubor
262
src/live_hub.rs
Normální soubor
@ -0,0 +1,262 @@
|
||||
use actix::{Actor, Context, Handler, Message, Recipient};
|
||||
use askama::Template;
|
||||
use redis::Connection;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::models::{Board, Post},
|
||||
filters,
|
||||
web::tcx::TemplateCtx,
|
||||
};
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct SessionMessage(pub String);
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct ConnectMessage {
|
||||
pub uuid: Uuid,
|
||||
pub thread: (String, i64),
|
||||
pub tcx: TemplateCtx,
|
||||
pub recv: Recipient<SessionMessage>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct DisconnectMessage {
|
||||
pub uuid: Uuid,
|
||||
pub thread: (String, i64),
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct PostCreatedMessage {
|
||||
pub post: Post,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct TargetedPostCreatedMessage {
|
||||
pub uuid: Uuid,
|
||||
pub post: Post,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct PostUpdatedMessage {
|
||||
pub post: Post,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
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>)>,
|
||||
pub recv_by_thread: HashMap<(String, i64), Vec<Uuid>>,
|
||||
}
|
||||
|
||||
impl LiveHub {
|
||||
pub fn new(cache: Connection) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
recv_by_uuid: HashMap::new(),
|
||||
recv_by_thread: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for LiveHub {
|
||||
type Context = Context<Self>;
|
||||
}
|
||||
|
||||
impl Handler<ConnectMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ConnectMessage, _: &mut Self::Context) -> Self::Result {
|
||||
self.recv_by_uuid.insert(msg.uuid, (msg.tcx, msg.recv));
|
||||
|
||||
match self.recv_by_thread.get_mut(&msg.thread) {
|
||||
Some(vec) => {
|
||||
vec.push(msg.uuid);
|
||||
}
|
||||
None => {
|
||||
self.recv_by_thread.insert(msg.thread, vec![msg.uuid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<DisconnectMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: DisconnectMessage, _: &mut Self::Context) -> Self::Result {
|
||||
self.recv_by_uuid.remove(&msg.uuid);
|
||||
|
||||
let recv_by_thread = match self.recv_by_thread.get_mut(&msg.thread) {
|
||||
Some(recv_by_thread) => recv_by_thread,
|
||||
None => return,
|
||||
};
|
||||
|
||||
*recv_by_thread = recv_by_thread
|
||||
.iter()
|
||||
.filter(|uuid| **uuid != msg.uuid)
|
||||
.map(Uuid::clone)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<PostCreatedMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: PostCreatedMessage, _: &mut Self::Context) -> Self::Result {
|
||||
let post = msg.post;
|
||||
|
||||
let uuids = self
|
||||
.recv_by_thread
|
||||
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
|
||||
|
||||
let uuids = match uuids {
|
||||
Some(uuids) => uuids,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
tcx.update_yous(&mut self.cache).ok();
|
||||
|
||||
let post = post.clone();
|
||||
let id = post.id;
|
||||
let tcx = tcx.clone();
|
||||
let board = board.clone();
|
||||
|
||||
let html = PostTemplate { tcx, post, board }
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<TargetedPostCreatedMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: TargetedPostCreatedMessage, _: &mut Self::Context) -> Self::Result {
|
||||
let post = msg.post;
|
||||
|
||||
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid)else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = post.id;
|
||||
let tcx = tcx.clone();
|
||||
|
||||
let html = PostTemplate { tcx, post, board }
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<PostUpdatedMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: PostUpdatedMessage, _: &mut Self::Context) -> Self::Result {
|
||||
let post = msg.post;
|
||||
|
||||
let uuids = self
|
||||
.recv_by_thread
|
||||
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
|
||||
|
||||
let uuids = match uuids {
|
||||
Some(uuids) => uuids,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let Ok(Some(board)) = Board::read_sync(&mut self.cache, post.board.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
let Some((tcx, recv)) = self.recv_by_uuid.get_mut(uuid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
tcx.update_yous(&mut self.cache).ok();
|
||||
|
||||
let post = post.clone();
|
||||
let id = post.id;
|
||||
let tcx = tcx.clone();
|
||||
let board = board.clone();
|
||||
|
||||
let html = PostTemplate { tcx, post, board }
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
json!({ "type": "updated", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<PostRemovedMessage> for LiveHub {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: PostRemovedMessage, _: &mut Self::Context) -> Self::Result {
|
||||
let post = msg.post;
|
||||
|
||||
let uuids = self
|
||||
.recv_by_thread
|
||||
.get(&(post.board.clone(), post.thread.unwrap_or(post.id)));
|
||||
|
||||
let uuids = match uuids {
|
||||
Some(uuids) => uuids,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for uuid in uuids {
|
||||
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
json!({ "type": "removed", "id": post.id }).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
65
src/live_session.rs
Normální soubor
65
src/live_session.rs
Normální soubor
@ -0,0 +1,65 @@
|
||||
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
|
||||
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
live_hub::{ConnectMessage, DisconnectMessage, LiveHub, SessionMessage},
|
||||
web::tcx::TemplateCtx,
|
||||
};
|
||||
|
||||
pub struct LiveSession {
|
||||
pub uuid: Uuid,
|
||||
pub thread: (String, i64),
|
||||
pub tcx: TemplateCtx,
|
||||
pub hub: Addr<LiveHub>,
|
||||
}
|
||||
|
||||
impl Actor for LiveSession {
|
||||
type Context = WebsocketContext<Self>;
|
||||
}
|
||||
|
||||
impl Handler<SessionMessage> for LiveSession {
|
||||
type Result = ();
|
||||
|
||||
fn handle(
|
||||
&mut self,
|
||||
SessionMessage(msg): SessionMessage,
|
||||
ctx: &mut Self::Context,
|
||||
) -> Self::Result {
|
||||
ctx.text(msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamHandler<Result<WsMessage, ProtocolError>> for LiveSession {
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
let uuid = self.uuid;
|
||||
let thread = self.thread.clone();
|
||||
let tcx = self.tcx.clone();
|
||||
let recv = ctx.address().recipient();
|
||||
|
||||
self.hub.do_send(ConnectMessage {
|
||||
uuid,
|
||||
thread,
|
||||
tcx,
|
||||
recv,
|
||||
});
|
||||
}
|
||||
|
||||
fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) {
|
||||
match msg {
|
||||
Ok(WsMessage::Ping(data)) => ctx.pong(&data),
|
||||
Ok(WsMessage::Close(_)) => self.finished(ctx),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn finished(&mut self, ctx: &mut Self::Context) {
|
||||
self.hub.do_send(DisconnectMessage {
|
||||
uuid: self.uuid,
|
||||
thread: self.thread.clone(),
|
||||
});
|
||||
|
||||
ctx.close(None);
|
||||
ctx.stop();
|
||||
}
|
||||
}
|
@ -23,7 +23,7 @@ use sqlx::migrate;
|
||||
use std::{env::var, time::Duration};
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::main]
|
||||
#[actix_web::main]
|
||||
async fn main() {
|
||||
dotenv::dotenv().ok();
|
||||
env_logger::init();
|
||||
@ -66,6 +66,7 @@ async fn run() -> Result<(), Error> {
|
||||
.service(web::captcha::captcha)
|
||||
.service(web::edit_posts::edit_posts)
|
||||
.service(web::ip_posts::ip_posts)
|
||||
.service(web::live::live)
|
||||
.service(web::login::login_get)
|
||||
.service(web::login::login_post)
|
||||
.service(web::logout::logout)
|
||||
|
@ -23,6 +23,7 @@ pub enum Permissions {
|
||||
BypassAntispam,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionWrapper(BitFlags<Permissions>, bool);
|
||||
|
||||
impl PermissionWrapper {
|
||||
|
@ -64,7 +64,7 @@ pub async fn create_post(
|
||||
return Err(NekrochanError::BoardLockError(board.id.clone()));
|
||||
}
|
||||
|
||||
let mut bumpy_bump = true;
|
||||
let mut bump = true;
|
||||
let mut noko = ctx.cfg.site.default_noko;
|
||||
|
||||
let thread = match form.thread {
|
||||
@ -74,7 +74,7 @@ pub async fn create_post(
|
||||
.ok_or(NekrochanError::PostNotFound(board.id.clone(), thread))?;
|
||||
|
||||
if thread.thread.is_some() {
|
||||
return Err(NekrochanError::ReplyReplyError);
|
||||
return Err(NekrochanError::IsReplyError);
|
||||
}
|
||||
|
||||
if thread.locked && !(perms.owner() || perms.bypass_thread_lock()) {
|
||||
@ -86,7 +86,7 @@ pub async fn create_post(
|
||||
}
|
||||
|
||||
if thread.bumps >= board.config.0.bump_limit {
|
||||
bumpy_bump = false;
|
||||
bump = false;
|
||||
}
|
||||
|
||||
Some(thread)
|
||||
@ -137,16 +137,32 @@ pub async fn create_post(
|
||||
|
||||
let email_lower = email_raw.to_lowercase();
|
||||
|
||||
if email_lower.contains("sage") {
|
||||
bumpy_bump = false;
|
||||
if email_lower == "sage" {
|
||||
bump = false;
|
||||
}
|
||||
|
||||
if !ctx.cfg.site.default_noko && email_lower.contains("noko") {
|
||||
if !ctx.cfg.site.default_noko && email_lower == "noko" {
|
||||
noko = true
|
||||
}
|
||||
|
||||
if ctx.cfg.site.default_noko && email_lower.contains("nonoko") {
|
||||
noko = false
|
||||
if ctx.cfg.site.default_noko {
|
||||
if email_lower == "nonoko" {
|
||||
noko = false;
|
||||
}
|
||||
|
||||
if email_lower == "nonokosage" {
|
||||
noko = false;
|
||||
bump = false;
|
||||
}
|
||||
} else {
|
||||
if email_lower == "noko" {
|
||||
noko = true;
|
||||
}
|
||||
|
||||
if email_lower == "nokosage" {
|
||||
noko = true;
|
||||
bump = false;
|
||||
}
|
||||
}
|
||||
|
||||
Some(email_raw.into())
|
||||
@ -221,7 +237,7 @@ pub async fn create_post(
|
||||
password,
|
||||
country,
|
||||
ip,
|
||||
bumpy_bump,
|
||||
bump,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
@ -21,7 +21,7 @@ pub async fn get_posts_from_ids(ctx: &Ctx, ids: &Vec<String>) -> Vec<Post> {
|
||||
let mut posts = Vec::new();
|
||||
|
||||
for id in ids {
|
||||
if let Some((board, id)) = parse_id(&id) {
|
||||
if let Some((board, id)) = parse_id(id) {
|
||||
if let Ok(Some(post)) = Post::read(ctx, board, id).await {
|
||||
posts.push(post);
|
||||
}
|
||||
|
@ -125,8 +125,12 @@ pub async fn staff_post_actions(
|
||||
}
|
||||
|
||||
if form.toggle_lock.is_some() {
|
||||
post.update_lock(&ctx).await?;
|
||||
locks_toggled += 1;
|
||||
if post.thread.is_some() {
|
||||
writeln!(&mut response, "[Chyba] Odpověď nelze uzamknout.").ok();
|
||||
} else {
|
||||
post.update_lock(&ctx).await?;
|
||||
locks_toggled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,15 +149,10 @@ pub async fn staff_post_actions(
|
||||
let mut already_banned = HashSet::new();
|
||||
|
||||
for post in &posts {
|
||||
if let (
|
||||
(Some(_), None) | (None, Some(_)) | (Some(_), Some(_)),
|
||||
Some(reason),
|
||||
Some(duration),
|
||||
Some(range),
|
||||
) = (
|
||||
if let ((Some(_), _) | (_, Some(_)), reason, duration, Some(range)) = (
|
||||
(form.ban_user.clone(), form.ban_reporters.clone()),
|
||||
form.ban_reason.clone(),
|
||||
form.ban_duration,
|
||||
form.ban_reason.clone().unwrap_or_default(),
|
||||
form.ban_duration.unwrap_or_default(),
|
||||
form.ban_range.clone(),
|
||||
) {
|
||||
if !(account.perms().owner() || account.perms().bans()) {
|
||||
@ -222,19 +221,24 @@ pub async fn staff_post_actions(
|
||||
for post in &posts {
|
||||
if form.troll_user.is_some() {
|
||||
if !(tcx.perms.owner() || tcx.perms.edit_posts()) {
|
||||
writeln!(&mut response, "[Chyba] Nemáš oprávnění upravovat příspěvky.").ok();
|
||||
writeln!(
|
||||
&mut response,
|
||||
"[Chyba] Nemáš oprávnění upravovat příspěvky."
|
||||
)
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
if !(tcx.perms.owner() || tcx.perms.view_ips()) {
|
||||
writeln!(&mut response, "[Chyba] Nemáš oprávnění zobrazovat IP adresy.").ok();
|
||||
writeln!(
|
||||
&mut response,
|
||||
"[Chyba] Nemáš oprávnění zobrazovat IP adresy."
|
||||
)
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
let content_nomarkup = format!(
|
||||
"{}\n\n##({})##",
|
||||
post.content_nomarkup, post.ip
|
||||
);
|
||||
let content_nomarkup = format!("{}\n\n##({})##", post.content_nomarkup, post.ip);
|
||||
|
||||
let content = format!(
|
||||
"{}\n\n<span class=\"jannytext\">({})</span>",
|
||||
@ -246,7 +250,6 @@ pub async fn staff_post_actions(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if posts_removed != 0 {
|
||||
writeln!(
|
||||
&mut response,
|
||||
@ -312,6 +315,7 @@ pub async fn staff_post_actions(
|
||||
template_response(&template)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ban_ip(
|
||||
ctx: &Ctx,
|
||||
account: &Account,
|
||||
@ -359,7 +363,7 @@ async fn ban_ip(
|
||||
Some(Utc::now() + Duration::days(duration as i64))
|
||||
};
|
||||
|
||||
Ban::create(&ctx, account, board, ip_range, reason, appealable, expires).await?;
|
||||
Ban::create(ctx, account, board, ip_range, reason, appealable, expires).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
62
src/web/live.rs
Normální soubor
62
src/web/live.rs
Normální soubor
@ -0,0 +1,62 @@
|
||||
use actix_web::{
|
||||
get,
|
||||
web::{Data, Path, Payload},
|
||||
HttpRequest, HttpResponse,
|
||||
};
|
||||
use actix_web_actors::ws;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
ctx::Ctx,
|
||||
db::models::{Board, Post},
|
||||
error::NekrochanError,
|
||||
live_hub::TargetedPostCreatedMessage,
|
||||
live_session::LiveSession,
|
||||
web::tcx::TemplateCtx,
|
||||
};
|
||||
|
||||
#[get("/live/{board}/{id}/{last}")]
|
||||
pub async fn live(
|
||||
ctx: Data<Ctx>,
|
||||
req: HttpRequest,
|
||||
path: Path<(String, i64, i64)>,
|
||||
stream: Payload,
|
||||
) -> Result<HttpResponse, NekrochanError> {
|
||||
let (board, id, last) = path.into_inner();
|
||||
|
||||
let board = Board::read(&ctx, board.clone())
|
||||
.await?
|
||||
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||
|
||||
let post = Post::read(&ctx, board.id.clone(), id)
|
||||
.await?
|
||||
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
|
||||
|
||||
if post.thread.is_some() {
|
||||
return Err(NekrochanError::IsReplyError);
|
||||
}
|
||||
|
||||
let uuid = Uuid::new_v4();
|
||||
let thread = (board.id, id);
|
||||
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||
let hub = ctx.hub();
|
||||
|
||||
let ws = LiveSession {
|
||||
uuid,
|
||||
thread,
|
||||
tcx,
|
||||
hub,
|
||||
};
|
||||
|
||||
let res = ws::start(ws, &req, stream)?;
|
||||
|
||||
let new_replies = post.read_replies_after(&ctx, last).await?;
|
||||
|
||||
for post in new_replies {
|
||||
ctx.hub()
|
||||
.send(TargetedPostCreatedMessage { uuid, post })
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
@ -5,6 +5,7 @@ pub mod captcha;
|
||||
pub mod edit_posts;
|
||||
pub mod index;
|
||||
pub mod ip_posts;
|
||||
pub mod live;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod news;
|
||||
|
@ -9,7 +9,13 @@ use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
check_page, ctx::Ctx, db::models::{Board, Post}, error::NekrochanError, filters, paginate, web::{tcx::TemplateCtx, template_response}, GENERIC_PAGE_SIZE
|
||||
check_page,
|
||||
ctx::Ctx,
|
||||
db::models::{Board, Post},
|
||||
error::NekrochanError,
|
||||
filters, paginate,
|
||||
web::{tcx::TemplateCtx, template_response},
|
||||
GENERIC_PAGE_SIZE,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -5,6 +5,7 @@ use crate::{
|
||||
ctx::Ctx,
|
||||
db::models::NewsPost,
|
||||
error::NekrochanError,
|
||||
filters,
|
||||
web::{tcx::TemplateCtx, template_response},
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
use actix_web::HttpRequest;
|
||||
use redis::AsyncCommands;
|
||||
use redis::{AsyncCommands, Commands, Connection};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
@ -10,12 +10,12 @@ use crate::{
|
||||
perms::PermissionWrapper,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateCtx {
|
||||
pub cfg: Cfg,
|
||||
pub boards: Vec<String>,
|
||||
pub account: Option<String>,
|
||||
pub perms: PermissionWrapper,
|
||||
pub name: Option<String>,
|
||||
pub ip: IpAddr,
|
||||
pub yous: HashSet<String>,
|
||||
}
|
||||
@ -32,8 +32,6 @@ impl TemplateCtx {
|
||||
None => PermissionWrapper::new(0, false),
|
||||
};
|
||||
|
||||
let name = req.cookie("name").map(|cookie| cookie.value().into());
|
||||
|
||||
let (ip, _) = ip_from_req(req)?;
|
||||
let yous = ctx.cache().zrange(format!("by_ip:{ip}"), 0, -1).await?;
|
||||
|
||||
@ -43,7 +41,6 @@ impl TemplateCtx {
|
||||
cfg,
|
||||
boards,
|
||||
perms,
|
||||
name,
|
||||
ip,
|
||||
yous,
|
||||
account,
|
||||
@ -51,6 +48,11 @@ impl TemplateCtx {
|
||||
|
||||
Ok(tcx)
|
||||
}
|
||||
|
||||
pub fn update_yous(&mut self, cache: &mut Connection) -> Result<(), NekrochanError> {
|
||||
self.yous = cache.zrange(format!("by_ip:{}", self.ip), 0, -1)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn account_from_auth(ctx: &Ctx, req: &HttpRequest) -> Result<Account, NekrochanError> {
|
||||
|
@ -1,4 +1,5 @@
|
||||
$(function () {
|
||||
let name = get_cookie("name");
|
||||
let password = get_cookie("password");
|
||||
|
||||
if (password === "") {
|
||||
@ -6,6 +7,7 @@ $(function () {
|
||||
set_cookie("password", password);
|
||||
}
|
||||
|
||||
$('input[name="name"]').prop("value", name);
|
||||
$('input[name="password"]').prop("value", password);
|
||||
});
|
||||
|
@ -1,23 +1,29 @@
|
||||
$(function () {
|
||||
$(".expandable").click(function () {
|
||||
let src_link = $(this).attr("href");
|
||||
|
||||
let is_video = [".mpeg", ".mov", ".mp4", ".webm", ".mkv", ".ogg"].some(
|
||||
(ext) => src_link.endsWith(ext)
|
||||
);
|
||||
|
||||
if (!is_video) {
|
||||
toggle_image($(this), src_link);
|
||||
} else {
|
||||
toggle_video($(this), src_link);
|
||||
}
|
||||
|
||||
$(this).toggleClass("expanded");
|
||||
|
||||
return false;
|
||||
});
|
||||
update_expandable($(".expandable"));
|
||||
});
|
||||
|
||||
function update_expandable(elements) {
|
||||
elements.each(function() {
|
||||
$(this).click(function() {
|
||||
let src_link = $(this).attr("href");
|
||||
|
||||
let is_video = [".mpeg", ".mov", ".mp4", ".webm", ".mkv", ".ogg"].some(
|
||||
(ext) => src_link.endsWith(ext)
|
||||
);
|
||||
|
||||
if (!is_video) {
|
||||
toggle_image($(this), src_link);
|
||||
} else {
|
||||
toggle_video($(this), src_link);
|
||||
}
|
||||
|
||||
$(this).toggleClass("expanded");
|
||||
|
||||
return false;
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function toggle_image(parent, src_link) {
|
||||
let thumb = parent.find(".thumb");
|
||||
let src = parent.find(".src");
|
64
static/js/live.js
Normální soubor
64
static/js/live.js
Normální soubor
@ -0,0 +1,64 @@
|
||||
$(function () {
|
||||
let main = $(".thread + hr");
|
||||
|
||||
main.after(
|
||||
'<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>'
|
||||
);
|
||||
|
||||
$("#live-indicator").css("background-color", "orange");
|
||||
$("#live-status").text("Připojování...");
|
||||
|
||||
let thread = window.location.pathname.split("/").slice(-2);
|
||||
let protocol;
|
||||
|
||||
if (window.location.protocol === "https:") {
|
||||
protocol = "wss:";
|
||||
} else {
|
||||
protocol = "ws:";
|
||||
}
|
||||
|
||||
let last_post = $(".thread").find(".post").last().attr("id");
|
||||
|
||||
let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`;
|
||||
let ws = new WebSocket(ws_location);
|
||||
|
||||
ws.addEventListener("open", function (_) {
|
||||
$("#live-indicator").css("background-color", "lime");
|
||||
$("#live-status").text("Připojeno pro nové příspěvky");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", function (msg) {
|
||||
let data = JSON.parse(msg.data);
|
||||
|
||||
switch (data.type) {
|
||||
case "created":
|
||||
$(".thread").append(data.html + "<br>");
|
||||
update_expandable($(`#${data.id} .expandable`));
|
||||
update_reltimes($(`#${data.id}`).find("time"));
|
||||
break;
|
||||
case "updated":
|
||||
$(`#${data.id}`).replaceWith(data.html);
|
||||
update_expandable($(`#${data.id} .expandable`));
|
||||
update_reltimes($(`#${data.id}`).find("time"));
|
||||
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;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", function (_) {
|
||||
$("#live-indicator").css("background-color", "red");
|
||||
$("#live-status").text("Odpojeno, obnov stránku");
|
||||
});
|
||||
});
|
112
static/js/time.js
Normální soubor
112
static/js/time.js
Normální soubor
@ -0,0 +1,112 @@
|
||||
$(function () {
|
||||
update_reltimes($("time"));
|
||||
|
||||
setInterval(() => {
|
||||
update_reltimes($("time"));
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
function update_reltimes(elements) {
|
||||
elements.each(function () {
|
||||
let title = $(this).attr("title");
|
||||
|
||||
if (!title) {
|
||||
$(this).prop("title", $(this).text());
|
||||
}
|
||||
|
||||
let rel = reltime($(this).attr("datetime"));
|
||||
|
||||
$(this).text(rel);
|
||||
});
|
||||
}
|
||||
|
||||
const MINUTE = 60000,
|
||||
HOUR = 3600000,
|
||||
DAY = 86400000,
|
||||
WEEK = 604800000,
|
||||
MONTH = 2592000000,
|
||||
YEAR = 31536000000;
|
||||
|
||||
function reltime(date) {
|
||||
let delta = Date.now() - Date.parse(date);
|
||||
let fut = false;
|
||||
|
||||
if (delta < 0) {
|
||||
delta = Math.abs(delta);
|
||||
fut = true;
|
||||
}
|
||||
|
||||
let minutes = Math.floor(delta / MINUTE);
|
||||
let hours = Math.floor(delta / HOUR);
|
||||
let days = Math.floor(delta / DAY);
|
||||
let weeks = Math.floor(delta / WEEK);
|
||||
let months = Math.floor(delta / MONTH);
|
||||
let years = Math.floor(delta / YEAR);
|
||||
|
||||
let rt = "Teď";
|
||||
|
||||
if (minutes > 0) {
|
||||
if (fut) {
|
||||
rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`;
|
||||
} else {
|
||||
rt = `před ${minutes} ${plural("minutou|minutami|minutami", minutes)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
if (fut) {
|
||||
rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`;
|
||||
} else {
|
||||
rt = `před ${hours} ${plural("hodinou|hodinami|hodinami", hours)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (days > 0) {
|
||||
if (fut) {
|
||||
rt = `za ${days} ${plural("den|dny|dnů", days)}`;
|
||||
} else {
|
||||
rt = `před ${days} ${plural("dnem|dny|dny", days)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (weeks > 0) {
|
||||
if (fut) {
|
||||
rt = `za ${weeks} ${plural("týden|týdny", weeks)}`;
|
||||
} else {
|
||||
rt = `před ${weeks} ${plural("týdnem|týdny", weeks)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (months > 0) {
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (years > 0) {
|
||||
if (fut) {
|
||||
rt = `za ${years} ${plural("rok|roky|let", years)}`;
|
||||
} else {
|
||||
rt = `před ${years} ${plural("rokem|lety|lety", years)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return rt;
|
||||
}
|
||||
|
||||
function plural(plurals, count) {
|
||||
let plurals_arr = plurals.split("|");
|
||||
let one = plurals_arr[0];
|
||||
let few = plurals_arr[1];
|
||||
let other = plurals_arr[2];
|
||||
|
||||
if (count === 1) {
|
||||
return one;
|
||||
} else if (count < 5 && count !== 0) {
|
||||
return few;
|
||||
} else {
|
||||
return other;
|
||||
}
|
||||
}
|
@ -18,7 +18,8 @@ a:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
details {
|
||||
details,
|
||||
#live-info {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@ -492,3 +493,11 @@ summary {
|
||||
image-rendering: pixelated;
|
||||
margin: 0px auto;
|
||||
}
|
||||
|
||||
#live-indicator {
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
@ -29,7 +29,7 @@
|
||||
<span>
|
||||
Tvůj ban platí pro IP adresu/rozsah <b>{{ ban.ip_range }}</b> a 
|
||||
{% if let Some(expires) = ban.expires %}
|
||||
vyprší <b>{{ expires|czech_datetime }} ({{ expires|czech_humantime }}).</b>
|
||||
vyprší <time datetime="{{ expires }}">{{ expires|czech_datetime }}.</time>
|
||||
{% else %}
|
||||
je <b>trvalý.</b>
|
||||
{% endif %}
|
||||
|
@ -10,8 +10,10 @@
|
||||
<link rel="stylesheet" href='/static/themes/{% block theme %}{% include "../theme.txt" %}{% endblock %}'>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/js/jquery.min.js"></script>
|
||||
<script src="/static/js/expand-image.js"></script>
|
||||
<script src="/static/js/password.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>
|
||||
{% block scripts %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
@ -22,7 +22,7 @@
|
||||
<h2 class="headline">
|
||||
<span>{{ news.title }}</span>
|
||||
<span class="float-r">
|
||||
{{ news.author }} - <span title="{{ news.created|czech_humantime }}">{{ news.created|czech_datetime }}</span>
|
||||
{{ news.author }} - <time datetime="{{ news.created }}">{{ news.created|czech_datetime }}</time>
|
||||
</span>
|
||||
</h2>
|
||||
<hr>
|
||||
|
@ -13,7 +13,7 @@
|
||||
<tr>
|
||||
<td class="label">Jméno</td>
|
||||
<td>
|
||||
<input name="name" type="text" placeholder="{{ board.config.0.anon_name }}"{% if let Some(name) = tcx.name %}value="{{ name }}"{% endif %}>
|
||||
<input name="name" type="text" placeholder="{{ board.config.0.anon_name }}">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -23,7 +23,7 @@
|
||||
{% if board.config.0.flags %}
|
||||
<img title="Země: {{ post.country }}" class="icon" src="/static/flags/{{ post.country }}.png"> 
|
||||
{% endif %}
|
||||
<span title="{{ post.created|czech_datetime }}">{{ post.created|czech_humantime }}</span> 
|
||||
<time datetime="{{ post.created }}">{{ post.created|czech_datetime }}</time> 
|
||||
{% if board.config.0.user_ids %}
|
||||
<span class="user-id" style="background-color: #{{ post.user_id }};">{{ post.user_id }}</span> 
|
||||
{% endif %}
|
||||
|
@ -10,7 +10,7 @@
|
||||
<h2 class="headline">
|
||||
<span>{{ newspost.title }}</span>
|
||||
<span class="float-r">
|
||||
{{ newspost.author }} - <span title="{{ newspost.created|czech_humantime }}">{{ newspost.created|czech_datetime }}</span>
|
||||
{{ newspost.author }} - <time datetime="{{ newspost.created }}">{{ newspost.created|czech_datetime }}</time>
|
||||
</span>
|
||||
</h2>
|
||||
<hr>
|
||||
|
@ -24,7 +24,7 @@
|
||||
<td><input name="accounts[]" type="checkbox" value="{{ account.username }}" {% if !tcx.perms.owner() %}disabled=""{% endif %}></td>
|
||||
<td>{{ account.username }}</td>
|
||||
<td>{% if account.owner %}Ano{% else %}Ne{% endif %}</td>
|
||||
<td title="{{ account.created|czech_humantime }}">{{ account.created|czech_datetime }}</td>
|
||||
<td><time datetime="{{ account.created }}">{{ account.created|czech_datetime }}</time></td>
|
||||
<td>{{ account.permissions.0 }} <a href="/staff/permissions/{{ account.username }}">[Zobrazit]</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -38,9 +38,9 @@
|
||||
<td>{{ ban.issued_by }}</td>
|
||||
<td>{% if ban.appealable %}Ano{% else %}Ne{% endif %}</td>
|
||||
<td>{% if let Some(appeal) = ban.appeal %}<div class="post-content">{{ appeal }}</div>{% else %}-{% endif %}</td>
|
||||
<td title="ban.{{ ban.created|czech_humantime }}">{{ ban.created|czech_datetime }}</td>
|
||||
<td><time datetime="{{ ban.created }}">{{ ban.created|czech_datetime }}</time></td>
|
||||
{% if let Some(expires) = ban.expires %}
|
||||
<td title="{{ expires|czech_humantime }}">{{ expires|czech_datetime }}</td>
|
||||
<td><time datetime="{{ expires }}">{{ expires|czech_datetime }}</time></td>
|
||||
{% else %}
|
||||
<td>Nikdy</td>
|
||||
{% endif %}
|
||||
|
@ -26,7 +26,7 @@
|
||||
<td>/{{ board.id }}/</td>
|
||||
<td>{{ board.name }}</td>
|
||||
<td>{{ board.description }}</td>
|
||||
<td title="{{ board.created|czech_humantime }}">{{ board.created|czech_datetime }}</td>
|
||||
<td><time datetime="{{ board.created }}">{{ board.created|czech_datetime }}</time></td>
|
||||
<td>{% if tcx.perms.owner() || tcx.perms.board_config() %}<a href="/staff/board-config/{{ board.id }}">[Zobrazit]</a>{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -12,7 +12,7 @@
|
||||
<h2 class="headline">
|
||||
<span>{{ newspost.title }}</span>
|
||||
<span class="float-r">
|
||||
{{ newspost.author }} - <span title="{{ newspost.created|czech_humantime }}">{{ newspost.created|czech_datetime }}</span>
|
||||
{{ newspost.author }} - <time datetime="{{ newspost.created }}">{{ newspost.created|czech_datetime }}</time>
|
||||
</span>
|
||||
</h2>
|
||||
<hr>
|
||||
|
@ -23,7 +23,7 @@
|
||||
<td><input name="news[]" type="checkbox" value="{{ newspost.id }}"></td>
|
||||
<td>{{ newspost.title }}</td>
|
||||
<td>{{ newspost.author }}</td>
|
||||
<td>{{ newspost.created }}</td>
|
||||
<td><time datetime="{{ newspost.created }}">{{ newspost.created|czech_datetime }}</time></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
@ -6,7 +6,11 @@
|
||||
|
||||
{% block theme %}{{ board.config.0.board_theme }}{% endblock %}
|
||||
{% block title %}/{{ board.id }}/ - {{ thread.content_nomarkup|inline_post }}{% endblock %}
|
||||
{% block scripts %}<script src="/static/js/captcha.js"></script>{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/captcha.js"></script>
|
||||
<script src="/static/js/live.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
|
Načítá se…
Odkázat v novém úkolu
Zablokovat Uživatele