//! Thread tracking handlers, track, untrack, tracked threads list. use axum::{ extract::{Path, Query}, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::AppState; use crate::auth::{MaybeUser, RequireUser}; use crate::csrf; use crate::templates::{ Pagination, TrackedThreadViewRow, TrackedThreadsTemplate, TrackingInfoTemplate, }; use super::{CommunityScope, check_community_access, db_error, template_user}; use mt_db::queries::ThreadWithBreadcrumb; /// POST /p/{slug}/{cat}/{thread_id}/track #[tracing::instrument(skip_all)] pub(super) async fn track_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, RequireUser(user): RequireUser, ) -> Result { // C1: prove the thread belongs to the slug's community (mismatch/bad id → // 404), and refuse banned/suspended users so they can't accrue tracking // state via a foreign slug. Tracking is read-adjacent, so a mute doesn't gate // it, use `check_community_access`, not write access. let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; check_community_access(&state.db, &scope.community, Some(user.user_id)).await?; mt_db::mutations::track_thread(&state.db, user.user_id, scope.resource.id) .await .map_err(db_error)?; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Thread+tracked" ))) } /// POST /p/{slug}/{cat}/{thread_id}/untrack #[tracing::instrument(skip_all)] pub(super) async fn untrack_thread_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>, RequireUser(user): RequireUser, ) -> Result { // C1: same scope proof as track (mismatch/bad id → 404). No ban check here, // removing your own tracking state is always allowed. let scope = CommunityScope::::resolve(&state.db, &slug, &thread_id_str).await?; mt_db::mutations::untrack_thread(&state.db, user.user_id, scope.resource.id) .await .map_err(db_error)?; Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Thread+untracked" ))) } /// POST /tracked/stop-all #[tracing::instrument(skip_all)] pub(super) async fn untrack_all_handler( axum::extract::State(state): axum::extract::State, RequireUser(user): RequireUser, ) -> Result { mt_db::mutations::untrack_all(&state.db, user.user_id) .await .map_err(db_error)?; Ok(Redirect::to("/tracked?toast=Stopped+tracking+all")) } /// GET /about/tracking, privacy/tracking info page #[tracing::instrument(skip_all)] pub(super) async fn tracking_info_page( axum::extract::State(state): axum::extract::State, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); let session_user = session_user .as_ref() .map(|u| template_user(u, state.config.platform_admin_id)); Ok(TrackingInfoTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), }) } /// GET /tracked, tracked threads page #[tracing::instrument(skip_all)] pub(super) async fn tracked_threads_page( axum::extract::State(state): axum::extract::State, session: Session, Query(query): Query, RequireUser(user): RequireUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); const PER_PAGE: i64 = 50; let total = mt_db::queries::count_tracked_threads(&state.db, user.user_id) .await .map_err(db_error)?; let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, PER_PAGE); let offset = pagination.offset(PER_PAGE); let db_tracked = mt_db::queries::list_tracked_threads(&state.db, user.user_id, PER_PAGE, offset) .await .map_err(db_error)?; let threads = db_tracked .into_iter() .map(|t| TrackedThreadViewRow { thread_id: t.thread_id.to_string(), thread_title: t.thread_title, community_name: t.community_name, community_slug: t.community_slug, category_slug: t.category_slug, unread_count: t.unread_count.max(0) as u32, has_mention: t.has_mention, }) .collect(); Ok(TrackedThreadsTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), threads, pagination, }) }