nekrochan/src/db/post.rs

595 řádky
16 KiB
Rust
Spustitelný soubor

use chrono::Utc;
use redis::AsyncCommands;
use sha256::digest;
use sqlx::{query, query_as, types::Json};
use std::net::IpAddr;
use super::models::{Board, File, Post, Report};
use crate::{
ctx::Ctx,
error::NekrochanError,
live_hub::{PostCreatedMessage, PostRemovedMessage, PostUpdatedMessage},
GENERIC_PAGE_SIZE,
};
impl Post {
#[allow(clippy::too_many_arguments)]
pub async fn create(
ctx: &Ctx,
board: &Board,
thread: Option<i64>,
name: String,
tripcode: Option<String>,
capcode: Option<String>,
email: Option<String>,
content: String,
content_nomarkup: String,
files: Vec<File>,
password: String,
country: String,
ip: IpAddr,
bump: bool,
) -> Result<Self, NekrochanError> {
let post: Post = query_as(&format!(
r#"INSERT INTO posts_{}
(thread, name, tripcode, capcode, email, content, content_nomarkup, files, password, country, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"#, board.id)
)
.bind(thread)
.bind(name)
.bind(tripcode)
.bind(capcode)
.bind(email)
.bind(content)
.bind(content_nomarkup)
.bind(Json(files))
.bind(password)
.bind(country)
.bind(ip)
.fetch_one(ctx.db())
.await?;
if let Some(thread) = thread {
query(&format!(
"UPDATE posts_{} SET replies = replies + 1 WHERE id = $1",
board.id
))
.bind(thread)
.execute(ctx.db())
.await?;
if bump {
query(&format!(
"UPDATE posts_{} SET bumps = bumps + 1, bumped = CURRENT_TIMESTAMP WHERE id = $1",
board.id
))
.bind(thread)
.execute(ctx.db())
.await?;
}
} else {
delete_old_threads(ctx, board).await?;
ctx.cache().incr("total_threads", 1).await?;
ctx.cache()
.incr(format!("board_threads:{}", board.id), 1)
.await?;
}
let ip_key = format!("by_ip:{ip}");
let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", board.id, post.id);
let score = post.created.timestamp_micros();
ctx.cache().zadd(ip_key, &member, score).await?;
ctx.cache().zadd(content_key, &member, score).await?;
if thread.is_none() {
ctx.cache()
.set(format!("last_thread:{ip}"), post.created.timestamp_micros())
.await?;
}
Ok(post)
}
pub async fn create_report(
&self,
ctx: &Ctx,
reason: String,
reporter_country: String,
reporter_ip: IpAddr,
) -> Result<(), NekrochanError> {
let mut reports = self.reports.clone();
reports.push(Report {
reason,
reporter_country,
reporter_ip,
});
query(&format!(
"UPDATE posts_{} SET reported = CURRENT_TIMESTAMP, reports = $1 WHERE id = $2",
self.board
))
.bind(reports)
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> {
let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board))
.bind(id)
.fetch_optional(ctx.db())
.await?;
Ok(post)
}
pub async fn read_board_page(
ctx: &Ctx,
board: &Board,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
r#"SELECT * FROM posts_{}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC
LIMIT $1
OFFSET $2"#,
board.id
))
.bind(board.config.0.page_size)
.bind((page - 1) * board.config.0.page_size)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_board_catalog(ctx: &Ctx, board: String) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
r#"SELECT * FROM posts_{board}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC"#
))
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_overboard_page(ctx: &Ctx, page: i64) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE thread IS NULL
ORDER BY bumped DESC
LIMIT $1
OFFSET $2"#,
)
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_overboard_catalog(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE thread IS NULL
ORDER BY bumped DESC"#,
)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_reports_page(ctx: &Ctx, page: i64) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE reports != '[]'::jsonb
ORDER BY jsonb_array_length(reports), reported DESC
LIMIT $1
OFFSET $2"#,
)
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_ip_page(
ctx: &Ctx,
ip: IpAddr,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(
r#"SELECT * FROM overboard
WHERE ip = $1
ORDER BY created DESC
LIMIT $2
OFFSET $3"#,
)
.bind(ip)
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_replies(&self, ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let replies = query_as(&format!(
"SELECT * FROM posts_{} WHERE thread = $1 ORDER BY sticky DESC, created ASC",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
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())
.await?;
Ok(posts)
}
pub async fn read_all_overboard(ctx: &Ctx) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as("SELECT * FROM overboard")
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_by_query(
ctx: &Ctx,
board: &Board,
query: String,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts = query_as(&format!(
"SELECT * FROM posts_{} WHERE LOWER(content_nomarkup) LIKE LOWER($1) LIMIT $2 OFFSET $3",
board.id
))
.bind(format!("%{query}%"))
.bind(board.config.0.page_size)
.bind((page - 1) * board.config.0.page_size)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn read_by_query_overboard(
ctx: &Ctx,
query: String,
page: i64,
) -> Result<Vec<Self>, NekrochanError> {
let posts =
query_as("SELECT * FROM overboard WHERE LOWER(content_nomarkup) LIKE LOWER($1) LIMIT $2 OFFSET $3")
.bind(format!("%{query}%"))
.bind(GENERIC_PAGE_SIZE)
.bind((page - 1) * GENERIC_PAGE_SIZE)
.fetch_all(ctx.db())
.await?;
Ok(posts)
}
pub async fn update_user_id(&self, ctx: &Ctx, user_id: String) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET user_id = $1 WHERE id = $2 RETURNING *",
self.board,
))
.bind(user_id)
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostCreatedMessage { post }).await?;
Ok(())
}
pub async fn update_sticky(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET sticky = NOT sticky WHERE id = $1 RETURNING *",
self.board
))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_lock(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET locked = NOT locked WHERE id = $1 RETURNING *",
self.board
))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_content(
&self,
ctx: &Ctx,
content: String,
content_nomarkup: String,
) -> Result<(), NekrochanError> {
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)
.fetch_optional(ctx.db())
.await?;
let Some(post) = post else { return Ok(()) };
let old_key = format!(
"by_content:{}",
digest(self.content_nomarkup.to_lowercase())
);
let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase()));
let member = format!("{}/{}", self.board, self.id);
let score = Utc::now().timestamp_micros();
ctx.cache().zrem(old_key, &member).await?;
ctx.cache().zadd(new_key, &member, score).await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_quotes(&self, ctx: &Ctx, id: i64) -> Result<(), NekrochanError> {
let post = query_as(&format!(
"UPDATE posts_{} SET quotes = array_append(quotes, $1) WHERE id = $2 RETURNING *",
self.board
))
.bind(id)
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let mut files = self.files.clone();
for file in files.iter_mut() {
file.spoiler = !file.spoiler;
}
let post = query_as(&format!(
"UPDATE posts_{} SET files = $1 WHERE id = $2 RETURNING *",
self.board
))
.bind(Json(files))
.bind(self.id)
.fetch_one(ctx.db())
.await?;
ctx.hub().send(PostUpdatedMessage { post }).await?;
Ok(())
}
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
let to_be_deleted: Vec<Post> = query_as(&format!(
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1 ORDER BY id ASC",
self.board
))
.bind(self.id)
.fetch_all(ctx.db())
.await?;
for post in &to_be_deleted {
for file in post.files.iter() {
file.delete().await;
}
let id = post.id;
let url = post.post_url();
let live_quote = format!("<a class=\"quote\" href=\"{url}\">&gt;&gt;{id}</a>");
let dead_quote = format!("<span class=\"dead-quote\">&gt;&gt;{id}</span>");
let posts = query_as(&format!(
"UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *",
self.board, live_quote
))
.bind(live_quote)
.bind(dead_quote)
.fetch_all(ctx.db())
.await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let posts = query_as(&format!(
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes) RETURNING *",
self.board
))
.bind(id)
.fetch_all(ctx.db())
.await?;
for post in posts {
ctx.hub().send(PostUpdatedMessage { post }).await?;
}
let ip_key = format!("by_ip:{}", post.ip);
let content_key = format!(
"by_content:{}",
digest(post.content_nomarkup.to_lowercase())
);
let member = format!("{}/{}", post.board, post.id);
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
.iter()
.map(|post| (post.id))
.collect::<Vec<_>>();
query(&format!(
"DELETE FROM posts_{} WHERE id = ANY($1)",
self.board
))
.bind(&in_list)
.execute(ctx.db())
.await?;
if let Some(thread) = self.thread {
query(&format!(
"UPDATE posts_{} SET replies = replies - 1 WHERE id = $1",
self.board
))
.bind(thread)
.execute(ctx.db())
.await?;
} else {
ctx.cache().decr("total_threads", 1).await?;
ctx.cache()
.decr(format!("board_threads:{}", self.board), 1)
.await?;
}
Ok(())
}
pub async fn delete_files(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
for file in &self.files.0 {
file.delete().await;
}
query(&format!(
"UPDATE posts_{} SET files = '[]'::jsonb WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
ctx.hub()
.send(PostUpdatedMessage { post: self.clone() })
.await?;
Ok(())
}
pub async fn delete_reports(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
query(&format!(
"UPDATE posts_{} SET reported = NULL, reports = '[]'::jsonb WHERE id = $1",
self.board
))
.bind(self.id)
.execute(ctx.db())
.await?;
Ok(())
}
}
impl Post {
pub fn post_url(&self) -> String {
format!(
"/boards/{}/{}#{}",
self.board,
self.thread.unwrap_or(self.id),
self.id
)
}
pub fn post_url_notarget(&self) -> String {
format!("/boards/{}/{}", self.board, self.id)
}
pub fn thread_url(&self) -> String {
format!("/boards/{}/{}", self.board, self.thread.unwrap_or(self.id),)
}
}
async fn delete_old_threads(ctx: &Ctx, board: &Board) -> Result<(), NekrochanError> {
let old_threads: Vec<Post> = query_as(&format!(
r#"SELECT * FROM posts_{}
WHERE thread IS NULL AND id NOT IN (
SELECT id
FROM (
SELECT id
FROM posts_{}
WHERE thread IS NULL
ORDER BY sticky DESC, bumped DESC
LIMIT $1
) catty
)"#,
board.id, board.id
))
.bind(board.config.0.page_size * board.config.0.page_count)
.fetch_all(ctx.db())
.await?;
for thread in &old_threads {
thread.delete(ctx).await?;
}
Ok(())
}