feat: update tag and timeline share panel copy for clarity and conciseness
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
This commit is contained in:
2026-04-02 23:05:49 +08:00
parent 6a50dd478c
commit 9665c933b5
94 changed files with 5266 additions and 1612 deletions

View File

@@ -33,6 +33,26 @@ pub struct PublicBrowserPushSubscriptionPayload {
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,
@@ -63,6 +83,21 @@ pub struct PublicSubscriptionResponse {
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,
@@ -89,6 +124,30 @@ fn public_browser_push_metadata(
})
}
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>,
@@ -119,11 +178,7 @@ pub async fn subscribe(
) -> 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),
)?;
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,
@@ -186,7 +241,9 @@ pub async fn subscribe_browser_push(
.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()))?
.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
@@ -196,15 +253,11 @@ pub async fn subscribe_browser_push(
.filter(|value| !value.is_empty())
.map(ToString::to_string);
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?;
verify_subscription_human_check(
&settings,
payload.turnstile_token.as_deref(),
payload.captcha_token.as_deref(),
payload.captcha_answer.as_deref(),
abuse_guard::enforce_public_scope(
"browser-push-subscription",
client_ip.as_deref(),
)
.await?;
Some(&endpoint),
)?;
let result = subscriptions::create_public_web_push_subscription(
&ctx,
@@ -240,6 +293,174 @@ pub async fn subscribe_browser_push(
})
}
#[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>,
@@ -333,6 +554,7 @@ 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))