//! Per-user account settings (signature + Fan+ status display). //! //! All routes require a logged-in session. The signature editor is gated on //! [`UserPerks::effective_plus`]: non-Fan+ users see the upsell instead of an //! input. Lapsed Fan+ users retain their saved markdown (we don't auto-delete //! it) but the post-rendering layer hides their signature until they renew. use axum::{ extract::{Form, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::*; use crate::AppState; use super::{ render_markdown, render_markdown_plus, template_user, SignatureForm, }; const SIGNATURE_MAX: usize = 1024; #[tracing::instrument(skip_all)] pub(super) async fn account_settings( State(state): State, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let row: Option<(Option, Option)> = sqlx::query_as( "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", ) .bind(user.user_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = ?e, "db error loading signature"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let (signature_markdown, signature_html) = row.unwrap_or((None, None)); Ok(AccountSettingsTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), has_plus: user.perks.effective_plus(), fan_plus: user.perks.fan_plus, signature_markdown, signature_html, error: None, }) } #[tracing::instrument(skip_all)] pub(super) async fn update_signature_handler( State(state): State, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; if !user.perks.effective_plus() { return Err(( StatusCode::FORBIDDEN, "Signatures are a Fan+ feature.", ) .into_response()); } // "Clear signature" button submits with `clear=1`; takes precedence over // the textarea content. if form.clear.as_deref() == Some("1") { sqlx::query( "UPDATE users SET signature_markdown = NULL, signature_html = NULL \ WHERE mnw_account_id = $1", ) .bind(user.user_id) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = ?e, "db error clearing signature"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; return Ok(Redirect::to("/account?toast=Signature+cleared")); } let trimmed = form.signature.trim(); if trimmed.is_empty() { // Treat empty submit as a no-op rather than implicit clear — there's // an explicit Clear button for that. return Ok(Redirect::to("/account")); } if trimmed.chars().count() > SIGNATURE_MAX { return Err(( StatusCode::UNPROCESSABLE_ENTITY, format!("Signature must be at most {SIGNATURE_MAX} characters."), ) .into_response()); } // Render with the same plus-aware paths as posts: creators get image // embeds via auto-grant, matching post-body behaviour. Render-time // visibility is gated by `users.is_fan_plus` so creator signatures // still only surface when they're also a Fan+ subscriber — auto-grant // covers editing capability, not the public + badge / signature display. let signature_html = if user.perks.effective_plus() { render_markdown_plus(trimmed) } else { render_markdown(trimmed) }; sqlx::query( "UPDATE users SET signature_markdown = $2, signature_html = $3 \ WHERE mnw_account_id = $1", ) .bind(user.user_id) .bind(trimmed) .bind(&signature_html) .execute(&state.db) .await .map_err(|e| { tracing::error!(error = ?e, "db error saving signature"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Redirect::to("/account?toast=Signature+saved")) }