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

@@ -1,4 +1,4 @@
use axum::http::{header, HeaderMap};
use axum::http::{HeaderMap, header};
use loco_rs::prelude::*;
use serde::Serialize;
use std::{
@@ -75,7 +75,8 @@ fn header_value(headers: &HeaderMap, key: &'static str) -> Option<String> {
}
fn split_groups(value: Option<String>) -> Vec<String> {
value.unwrap_or_default()
value
.unwrap_or_default()
.split([',', ';', ' '])
.map(str::trim)
.filter(|item| !item.is_empty())
@@ -192,8 +193,7 @@ pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option<AdminIdentit
}
pub(crate) fn check_auth(headers: &HeaderMap) -> Result<AdminIdentity> {
resolve_admin_identity(headers)
.ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
resolve_admin_identity(headers).ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
}
pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) {

View File

@@ -1,8 +1,12 @@
use std::collections::{HashMap, HashSet};
use axum::{
extract::{Multipart, Query},
http::{HeaderMap, header},
};
use loco_rs::prelude::*;
use regex::Regex;
use reqwest::Url;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set,
@@ -25,7 +29,7 @@ use crate::{
services::{
admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
},
workers::downloader::DownloadWorkerArgs,
workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format},
};
#[derive(Clone, Debug, Deserialize)]
@@ -171,6 +175,9 @@ pub struct AdminSiteSettingsResponse {
pub location: Option<String>,
pub tech_stack: Vec<String>,
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
pub music_enabled: bool,
pub maintenance_mode_enabled: bool,
pub maintenance_access_code: Option<String>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
@@ -356,6 +363,8 @@ pub struct AdminMediaDownloadPayload {
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub target_format: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
@@ -365,13 +374,19 @@ pub struct AdminMediaDownloadPayload {
pub tags: Option<Vec<String>>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub sync: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse {
pub queued: bool,
pub job_id: i32,
pub status: String,
pub job_id: Option<i32>,
pub status: Option<String>,
pub key: Option<String>,
pub url: Option<String>,
pub size_bytes: Option<i64>,
pub content_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -487,6 +502,37 @@ pub struct AdminPostPolishRequest {
pub markdown: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostLocalizeImagesRequest {
pub markdown: String,
#[serde(default)]
pub prefix: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizedImageItem {
pub source_url: String,
pub localized_url: String,
pub key: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesFailure {
pub source_url: String,
pub error: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesResponse {
pub markdown: String,
pub detected_count: usize,
pub localized_count: usize,
pub uploaded_count: usize,
pub failed_count: usize,
pub items: Vec<AdminPostLocalizedImageItem>,
pub failures: Vec<AdminPostLocalizeImagesFailure>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest {
pub title: String,
@@ -537,6 +583,199 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
})
}
fn normalize_localize_image_prefix(value: Option<String>) -> String {
trim_to_option(value)
.map(|item| item.trim_matches('/').to_string())
.filter(|item| !item.is_empty())
.unwrap_or_else(|| "post-inline-images".to_string())
}
fn normalize_markdown_image_target(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() > 2 {
Some(trimmed[1..trimmed.len() - 1].trim().to_string())
} else {
Some(trimmed.to_string())
}
}
fn markdown_image_reference_urls(markdown: &str) -> Vec<String> {
let markdown_pattern =
Regex::new(r#"!\[[^\]]*]\((?P<url><[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)"#)
.expect("valid markdown image regex");
let html_double_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*"(?P<url>[^"]+)""#)
.expect("valid html img double quote regex");
let html_single_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*'(?P<url>[^']+)'"#)
.expect("valid html img single quote regex");
let mut urls = Vec::new();
for captures in markdown_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_double_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_single_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
urls
}
fn is_remote_markdown_image_candidate(
url: &str,
settings: Option<&storage::MediaStorageSettings>,
) -> bool {
let Ok(parsed) = Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
if settings
.and_then(|item| storage::object_key_from_public_url(item, url))
.is_some()
{
return false;
}
true
}
fn replace_markdown_image_urls(
markdown: &str,
replacements: &HashMap<String, String>,
) -> (String, usize) {
let markdown_pattern = Regex::new(
r#"(?P<lead>!\[[^\]]*]\()(?P<url><[^>\n]+>|[^)\s]+)(?P<trail>(?:\s+(?:"[^"]*"|'[^']*'))?\))"#,
)
.expect("valid markdown image replacement regex");
let html_double_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*")(?P<url>[^"]+)(?P<trail>"[^>]*>)"#)
.expect("valid html img double quote replacement regex");
let html_single_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*')(?P<url>[^']+)(?P<trail>'[^>]*>)"#)
.expect("valid html img single quote replacement regex");
let mut localized_count = 0usize;
let after_markdown = markdown_pattern
.replace_all(markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_double = html_double_quote_pattern
.replace_all(&after_markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_single = html_single_quote_pattern
.replace_all(&after_html_double, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
(after_html_single, localized_count)
}
fn parse_optional_timestamp(
value: Option<&str>,
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> {
@@ -785,6 +1024,9 @@ fn build_settings_response(
location: item.location,
tech_stack: tech_stack_values(&item.tech_stack),
music_playlist: music_playlist_values(&item.music_playlist),
music_enabled: item.music_enabled.unwrap_or(true),
maintenance_mode_enabled: item.maintenance_mode_enabled.unwrap_or(false),
maintenance_access_code: item.maintenance_access_code,
ai_enabled: item.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -1493,9 +1735,11 @@ pub async fn download_media_object(
Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let target_format = normalize_target_format(payload.target_format.clone())?;
let worker_args = DownloadWorkerArgs {
source_url: payload.source_url.clone(),
prefix: payload.prefix.clone(),
target_format,
title: payload.title.clone(),
alt_text: payload.alt_text.clone(),
caption: payload.caption.clone(),
@@ -1503,6 +1747,38 @@ pub async fn download_media_object(
notes: payload.notes.clone(),
job_id: None,
};
if payload.sync {
let downloaded = download_media_to_storage(&ctx, &worker_args).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(downloaded.key.clone()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"queued": false,
"source_url": payload.source_url,
"target_format": worker_args.target_format,
"key": downloaded.key,
"url": downloaded.url,
})),
)
.await?;
return format::json(AdminMediaDownloadResponse {
queued: false,
job_id: None,
status: Some("completed".to_string()),
key: Some(downloaded.key),
url: Some(downloaded.url),
size_bytes: Some(downloaded.size_bytes),
content_type: downloaded.content_type,
});
}
let job = worker_jobs::queue_download_job(
&ctx,
&worker_args,
@@ -1524,14 +1800,19 @@ pub async fn download_media_object(
"job_id": job.id,
"queued": true,
"source_url": payload.source_url,
"target_format": worker_args.target_format,
})),
)
.await?;
format::json(AdminMediaDownloadResponse {
queued: true,
job_id: job.id,
status: job.status,
job_id: Some(job.id),
status: Some(job.status),
key: None,
url: None,
size_bytes: None,
content_type: None,
})
}
@@ -1907,6 +2188,89 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
}
#[debug_handler]
pub async fn localize_post_markdown_images(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminPostLocalizeImagesRequest>,
) -> Result<Response> {
check_auth(&headers)?;
let normalized_markdown = payload.markdown.replace("\r\n", "\n");
let prefix = normalize_localize_image_prefix(payload.prefix);
let settings = storage::optional_r2_settings(&ctx).await?;
let detected_urls = markdown_image_reference_urls(&normalized_markdown);
let candidate_urls = detected_urls
.into_iter()
.filter(|url| is_remote_markdown_image_candidate(url, settings.as_ref()))
.collect::<Vec<_>>();
if candidate_urls.is_empty() {
return format::json(AdminPostLocalizeImagesResponse {
markdown: normalized_markdown,
detected_count: 0,
localized_count: 0,
uploaded_count: 0,
failed_count: 0,
items: Vec::new(),
failures: Vec::new(),
});
}
let mut seen = HashSet::new();
let unique_urls = candidate_urls
.iter()
.filter(|url| seen.insert((*url).clone()))
.cloned()
.collect::<Vec<_>>();
let mut replacements = HashMap::<String, String>::new();
let mut items = Vec::<AdminPostLocalizedImageItem>::new();
let mut failures = Vec::<AdminPostLocalizeImagesFailure>::new();
for source_url in unique_urls {
let args = DownloadWorkerArgs {
source_url: source_url.clone(),
prefix: Some(prefix.clone()),
target_format: None,
title: None,
alt_text: None,
caption: None,
tags: vec!["markdown-image".to_string()],
notes: Some("localized from markdown body".to_string()),
job_id: None,
};
match download_media_to_storage(&ctx, &args).await {
Ok(downloaded) => {
replacements.insert(source_url.clone(), downloaded.url.clone());
items.push(AdminPostLocalizedImageItem {
source_url,
localized_url: downloaded.url,
key: downloaded.key,
});
}
Err(error) => failures.push(AdminPostLocalizeImagesFailure {
source_url,
error: error.to_string(),
}),
}
}
let (markdown, localized_count) =
replace_markdown_image_urls(&normalized_markdown, &replacements);
format::json(AdminPostLocalizeImagesResponse {
markdown,
detected_count: candidate_urls.len(),
localized_count,
uploaded_count: items.len(),
failed_count: failures.len(),
items,
failures,
})
}
#[debug_handler]
pub async fn polish_review_description(
headers: HeaderMap,
@@ -2045,6 +2409,10 @@ pub fn routes() -> Routes {
.add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider))
.add("/ai/test-image-provider", post(test_ai_image_provider))
.add(
"/posts/localize-images",
post(localize_post_markdown_images),
)
.add("/storage/r2/test", post(test_r2_storage))
.add(
"/storage/media",

View File

@@ -8,9 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
models::_entities::{
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
},
models::_entities::{admin_audit_logs, notification_deliveries, post_revisions, subscriptions},
services::{
admin_audit, backups, post_revisions as revision_service,
subscriptions as subscription_service, worker_jobs,
@@ -174,7 +172,12 @@ fn format_revision(item: post_revisions::Model) -> PostRevisionListItem {
actor_email: item.actor_email,
actor_source: item.actor_source,
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(),
has_markdown: item
.markdown
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some(),
metadata: item.metadata,
}
}
@@ -187,17 +190,31 @@ pub async fn list_audit_logs(
) -> Result<Response> {
check_auth(&headers)?;
let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
let mut db_query =
admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
if let Some(action) = query
.action
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action));
}
if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
if let Some(target_type) = query
.target_type
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type));
}
format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?)
format::json(
db_query
.limit(query.limit.unwrap_or(80))
.all(&ctx.db)
.await?,
)
}
#[debug_handler]
@@ -207,7 +224,9 @@ pub async fn list_post_revisions(
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?;
let items =
revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120))
.await?;
format::json(items.into_iter().map(format_revision).collect::<Vec<_>>())
}
@@ -234,8 +253,7 @@ pub async fn restore_post_revision(
) -> Result<Response> {
let actor = check_auth(&headers)?;
let mode = payload.mode.unwrap_or_else(|| "full".to_string());
let restored =
revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
let restored = revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
@@ -278,7 +296,8 @@ pub async fn list_subscription_deliveries(
) -> Result<Response> {
check_auth(&headers)?;
format::json(DeliveryListResponse {
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?,
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80))
.await?,
})
}
@@ -300,7 +319,9 @@ pub async fn create_subscription(
channel_type: Set(channel_type.clone()),
target: Set(target.clone()),
display_name: Set(trim_to_option(payload.display_name)),
status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))),
status: Set(subscription_service::normalize_status(
payload.status.as_deref().unwrap_or("active"),
)),
filters: Set(subscription_service::normalize_filters(payload.filters)),
metadata: Set(payload.metadata),
secret: Set(trim_to_option(payload.secret)),
@@ -461,7 +482,9 @@ pub async fn send_subscription_digest(
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?;
let summary =
subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly"))
.await?;
admin_audit::log_event(
&ctx,
@@ -664,17 +687,29 @@ pub fn routes() -> Routes {
.add("/post-revisions", get(list_post_revisions))
.add("/post-revisions/{id}", get(get_post_revision))
.add("/post-revisions/{id}/restore", post(restore_post_revision))
.add("/subscriptions", get(list_subscriptions).post(create_subscription))
.add("/subscriptions/deliveries", get(list_subscription_deliveries))
.add(
"/subscriptions",
get(list_subscriptions).post(create_subscription),
)
.add(
"/subscriptions/deliveries",
get(list_subscription_deliveries),
)
.add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
.add(
"/subscriptions/{id}",
patch(update_subscription).delete(delete_subscription),
)
.add("/subscriptions/{id}/test", post(test_subscription))
.add("/workers/overview", get(workers_overview))
.add("/workers/jobs", get(list_worker_jobs))
.add("/workers/jobs/{id}", get(get_worker_job))
.add("/workers/jobs/{id}/cancel", post(cancel_worker_job))
.add("/workers/jobs/{id}/retry", post(retry_worker_job))
.add("/workers/tasks/retry-deliveries", post(run_retry_deliveries_job))
.add(
"/workers/tasks/retry-deliveries",
post(run_retry_deliveries_job),
)
.add("/workers/tasks/digest", post(run_digest_worker_job))
.add("/site-backup/export", get(export_site_backup))
.add("/site-backup/import", post(import_site_backup))

View File

@@ -4,8 +4,8 @@ use async_stream::stream;
use axum::{
body::{Body, Bytes},
http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderMap, HeaderValue,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
},
};
use chrono::{DateTime, Utc};

View File

@@ -8,10 +8,11 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
use axum::{
extract::{rejection::ExtensionRejection, ConnectInfo},
http::{header, HeaderMap},
extract::{ConnectInfo, rejection::ExtensionRejection},
http::{HeaderMap, header},
};
use crate::controllers::admin::check_auth;
use crate::models::_entities::{
comments::{ActiveModel, Column, Entity, Model},
posts,
@@ -21,7 +22,6 @@ use crate::services::{
comment_guard::{self, CommentGuardInput},
notifications,
};
use crate::controllers::admin::check_auth;
const ARTICLE_SCOPE: &str = "article";
const PARAGRAPH_SCOPE: &str = "paragraph";

View File

@@ -38,8 +38,15 @@ pub async fn record(
headers: HeaderMap,
Json(payload): Json<ContentAnalyticsEventPayload>,
) -> Result<Response> {
let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers);
if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() {
let mut request_context =
analytics::content_request_context_from_headers(&payload.path, &headers);
if payload
.referrer
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
request_context.referrer = payload.referrer;
}

View File

@@ -127,7 +127,9 @@ pub async fn update(
"friend_link.update",
"friend_link",
Some(item.id.to_string()),
item.site_name.clone().or_else(|| Some(item.site_url.clone())),
item.site_name
.clone()
.or_else(|| Some(item.site_url.clone())),
Some(serde_json::json!({ "status": item.status })),
)
.await?;
@@ -142,7 +144,10 @@ pub async fn remove(
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let label = item.site_name.clone().or_else(|| Some(item.site_url.clone()));
let label = item
.site_name
.clone()
.or_else(|| Some(item.site_url.clone()));
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,

View File

@@ -1,12 +1,12 @@
pub mod admin;
pub mod admin_api;
pub mod admin_taxonomy;
pub mod admin_ops;
pub mod admin_taxonomy;
pub mod ai;
pub mod auth;
pub mod content_analytics;
pub mod category;
pub mod comment;
pub mod content_analytics;
pub mod friend_link;
pub mod health;
pub mod post;

View File

@@ -14,7 +14,11 @@ use crate::{
fn is_public_review_status(status: Option<&str>) -> bool {
matches!(
status.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
status
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"published" | "completed" | "done"
)
}
@@ -67,7 +71,9 @@ pub async fn get_one(
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review {
Some(r) if include_private || is_public_review_status(r.status.as_deref()) => format::json(r),
Some(r) if include_private || is_public_review_status(r.status.as_deref()) => {
format::json(r)
}
Some(_) => Err(Error::NotFound),
None => Err(Error::NotFound),
}

View File

@@ -4,6 +4,7 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sha2::{Digest, Sha256};
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
@@ -89,6 +90,12 @@ pub struct SiteSettingsPayload {
pub tech_stack: Option<Vec<String>>,
#[serde(default, alias = "musicPlaylist")]
pub music_playlist: Option<Vec<MusicTrackPayload>>,
#[serde(default, alias = "musicEnabled")]
pub music_enabled: Option<bool>,
#[serde(default, alias = "maintenanceModeEnabled")]
pub maintenance_mode_enabled: Option<bool>,
#[serde(default, alias = "maintenanceAccessCode")]
pub maintenance_access_code: Option<String>,
#[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
@@ -199,6 +206,7 @@ pub struct PublicSiteSettingsResponse {
pub location: Option<String>,
pub tech_stack: Option<serde_json::Value>,
pub music_playlist: Option<serde_json::Value>,
pub music_enabled: bool,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
@@ -217,6 +225,31 @@ pub struct PublicSiteSettingsResponse {
pub seo_wechat_share_qr_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceAccessTokenPayload {
#[serde(default, alias = "accessToken")]
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceVerifyPayload {
#[serde(default)]
pub code: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceAccessStatusResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceVerifyResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct HomeCategorySummary {
pub id: i32,
@@ -252,6 +285,51 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
value.map(|item| item.clamp(min, max))
}
fn maintenance_mode_enabled(model: &Model) -> bool {
model.maintenance_mode_enabled.unwrap_or(false)
}
fn maintenance_access_code(model: &Model) -> Option<String> {
normalize_optional_string(model.maintenance_access_code.clone())
}
fn maintenance_access_token_from_secret(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"termi-maintenance-access:v1:");
hasher.update(secret.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool {
let Some(candidate) = token.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}) else {
return false;
};
let Some(secret) = maintenance_access_code(model) else {
return false;
};
candidate == maintenance_access_token_from_secret(&secret)
}
fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option<String> {
let candidate = code.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})?;
let secret = maintenance_access_code(model)?;
(candidate == secret).then(|| maintenance_access_token_from_secret(&secret))
}
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let normalized = item.trim().to_ascii_lowercase();
@@ -272,7 +350,7 @@ pub(crate) fn default_subscription_popup_title() -> String {
}
pub(crate) fn default_subscription_popup_description() -> String {
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订".to_string()
"有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份".to_string()
}
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
@@ -555,6 +633,15 @@ impl SiteSettingsPayload {
if let Some(music_playlist) = self.music_playlist {
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
}
if let Some(music_enabled) = self.music_enabled {
item.music_enabled = Some(music_enabled);
}
if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled {
item.maintenance_mode_enabled = Some(maintenance_mode_enabled);
}
if self.maintenance_access_code.is_some() {
item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code);
}
if let Some(ai_enabled) = self.ai_enabled {
item.ai_enabled = Some(ai_enabled);
}
@@ -752,10 +839,10 @@ fn default_payload() -> SiteSettingsPayload {
site_name: Some("InitCool".to_string()),
site_short_name: Some("Termi".to_string()),
site_url: Some("https://init.cool".to_string()),
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活".to_string()),
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
site_title: Some("InitCool · 技术笔记与内容档案".to_string()),
site_description: Some("围绕开发实践、产品观察与长期积累整理的中文内容站".to_string()),
hero_title: Some("欢迎来到 InitCool".to_string()),
hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()),
owner_name: Some("InitCool".to_string()),
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
owner_bio: Some(
@@ -813,6 +900,9 @@ fn default_payload() -> SiteSettingsPayload {
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
},
]),
music_enabled: Some(true),
maintenance_mode_enabled: Some(false),
maintenance_access_code: None,
ai_enabled: Some(false),
paragraph_comments_enabled: Some(true),
comment_verification_mode: Some(
@@ -923,6 +1013,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
location: model.location,
tech_stack: model.tech_stack,
music_playlist: model.music_playlist,
music_enabled: model.music_enabled.unwrap_or(true),
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -1019,6 +1110,50 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?))
}
#[debug_handler]
pub async fn maintenance_status(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceAccessTokenPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
let access_granted = if enabled {
validate_maintenance_access_token(&current, params.access_token.as_deref())
} else {
true
};
format::json(MaintenanceAccessStatusResponse {
maintenance_mode_enabled: enabled,
access_granted,
})
}
#[debug_handler]
pub async fn maintenance_verify(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceVerifyPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
if !enabled {
return format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: false,
access_granted: true,
access_token: None,
});
}
let access_token = verify_maintenance_access_code(&current, params.code.as_deref());
format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: true,
access_granted: access_token.is_some(),
access_token,
})
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
@@ -1039,6 +1174,8 @@ pub fn routes() -> Routes {
Routes::new()
.prefix("api/site_settings/")
.add("home", get(home))
.add("maintenance/status", post(maintenance_status))
.add("maintenance/verify", post(maintenance_verify))
.add("/", get(show))
.add("/", put(update))
.add("/", patch(update))

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))