//! Chat routes: the room page, the SSE stream, sending, and moderation. //! //! Every handler starts the same way, by resolving the slug to a `Room` through //! [`MtChatRooms`] and refusing anything that is not open. That is deliberate //! repetition of one call, not a copied predicate: the room carries the state //! and the retention, and each handler's gates read it rather than re-deriving //! community facts. See `crate::chat::authz` for why that shape is what keeps //! chat out of the C1 authz chronic. //! //! # Why `off` 404s rather than 403s //! //! A community with chat off has no route, no hub room and no affordance. A 403 //! would confirm the room exists and is merely shut, which is a different //! statement than the design's "off must be total". A closed room and an absent //! community are the same reply. use axum::{ Form, Json, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; use livechat::{ ChatAuthz, ChatIdentity, ChatRooms, DenyReason, MessageId, Room, RoomState, SendRejection, SendRequest, UserId, }; use serde::{Deserialize, Serialize}; use crate::AppState; use crate::auth::{MaybeUser, RequireUser, SessionUser}; use crate::chat::{ authz::MtChatAuthz, identity::MtChatIdentity, moderation::MtChatModeration, rooms::MtChatRooms, }; use crate::templates::ChatTemplate; use super::{db_error, get_user_by_username, template_user}; /// Backlog handed to a client with no cursor. /// /// A screenful and some scroll, not the whole retention window: a room holds up /// to `chat_max_messages` and shipping that on every page load would be a large /// response for content nobody scrolls back through. const INITIAL_BACKLOG: i64 = 50; /// Ceiling on one replay reply. /// /// A client away longer than this walks forward a page at a time, because its /// cursor advances with each batch. Without the ceiling, a client that has been /// gone since before the retention window could ask the server to materialize /// every message in the room in one query. const MAX_REPLAY: i64 = 200; // Resolution and gating /// Resolve the slug to an open-or-read-only room, or 404. async fn open_room(state: &AppState, slug: &str) -> Result { let room = MtChatRooms::new(state.db.clone()) .resolve(slug) .await .map_err(|e| chat_error(&e))? .ok_or_else(crate::error_page::not_found)?; if room.state == RoomState::Closed { return Err(crate::error_page::not_found()); } Ok(room) } /// Build this request's authz gate from the session. /// /// The policy is re-read rather than carried on `Room`, which holds only the /// state the crate needs. One extra cheap read per request, and it keeps the /// crate's `Room` free of a Multithreaded concept. async fn gate( state: &AppState, slug: &str, user: Option<&SessionUser>, ) -> Result { let policy = mt_db::queries::get_chat_room_by_slug(&state.db, slug) .await .map_err(db_error)? .map(|row| row.policy) .ok_or_else(crate::error_page::not_found)?; Ok(MtChatAuthz::new( state.db.clone(), policy, user.map(|u| u.user_id), user.map(|u| u.perks.clone()).unwrap_or_default(), )) } /// A crate-side failure. Every one of these is a host fault by the time it /// reaches a handler: refusals come back as values, not errors. fn chat_error(err: &livechat::ChatError) -> Response { tracing::error!(error = ?err, "chat error"); crate::error_page::internal_error() } /// Refuse a reader who may not see the room, as a 404 rather than a 403. async fn require_read( authz: &MtChatAuthz, room: &Room, viewer: Option, ) -> Result<(), Response> { let allowed = authz .can_read(viewer, room) .await .map_err(|e| chat_error(&e))?; if allowed { Ok(()) } else { Err(crate::error_page::not_found()) } } /// Load a backlog window and fill in each author for display. async fn backlog( state: &AppState, room: &Room, after: Option, ) -> Result, livechat::ChatError> { let rows = match after { Some(MessageId(cursor)) => { mt_db::queries::backlog_after(&state.db, room.id.0, cursor, MAX_REPLAY).await } None => mt_db::queries::recent_backlog(&state.db, room.id.0, INITIAL_BACKLOG).await, } .map_err(livechat::ChatError::host)?; let mut messages: Vec<_> = rows .into_iter() .map(|r| livechat::Message { id: MessageId(r.id), room_id: room.id, author_id: UserId(r.author_id), body_html: r.body_html, created_at: r.created_at, nonce: None, author: None, }) .collect(); MtChatIdentity::new(state.db.clone(), state.chat_identities.clone()) .attach(&mut messages) .await?; Ok(messages) } // The room page #[tracing::instrument(skip_all)] pub(super) async fn chat_page( State(state): State, Path(slug): Path, MaybeUser(user): MaybeUser, session: tower_sessions::Session, ) -> Result { let room = open_room(&state, &slug).await?; let authz = gate(&state, &slug, user.as_ref()).await?; let viewer = user.as_ref().map(|u| UserId(u.user_id)); require_read(&authz, &room, viewer).await?; let community = super::get_community(&state.db, &slug).await?; // Whether to render the composer at all. A read-only room, a logged-out // visitor, and a member without the tier all read the same room and none of // them can type in it, so the answer is one question asked once here rather // than three conditions in the template. let can_send = match (&user, room.state) { (Some(u), RoomState::Open) => authz .can_write(UserId(u.user_id), &room) .await .map_err(|e| chat_error(&e))? .is_allowed(), _ => false, }; let is_moderator = match &user { Some(u) => authz .is_moderator(UserId(u.user_id), &room) .await .map_err(|e| chat_error(&e))?, None => false, }; let messages = backlog(&state, &room, None) .await .map_err(|e| chat_error(&e))?; Ok(ChatTemplate { csrf_token: Some(crate::csrf::get_or_create_token(&session).await?), session_user: user .as_ref() .map(|u| template_user(u, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, read_only: room.state == RoomState::ReadOnly, can_send, is_moderator, viewer_id: user.as_ref().map(|u| u.user_id.to_string()), max_message_len: livechat::MAX_MESSAGE_LEN, messages: messages.iter().map(chat_message_row).collect(), cursor: messages.last().map_or(0, |m| m.id.0), } .into_response()) } /// One rendered message, for the server-rendered first paint. fn chat_message_row(m: &livechat::Message) -> crate::templates::ChatMessageRow { crate::templates::ChatMessageRow { id: m.id.0, author_id: m.author_id.0.to_string(), author_name: m .author .as_ref() .map_or_else(|| "Unknown".to_owned(), |a| a.display_name.clone()), avatar_url: m.author.as_ref().and_then(|a| a.avatar_url.clone()), body_html: m.body_html.clone(), created_at: m.created_at, } } // The stream #[derive(Deserialize)] pub(super) struct StreamQuery { /// Last message id the client holds. Absent on a first connection. after: Option, } #[tracing::instrument(skip_all)] pub(super) async fn chat_stream( State(state): State, Path(slug): Path, Query(query): Query, MaybeUser(user): MaybeUser, ) -> Result { let room = open_room(&state, &slug).await?; let authz = gate(&state, &slug, user.as_ref()).await?; let viewer = user.as_ref().map(|u| UserId(u.user_id)); require_read(&authz, &room, viewer).await?; // A logged-out reader still occupies a connection slot, so it needs an // identity for the per-user cap. The room id stands in: anonymous readers of // one room share a budget rather than being unbounded, which is the correct // answer when the alternative is a per-IP cap that a proxy collapses anyway. let listener = viewer.unwrap_or(UserId(room.id.0)); let stream = state .chat .subscribe(room.id, listener, query.after.map(MessageId), |after| { let state = state.clone(); async move { backlog(&state, &room, after).await } }) .await .map_err(|e| match e { // At capacity is a resource refusal, not an authz one: the caller // already passed the read gate. 503 with a Retry-After, so a // reconnecting client backs off instead of hammering. livechat::ChatError::ConnectionLimit(scope) => { tracing::warn!(%scope, "chat connection limit reached"); ( StatusCode::SERVICE_UNAVAILABLE, [("Retry-After", "5")], "Too many chat connections. Try again shortly.", ) .into_response() } other => chat_error(&other), })?; Ok(livechat::sse_response(stream).into_response()) } // Sending #[derive(Deserialize)] pub(super) struct SendForm { body: String, /// Client-generated, so the sender's optimistically rendered message /// reconciles instead of appearing twice. nonce: Option, } #[derive(Serialize)] pub(super) struct SendResponse { id: i64, #[serde(skip_serializing_if = "Option::is_none")] nonce: Option, } #[tracing::instrument(skip_all)] pub(super) async fn chat_send( State(state): State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result, Response> { let room = open_room(&state, &slug).await?; let authz = gate(&state, &slug, Some(&user)).await?; let nonce = form.nonce.as_deref().and_then(livechat::Nonce::parse); let author = UserId(user.user_id); let retention_hours = i32::try_from(room.retention.max_age().as_secs() / 3600).unwrap_or(168); let sent = state .chat .send( &authz, SendRequest { room: &room, author, body: &form.body, nonce: nonce.clone(), now: std::time::Instant::now(), }, |body| { let state = state.clone(); async move { // Rendered here, once, through docengine's chat preset. // The crate never sees markdown and never renders: keeping // sanitization in one place is the whole reason the store // closure exists. let html = docengine::render_chat(body.as_str()); let stored = mt_db::mutations::insert_chat_message( &state.db, room.id.0, author.0, &html, retention_hours, ) .await .map_err(livechat::ChatError::host)?; let mut message = livechat::Message { id: MessageId(stored.id), room_id: room.id, author_id: author, body_html: html, created_at: stored.created_at, nonce: None, author: None, }; // Attach the author before the message is published, so // every listener gets a renderable frame rather than an id // they have to resolve themselves. MtChatIdentity::new(state.db.clone(), state.chat_identities.clone()) .attach(std::slice::from_mut(&mut message)) .await?; Ok(message) } }, ) .await .map_err(|e| send_error(&e))?; Ok(Json(SendResponse { id: sent.id.0, nonce: nonce.map(|n| n.0), })) } /// Turn a refusal into the status a client can act on. /// /// Refusals are expected traffic and must not be logged as faults; only the /// host-error arm is a real problem. fn send_error(err: &livechat::ChatError) -> Response { let livechat::ChatError::Rejected(rejection) = err else { return chat_error(err); }; match rejection { SendRejection::Invalid(message) => (StatusCode::UNPROCESSABLE_ENTITY, message.clone()), SendRejection::RoomClosed => ( StatusCode::NOT_FOUND, "This room is not available.".to_owned(), ), SendRejection::RoomReadOnly => ( StatusCode::FORBIDDEN, "This community is read-only.".to_owned(), ), SendRejection::Denied(reason) => return deny_response(reason), } .into_response() } fn deny_response(reason: &DenyReason) -> Response { match reason { DenyReason::RateLimited { retry_after } => ( StatusCode::TOO_MANY_REQUESTS, [("Retry-After", retry_after.as_secs().max(1).to_string())], "You are sending messages too quickly.", ) .into_response(), DenyReason::Anonymous => (StatusCode::UNAUTHORIZED, "Sign in to chat.").into_response(), DenyReason::NotAMember => { (StatusCode::FORBIDDEN, "Join this community to chat in it.").into_response() } DenyReason::Banned => { (StatusCode::FORBIDDEN, "You are banned from this community.").into_response() } DenyReason::Muted => { (StatusCode::FORBIDDEN, "You are muted in this community.").into_response() } DenyReason::Suspended => { (StatusCode::FORBIDDEN, "Your account has been suspended.").into_response() } DenyReason::TierRequired => ( StatusCode::FORBIDDEN, "Chat here is limited to Fan+ supporters.", ) .into_response(), } } // Moderation #[tracing::instrument(skip_all)] pub(super) async fn chat_delete_message( State(state): State, Path((slug, message_id)): Path<(String, i64)>, RequireUser(user): RequireUser, ) -> Result { let room = open_room(&state, &slug).await?; let authz = gate(&state, &slug, Some(&user)).await?; let is_moderator = authz .is_moderator(UserId(user.user_id), &room) .await .map_err(|e| chat_error(&e))?; // The gate carries the powers; a member's gate cannot express a moderator // removal, and the impl checks authorship for everyone else. state .chat .delete_message( &MtChatModeration::new(state.db.clone(), is_moderator), UserId(user.user_id), &room, MessageId(message_id), ) .await .map_err(|e| { tracing::warn!(error = ?e, "chat delete refused"); (StatusCode::FORBIDDEN, "Cannot delete that message.").into_response() })?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] pub(super) struct ChatModerationForm { username: String, /// Timeout length in seconds. Absent means a ban. seconds: Option, reason: Option, } #[tracing::instrument(skip_all)] pub(super) async fn chat_timeout_user( State(state): State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (room, target) = moderation_target(&state, &slug, &user, &form).await?; let seconds = form.seconds.unwrap_or(300).clamp(60, 86_400); state .chat .timeout_user( &MtChatModeration::new(state.db.clone(), true), UserId(user.user_id), &room, UserId(target), std::time::Duration::from_secs(seconds), ) .await .map_err(|e| chat_error(&e))?; Ok(StatusCode::NO_CONTENT) } #[tracing::instrument(skip_all)] pub(super) async fn chat_ban_user( State(state): State, Path(slug): Path, RequireUser(user): RequireUser, Form(form): Form, ) -> Result { let (room, target) = moderation_target(&state, &slug, &user, &form).await?; let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty()); // Bans the user and removes their backlog in one action, announced to the // room as a single purge event. state .chat .ban_user( &MtChatModeration::new(state.db.clone(), true), UserId(user.user_id), &room, UserId(target), reason, ) .await .map_err(|e| chat_error(&e))?; Ok(StatusCode::NO_CONTENT) } /// Shared front half of the two moderation handlers: resolve the room, prove /// the actor moderates it, resolve the target, and refuse the targets that are /// off limits. async fn moderation_target( state: &AppState, slug: &str, user: &SessionUser, form: &ChatModerationForm, ) -> Result<(Room, uuid::Uuid), Response> { let room = open_room(state, slug).await?; let authz = gate(state, slug, Some(user)).await?; if !authz .is_moderator(UserId(user.user_id), &room) .await .map_err(|e| chat_error(&e))? { return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); } let target = get_user_by_username(&state.db, form.username.trim()).await?; // The same protections the forum's ban handler applies. Chat is a second // door onto `community_bans`, so it must not be a way around them. if state.config.platform_admin_id == Some(target) { return Err(( StatusCode::FORBIDDEN, "Cannot act on the platform administrator.", ) .into_response()); } let target_role = super::get_role(&state.db, target, room.id.0).await?; if super::is_mod_or_owner(target_role) { let actor_role = super::get_role(&state.db, user.user_id, room.id.0).await?; if !super::is_owner(actor_role) { return Err( (StatusCode::FORBIDDEN, "Only owners can act on moderators.").into_response(), ); } } if super::is_owner(target_role) { return Err((StatusCode::FORBIDDEN, "Cannot act on an owner.").into_response()); } Ok((room, target)) }