Skript na živé aktualizace vláken + relativní časy na frontendu

Tento commit je obsažen v:
sneedmaster 2024-02-25 17:26:39 +01:00
rodič cc6921d4ed
revize 3cce9a32e1
41 změnil soubory, kde provedl 888 přidání a 185 odebrání

82
Cargo.lock vygenerováno
Zobrazit soubor

@ -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"

Zobrazit soubor

@ -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"

Zobrazit soubor

@ -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,

Zobrazit soubor

@ -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()
}
}

Zobrazit soubor

@ -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?;

Zobrazit soubor

@ -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,

Zobrazit soubor

@ -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(())
}

Zobrazit soubor

@ -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,

Zobrazit soubor

@ -11,62 +11,6 @@ lazy_static! {
Regex::new(r#"<a class="quote" href=".+">&gt;&gt;(\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());

Zobrazit soubor

@ -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
Zobrazit 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
Zobrazit 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();
}
}

Zobrazit soubor

@ -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)

Zobrazit soubor

@ -23,6 +23,7 @@ pub enum Permissions {
BypassAntispam,
}
#[derive(Debug, Clone)]
pub struct PermissionWrapper(BitFlags<Permissions>, bool);
impl PermissionWrapper {

Zobrazit soubor

@ -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?;

Zobrazit soubor

@ -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);
}

Zobrazit soubor

@ -125,10 +125,14 @@ pub async fn staff_post_actions(
}
if form.toggle_lock.is_some() {
if post.thread.is_some() {
writeln!(&mut response, "[Chyba] Odpověď nelze uzamknout.").ok();
} else {
post.update_lock(&ctx).await?;
locks_toggled += 1;
}
}
}
for post in &posts {
if form.remove_reports.is_some() {
@ -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
Zobrazit 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)
}

Zobrazit soubor

@ -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;

Zobrazit soubor

@ -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)]

Zobrazit soubor

@ -5,6 +5,7 @@ use crate::{
ctx::Ctx,
db::models::NewsPost,
error::NekrochanError,
filters,
web::{tcx::TemplateCtx, template_response},
};

Zobrazit soubor

@ -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> {

Zobrazit soubor

@ -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);
});

Zobrazit soubor

@ -1,5 +1,10 @@
$(function () {
$(".expandable").click(function () {
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(
@ -15,8 +20,9 @@ $(function () {
$(this).toggleClass("expanded");
return false;
});
});
})
})
}
function toggle_image(parent, src_link) {
let thumb = parent.find(".thumb");

64
static/js/live.js Normální soubor
Zobrazit 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
Zobrazit 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;
}
}

Zobrazit soubor

@ -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%;
}

Zobrazit soubor

@ -29,7 +29,7 @@
<span>
Tvůj ban platí pro IP adresu/rozsah <b>{{ ban.ip_range }}</b> a&#32;
{% 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 %}

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -23,7 +23,7 @@
{% if board.config.0.flags %}
<img title="Země: {{ post.country }}" class="icon" src="/static/flags/{{ post.country }}.png">&#32;
{% endif %}
<span title="{{ post.created|czech_datetime }}">{{ post.created|czech_humantime }}</span>&#32;
<time datetime="{{ post.created }}">{{ post.created|czech_datetime }}</time>&#32;
{% if board.config.0.user_ids %}
<span class="user-id" style="background-color: #{{ post.user_id }};">{{ post.user_id }}</span>&#32;
{% endif %}

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -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 %}

Zobrazit soubor

@ -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 %}

Zobrazit soubor

@ -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 %}

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -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>

Zobrazit soubor

@ -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">