forked from 4ducky/4ducky
Port 4ducky to Rust (Axum + SQLite)
Same design, layout, URLs and features as the PHP version: browse grid, image view with AJAX comments, BBCode, likes, uploads (file, URL and bookmarklet), quicktagger, profile, block list, inbox, invites, Hall of Ducks, Truhe, abuse reports and the admin tools. - Parameterized SQL, escaped templates (askama), argon2 passwords, server-side sessions, CSRF tokens on every write, strict script CSP - SSRF-safe remote image fetching, magic-byte upload checks - Self-hosted captcha replaces reCAPTCHA v1; analytics/social widgets removed - Vanilla JS replaces jQuery and its plugins with the same DOM behaviour - Reverse proxy support (frontend_path, trust_proxy, secure_cookies) - Admin CLI: admin, settings, invites See README.md for running it and the full list of changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
//! Private messages (templates/inbox.tpl.php + inboxuser.tpl.php).
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
app::{AppResult, AppState, Ctx, Inner},
|
||||
browse::{blocked_ids, render},
|
||||
layout::Layout,
|
||||
util,
|
||||
};
|
||||
|
||||
struct Conversation {
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: Option<String>,
|
||||
title: String,
|
||||
border: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "inbox.html")]
|
||||
struct InboxPage {
|
||||
l: Layout,
|
||||
user_not_found: bool,
|
||||
user_blocked: bool,
|
||||
convos: Vec<Conversation>,
|
||||
}
|
||||
|
||||
struct Message {
|
||||
from_name: String,
|
||||
from_enc: String,
|
||||
date: String,
|
||||
body: String,
|
||||
border: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "inboxuser.html")]
|
||||
struct ConversationPage {
|
||||
l: Layout,
|
||||
border: &'static str,
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ConvoRow {
|
||||
id: i64,
|
||||
name: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: String,
|
||||
title: Option<String>,
|
||||
unread: i64,
|
||||
}
|
||||
|
||||
async fn inbox_page(st: &Inner, ctx: &Ctx, user_not_found: bool, user_blocked: bool) -> AppResult {
|
||||
let uid = ctx.uid();
|
||||
let rows: Vec<ConvoRow> = sqlx::query_as(
|
||||
"SELECT u.id, u.name, u.avatar, u.images, u.website, \
|
||||
(SELECT title_m FROM messages WHERE from_m = m.from_m AND to_m = ?1 ORDER BY created DESC, id DESC LIMIT 1) AS title, \
|
||||
(SELECT COUNT(*) FROM messages WHERE from_m = m.from_m AND to_m = ?1 AND read_m = 0) AS unread \
|
||||
FROM messages m JOIN users u ON u.id = m.from_m WHERE m.to_m = ?1 \
|
||||
GROUP BY m.from_m ORDER BY MAX(m.created) DESC",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let blocked = blocked_ids(st, uid).await?;
|
||||
let read_border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px solid #333;" };
|
||||
let convos = rows
|
||||
.into_iter()
|
||||
.filter(|r| !blocked.contains(&r.id))
|
||||
.map(|r| {
|
||||
let title = r.title.unwrap_or_default();
|
||||
Conversation {
|
||||
id: r.id,
|
||||
name_enc: util::path_enc(&r.name),
|
||||
name: r.name,
|
||||
avatar: r.avatar,
|
||||
images: r.images,
|
||||
website: util::safe_website(&r.website),
|
||||
title: if title.chars().count() > 1 { util::autolink_text(&util::strip_tags(&title)) } else { String::new() },
|
||||
border: if r.unread > 0 { " border:1px solid #FFD600; " } else { read_border },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render(InboxPage { l: Layout::new(st, ctx).await?, user_not_found, user_blocked, convos })
|
||||
}
|
||||
|
||||
pub async fn inbox(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if ctx.user.is_none() {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
inbox_page(&st, &ctx, false, false).await
|
||||
}
|
||||
|
||||
pub async fn conversation(State(st): State<AppState>, ctx: Ctx, Path(name): Path<String>) -> AppResult {
|
||||
let Some(me) = &ctx.user else { return Ok(Redirect::to("/login").into_response()) };
|
||||
if !util::valid_username(&name) {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
let other: Option<(i64, String, String, i64, String)> =
|
||||
sqlx::query_as("SELECT id, name, avatar, images, website FROM users WHERE name = ?")
|
||||
.bind(&name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((oid, oname, avatar, images, website)) = other else { return Ok(Redirect::to("/").into_response()) };
|
||||
|
||||
let rows: Vec<(i64, i64, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.from_m, m.to_m, m.message_m, m.read_m, m.created, u.name FROM messages m LEFT JOIN users u ON u.id = m.from_m \
|
||||
WHERE (m.to_m = ?1 AND m.from_m = ?2) OR (m.to_m = ?2 AND m.from_m = ?1) ORDER BY m.created DESC, m.id DESC",
|
||||
)
|
||||
.bind(oid)
|
||||
.bind(me.id)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE messages SET read_m = 1 WHERE to_m = ? AND from_m = ?")
|
||||
.bind(me.id)
|
||||
.bind(oid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
|
||||
let border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px dotted #333;" };
|
||||
let messages = rows
|
||||
.into_iter()
|
||||
.map(|(_, to, body, read, created, from_name)| {
|
||||
let from_name = from_name.unwrap_or_default();
|
||||
Message {
|
||||
from_enc: util::path_enc(&from_name),
|
||||
from_name,
|
||||
date: util::date("d. M Y H:i", created),
|
||||
body: util::autolink_text(&body),
|
||||
border: if read == 0 && to == me.id { "border:1px solid #FFD600".into() } else { border.into() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render(ConversationPage {
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
border,
|
||||
id: oid,
|
||||
name_enc: util::path_enc(&oname),
|
||||
name: oname,
|
||||
avatar,
|
||||
images,
|
||||
website: util::safe_website(&website),
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct MessageForm {
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
#[serde(rename = "_csrf")]
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
pub async fn send(State(st): State<AppState>, ctx: Ctx, Path(name): Path<String>, axum::Form(f): axum::Form<MessageForm>) -> AppResult {
|
||||
let Some(me) = ctx.user.clone() else { return Ok(Redirect::to("/login").into_response()) };
|
||||
ctx.check_csrf(&f.csrf)?;
|
||||
if !util::valid_username(&name) {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
let target: Option<(i64, String)> = sqlx::query_as("SELECT id, name FROM users WHERE name = ?")
|
||||
.bind(&name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((tid, tname)) = target else { return inbox_page(&st, &ctx, true, false).await };
|
||||
let blocked_me: Option<i64> = sqlx::query_scalar("SELECT 1 FROM blocked WHERE user_id = ? AND blocked_id = ?")
|
||||
.bind(tid)
|
||||
.bind(me.id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if blocked_me.is_some() {
|
||||
return inbox_page(&st, &ctx, false, true).await;
|
||||
}
|
||||
let content = f.content.unwrap_or_default();
|
||||
let title = f.title.unwrap_or_default();
|
||||
if !content.trim().is_empty() || !title.trim().is_empty() {
|
||||
st.limiter.check("message", &me.id.to_string(), 20, 60)?;
|
||||
sqlx::query("INSERT INTO messages (from_m, to_m, created, message_m, title_m) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(me.id)
|
||||
.bind(tid)
|
||||
.bind(util::now())
|
||||
.bind(content.chars().take(20_000).collect::<String>())
|
||||
.bind(title.chars().take(500).collect::<String>())
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(Redirect::to(&format!("/inbox/{}", util::path_enc(&tname))).into_response())
|
||||
}
|
||||
Reference in New Issue
Block a user