Files
5ducky/src/inbox.rs
T
kibiandClaude Opus 5.5 d0f2d526df 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>
2026-10-17 02:15:05 +02:00

207 lines
6.8 KiB
Rust

//! 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())
}