Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
style: enhance global CSS for better responsiveness of terminal chips and navigation pills test: remove inline subscription test and add maintenance mode access code test feat: implement media library picker dialog for selecting images from the media library feat: add media URL controls for uploading and managing media assets feat: add migration for music_enabled and maintenance_mode settings in site settings feat: implement maintenance mode functionality with access control feat: create maintenance page with access code input and error handling chore: add TypeScript declaration for QR code module
563 lines
17 KiB
Rust
563 lines
17 KiB
Rust
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<String>,
|
|
#[serde(default)]
|
|
pub source: Option<String>,
|
|
#[serde(default, alias = "turnstileToken")]
|
|
pub turnstile_token: Option<String>,
|
|
#[serde(default, alias = "captchaToken")]
|
|
pub captcha_token: Option<String>,
|
|
#[serde(default, alias = "captchaAnswer")]
|
|
pub captcha_answer: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct PublicBrowserPushSubscriptionPayload {
|
|
pub subscription: serde_json::Value,
|
|
#[serde(default)]
|
|
pub source: Option<String>,
|
|
#[serde(default, alias = "turnstileToken")]
|
|
pub turnstile_token: Option<String>,
|
|
#[serde(default, alias = "captchaToken")]
|
|
pub captcha_token: Option<String>,
|
|
#[serde(default, alias = "captchaAnswer")]
|
|
pub captcha_answer: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct PublicCombinedSubscriptionPayload {
|
|
#[serde(default)]
|
|
pub channels: Vec<String>,
|
|
#[serde(default)]
|
|
pub email: Option<String>,
|
|
#[serde(default, alias = "displayName")]
|
|
pub display_name: Option<String>,
|
|
#[serde(default)]
|
|
pub subscription: Option<serde_json::Value>,
|
|
#[serde(default)]
|
|
pub source: Option<String>,
|
|
#[serde(default, alias = "turnstileToken")]
|
|
pub turnstile_token: Option<String>,
|
|
#[serde(default, alias = "captchaToken")]
|
|
pub captcha_token: Option<String>,
|
|
#[serde(default, alias = "captchaAnswer")]
|
|
pub captcha_answer: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
pub status: Option<String>,
|
|
#[serde(default)]
|
|
pub filters: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[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<PublicCombinedSubscriptionItemResponse>,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct SubscriptionManageResponse {
|
|
pub ok: bool,
|
|
pub subscription: subscriptions::PublicSubscriptionView,
|
|
}
|
|
|
|
fn public_subscription_metadata(source: Option<String>) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"source": source,
|
|
"kind": "public-form",
|
|
})
|
|
}
|
|
|
|
fn public_browser_push_metadata(
|
|
source: Option<String>,
|
|
subscription: serde_json::Value,
|
|
user_agent: Option<String>,
|
|
) -> 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<String> {
|
|
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<AppContext>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<PublicSubscriptionPayload>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<PublicBrowserPushSubscriptionPayload>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(payload): Json<PublicCombinedSubscriptionPayload>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
Json(payload): Json<SubscriptionTokenPayload>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
Query(query): Query<SubscriptionManageQuery>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
Json(payload): Json<SubscriptionManageUpdatePayload>,
|
|
) -> Result<Response> {
|
|
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<AppContext>,
|
|
Json(payload): Json<SubscriptionTokenPayload>,
|
|
) -> Result<Response> {
|
|
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))
|
|
}
|