use loco_rs::prelude::*; use serde::{Deserialize, Serialize}; use axum::http::header; use crate::services::{abuse_guard, admin_audit, subscriptions, turnstile}; #[derive(Clone, Debug, Deserialize)] pub struct PublicSubscriptionPayload { pub email: String, #[serde(default, alias = "displayName")] pub display_name: Option, #[serde(default)] pub source: Option, #[serde(default, alias = "turnstileToken")] pub turnstile_token: Option, #[serde(default, alias = "captchaToken")] pub captcha_token: Option, #[serde(default, alias = "captchaAnswer")] pub captcha_answer: Option, } #[derive(Clone, Debug, Deserialize)] pub struct PublicBrowserPushSubscriptionPayload { pub subscription: serde_json::Value, #[serde(default)] pub source: Option, #[serde(default, alias = "turnstileToken")] pub turnstile_token: Option, #[serde(default, alias = "captchaToken")] pub captcha_token: Option, #[serde(default, alias = "captchaAnswer")] pub captcha_answer: Option, } #[derive(Clone, Debug, Deserialize)] pub struct PublicCombinedSubscriptionPayload { #[serde(default)] pub channels: Vec, #[serde(default)] pub email: Option, #[serde(default, alias = "displayName")] pub display_name: Option, #[serde(default)] pub subscription: Option, #[serde(default)] pub source: Option, #[serde(default, alias = "turnstileToken")] pub turnstile_token: Option, #[serde(default, alias = "captchaToken")] pub captcha_token: Option, #[serde(default, alias = "captchaAnswer")] pub captcha_answer: Option, } #[derive(Clone, Debug, Deserialize)] pub struct SubscriptionTokenPayload { pub token: String, } #[derive(Clone, Debug, Deserialize)] pub struct SubscriptionManageQuery { pub token: String, } #[derive(Clone, Debug, Deserialize)] pub struct SubscriptionManageUpdatePayload { pub token: String, #[serde(default, alias = "displayName")] pub display_name: Option, #[serde(default)] pub status: Option, #[serde(default)] pub filters: Option, } #[derive(Clone, Debug, Serialize)] pub struct PublicSubscriptionResponse { pub ok: bool, pub subscription_id: i32, pub status: String, pub requires_confirmation: bool, pub message: String, } #[derive(Clone, Debug, Serialize)] pub struct PublicCombinedSubscriptionItemResponse { pub channel_type: String, pub subscription_id: i32, pub status: String, pub requires_confirmation: bool, } #[derive(Clone, Debug, Serialize)] pub struct PublicCombinedSubscriptionResponse { pub ok: bool, pub channels: Vec, pub message: String, } #[derive(Clone, Debug, Serialize)] pub struct SubscriptionManageResponse { pub ok: bool, pub subscription: subscriptions::PublicSubscriptionView, } fn public_subscription_metadata(source: Option) -> serde_json::Value { serde_json::json!({ "source": source, "kind": "public-form", }) } fn public_browser_push_metadata( source: Option, subscription: serde_json::Value, user_agent: Option, ) -> serde_json::Value { serde_json::json!({ "source": source, "kind": "browser-push", "subscription": subscription, "user_agent": user_agent, }) } fn normalize_public_subscription_channels(channels: &[String]) -> Vec { let mut normalized = Vec::new(); for raw in channels { let Some(channel) = ({ match raw.trim().to_ascii_lowercase().as_str() { "email" | "mail" => Some("email"), "browser" | "browser-push" | "browser_push" | "webpush" | "web-push" => { Some("browser_push") } _ => None, } }) else { continue; }; if !normalized.iter().any(|value| value == channel) { normalized.push(channel.to_string()); } } normalized } async fn verify_subscription_human_check( settings: &crate::models::_entities::site_settings::Model, turnstile_token: Option<&str>, captcha_token: Option<&str>, captcha_answer: Option<&str>, client_ip: Option<&str>, ) -> Result<()> { match turnstile::effective_mode(settings, turnstile::TurnstileScope::Subscription) { turnstile::VerificationMode::Off => Ok(()), turnstile::VerificationMode::Captcha => { crate::services::comment_guard::verify_captcha_solution( captcha_token, captcha_answer, client_ip, ) } turnstile::VerificationMode::Turnstile => { turnstile::verify_token(settings, turnstile_token, client_ip).await } } } #[debug_handler] pub async fn subscribe( State(ctx): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { let email = payload.email.trim().to_ascii_lowercase(); let client_ip = abuse_guard::detect_client_ip(&headers); abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(&email))?; let settings = crate::controllers::site_settings::load_current(&ctx).await?; verify_subscription_human_check( &settings, payload.turnstile_token.as_deref(), payload.captcha_token.as_deref(), payload.captcha_answer.as_deref(), client_ip.as_deref(), ) .await?; let result = subscriptions::create_public_email_subscription( &ctx, &email, payload.display_name, Some(public_subscription_metadata(payload.source)), ) .await?; admin_audit::log_event( &ctx, None, if result.requires_confirmation { "subscription.public.pending" } else { "subscription.public.active" }, "subscription", Some(result.subscription.id.to_string()), Some(result.subscription.target.clone()), Some(serde_json::json!({ "channel_type": result.subscription.channel_type, "status": result.subscription.status, })), ) .await?; format::json(PublicSubscriptionResponse { ok: true, subscription_id: result.subscription.id, status: result.subscription.status, requires_confirmation: result.requires_confirmation, message: result.message, }) } #[debug_handler] pub async fn subscribe_browser_push( State(ctx): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { let settings = crate::controllers::site_settings::load_current(&ctx).await?; if !crate::services::web_push::is_enabled(&settings) { return Err(Error::BadRequest("浏览器推送未启用".to_string())); } let endpoint = payload .subscription .get("endpoint") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()) })? .to_string(); let client_ip = abuse_guard::detect_client_ip(&headers); let user_agent = headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string); abuse_guard::enforce_public_scope( "browser-push-subscription", client_ip.as_deref(), Some(&endpoint), )?; let result = subscriptions::create_public_web_push_subscription( &ctx, payload.subscription.clone(), Some(public_browser_push_metadata( payload.source, payload.subscription, user_agent, )), ) .await?; admin_audit::log_event( &ctx, None, "subscription.public.web_push.active", "subscription", Some(result.subscription.id.to_string()), Some(result.subscription.target.clone()), Some(serde_json::json!({ "channel_type": result.subscription.channel_type, "status": result.subscription.status, })), ) .await?; format::json(PublicSubscriptionResponse { ok: true, subscription_id: result.subscription.id, status: result.subscription.status, requires_confirmation: false, message: result.message, }) } #[debug_handler] pub async fn subscribe_combined( State(ctx): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { let selected_channels = normalize_public_subscription_channels(&payload.channels); if selected_channels.is_empty() { return Err(Error::BadRequest("请至少选择一种订阅方式".to_string())); } let wants_email = selected_channels.iter().any(|value| value == "email"); let wants_browser_push = selected_channels .iter() .any(|value| value == "browser_push"); let settings = crate::controllers::site_settings::load_current(&ctx).await?; let client_ip = abuse_guard::detect_client_ip(&headers); let normalized_email = payload .email .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_lowercase()); if wants_email { let email = normalized_email .as_deref() .ok_or_else(|| Error::BadRequest("请选择邮箱订阅后填写邮箱地址".to_string()))?; abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(email))?; } let normalized_browser_subscription = if wants_browser_push { if !crate::services::web_push::is_enabled(&settings) { return Err(Error::BadRequest("浏览器推送未启用".to_string())); } let subscription = payload .subscription .clone() .ok_or_else(|| Error::BadRequest("缺少浏览器推送订阅信息".to_string()))?; let endpoint = subscription .get("endpoint") .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()) })? .to_string(); abuse_guard::enforce_public_scope( "browser-push-subscription", client_ip.as_deref(), Some(&endpoint), )?; Some(subscription) } else { None }; if wants_email { verify_subscription_human_check( &settings, payload.turnstile_token.as_deref(), payload.captcha_token.as_deref(), payload.captcha_answer.as_deref(), client_ip.as_deref(), ) .await?; } let user_agent = headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string); let mut items = Vec::new(); let mut message_parts = Vec::new(); if let Some(subscription) = normalized_browser_subscription { let browser_result = subscriptions::create_public_web_push_subscription( &ctx, subscription.clone(), Some(public_browser_push_metadata( payload.source.clone(), subscription, user_agent, )), ) .await?; admin_audit::log_event( &ctx, None, "subscription.public.web_push.active", "subscription", Some(browser_result.subscription.id.to_string()), Some(browser_result.subscription.target.clone()), Some(serde_json::json!({ "channel_type": browser_result.subscription.channel_type, "status": browser_result.subscription.status, })), ) .await?; message_parts.push(browser_result.message.clone()); items.push(PublicCombinedSubscriptionItemResponse { channel_type: browser_result.subscription.channel_type, subscription_id: browser_result.subscription.id, status: browser_result.subscription.status, requires_confirmation: false, }); } if wants_email { let email_result = subscriptions::create_public_email_subscription( &ctx, normalized_email.as_deref().unwrap_or_default(), payload.display_name, Some(public_subscription_metadata(payload.source)), ) .await?; admin_audit::log_event( &ctx, None, if email_result.requires_confirmation { "subscription.public.pending" } else { "subscription.public.active" }, "subscription", Some(email_result.subscription.id.to_string()), Some(email_result.subscription.target.clone()), Some(serde_json::json!({ "channel_type": email_result.subscription.channel_type, "status": email_result.subscription.status, })), ) .await?; message_parts.push(email_result.message.clone()); items.push(PublicCombinedSubscriptionItemResponse { channel_type: email_result.subscription.channel_type, subscription_id: email_result.subscription.id, status: email_result.subscription.status, requires_confirmation: email_result.requires_confirmation, }); } let message = if message_parts.is_empty() { "订阅请求已处理。".to_string() } else { message_parts.join(" ") }; format::json(PublicCombinedSubscriptionResponse { ok: true, channels: items, message, }) } #[debug_handler] pub async fn confirm( State(ctx): State, Json(payload): Json, ) -> Result { let item = subscriptions::confirm_subscription(&ctx, &payload.token).await?; admin_audit::log_event( &ctx, None, "subscription.public.confirm", "subscription", Some(item.id.to_string()), Some(item.target.clone()), Some(serde_json::json!({ "channel_type": item.channel_type })), ) .await?; format::json(SubscriptionManageResponse { ok: true, subscription: subscriptions::to_public_subscription_view(&item), }) } #[debug_handler] pub async fn manage( State(ctx): State, Query(query): Query, ) -> Result { let item = subscriptions::get_subscription_by_manage_token(&ctx, &query.token).await?; format::json(SubscriptionManageResponse { ok: true, subscription: subscriptions::to_public_subscription_view(&item), }) } #[debug_handler] pub async fn update_manage( State(ctx): State, Json(payload): Json, ) -> Result { let item = subscriptions::update_subscription_preferences( &ctx, &payload.token, payload.display_name, payload.status, payload.filters, ) .await?; admin_audit::log_event( &ctx, None, "subscription.public.update", "subscription", Some(item.id.to_string()), Some(item.target.clone()), None, ) .await?; format::json(SubscriptionManageResponse { ok: true, subscription: subscriptions::to_public_subscription_view(&item), }) } #[debug_handler] pub async fn unsubscribe( State(ctx): State, Json(payload): Json, ) -> Result { let item = subscriptions::unsubscribe_subscription(&ctx, &payload.token).await?; admin_audit::log_event( &ctx, None, "subscription.public.unsubscribe", "subscription", Some(item.id.to_string()), Some(item.target.clone()), None, ) .await?; format::json(SubscriptionManageResponse { ok: true, subscription: subscriptions::to_public_subscription_view(&item), }) } pub fn routes() -> Routes { Routes::new() .prefix("/api/subscriptions") .add("/", post(subscribe)) .add("/combined", post(subscribe_combined)) .add("/browser-push", post(subscribe_browser_push)) .add("/confirm", post(confirm)) .add("/manage", get(manage).patch(update_manage)) .add("/unsubscribe", post(unsubscribe)) }