feat: refresh content workflow and verification settings
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s

This commit is contained in:
2026-04-01 18:47:17 +08:00
parent f2c07df320
commit 7de4ddc3ee
66 changed files with 1455 additions and 2759 deletions

View File

@@ -1,6 +1,6 @@
use axum::{
extract::{Multipart, Query},
http::{header, HeaderMap},
http::{HeaderMap, header},
};
use loco_rs::prelude::*;
use sea_orm::{
@@ -170,7 +170,9 @@ pub struct AdminSiteSettingsResponse {
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
pub comment_turnstile_enabled: bool,
pub subscription_verification_mode: String,
pub subscription_turnstile_enabled: bool,
pub web_push_enabled: bool,
pub turnstile_site_key: Option<String>,
@@ -686,9 +688,7 @@ fn build_media_object_response(
title: metadata.and_then(|entry| entry.title.clone()),
alt_text: metadata.and_then(|entry| entry.alt_text.clone()),
caption: metadata.and_then(|entry| entry.caption.clone()),
tags: metadata
.map(media_assets::tag_list)
.unwrap_or_default(),
tags: metadata.map(media_assets::tag_list).unwrap_or_default(),
notes: metadata.and_then(|entry| entry.notes.clone()),
}
}
@@ -724,6 +724,14 @@ fn build_settings_response(
) -> AdminSiteSettingsResponse {
let ai_providers = site_settings::ai_provider_configs(&item);
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
let comment_verification_mode = crate::services::turnstile::selected_mode(
&item,
crate::services::turnstile::TurnstileScope::Comment,
);
let subscription_verification_mode = crate::services::turnstile::selected_mode(
&item,
crate::services::turnstile::TurnstileScope::Subscription,
);
let turnstile_site_key = crate::services::turnstile::site_key(&item);
let turnstile_secret_key = crate::services::turnstile::secret_key(&item);
let web_push_vapid_public_key = crate::services::web_push::public_key(&item);
@@ -751,8 +759,16 @@ fn build_settings_response(
music_playlist: music_playlist_values(&item.music_playlist),
ai_enabled: item.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
comment_turnstile_enabled: item.comment_turnstile_enabled.unwrap_or(false),
subscription_turnstile_enabled: item.subscription_turnstile_enabled.unwrap_or(false),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
comment_turnstile_enabled: matches!(
comment_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
),
subscription_verification_mode: subscription_verification_mode.as_str().to_string(),
subscription_turnstile_enabled: matches!(
subscription_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
),
web_push_enabled: item.web_push_enabled.unwrap_or(false),
turnstile_site_key,
turnstile_secret_key,
@@ -887,7 +903,6 @@ pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -
#[debug_handler]
pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
content::sync_markdown_posts(&ctx).await?;
let all_posts = posts::Entity::find().all(&ctx.db).await?;
let total_posts = all_posts.len() as u64;
@@ -1190,8 +1205,8 @@ pub async fn list_media_objects(
check_auth(&headers)?;
let settings = storage::require_r2_settings(&ctx).await?;
let objects = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
.await?;
let objects =
storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200)).await?;
let keys = objects
.iter()
.map(|item| item.key.clone())
@@ -1228,7 +1243,11 @@ pub async fn delete_media_object(
storage::delete_object(&ctx, key).await?;
if let Err(error) = media_assets::delete_by_key(&ctx, key).await {
tracing::warn!(?error, key, "failed to delete media metadata after object deletion");
tracing::warn!(
?error,
key,
"failed to delete media metadata after object deletion"
);
}
format::json(AdminMediaDeleteResponse {
@@ -1325,7 +1344,11 @@ pub async fn batch_delete_media_objects(
match storage::delete_object(&ctx, &key).await {
Ok(()) => {
if let Err(error) = media_assets::delete_by_key(&ctx, &key).await {
tracing::warn!(?error, key, "failed to delete media metadata after batch removal");
tracing::warn!(
?error,
key,
"failed to delete media metadata after batch removal"
);
}
deleted.push(key)
}
@@ -1955,7 +1978,10 @@ pub fn routes() -> Routes {
"/storage/media/batch-delete",
post(batch_delete_media_objects),
)
.add("/storage/media/metadata", patch(update_media_object_metadata))
.add(
"/storage/media/metadata",
patch(update_media_object_metadata),
)
.add("/storage/media/replace", post(replace_media_object))
.add(
"/comments/blacklist",

View File

@@ -137,7 +137,10 @@ fn tag_name(item: &tags::Model) -> String {
item.name.clone().unwrap_or_else(|| item.slug.clone())
}
fn build_category_record(item: &categories::Model, post_items: &[posts::Model]) -> AdminCategoryRecord {
fn build_category_record(
item: &categories::Model,
post_items: &[posts::Model],
) -> AdminCategoryRecord {
let name = category_name(item);
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
let count = post_items
@@ -224,7 +227,11 @@ async fn ensure_category_slug_unique(
Ok(())
}
async fn ensure_tag_slug_unique(ctx: &AppContext, slug: &str, exclude_id: Option<i32>) -> Result<()> {
async fn ensure_tag_slug_unique(
ctx: &AppContext,
slug: &str,
exclude_id: Option<i32>,
) -> Result<()> {
if let Some(existing) = tags::Entity::find()
.filter(tags::Column::Slug.eq(slug))
.one(&ctx.db)
@@ -243,9 +250,11 @@ async fn load_posts(ctx: &AppContext) -> Result<Vec<posts::Model>> {
}
#[debug_handler]
pub async fn list_categories(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
pub async fn list_categories(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
content::sync_markdown_posts(&ctx).await?;
let items = categories::Entity::find()
.order_by_asc(categories::Column::Slug)
@@ -254,7 +263,8 @@ pub async fn list_categories(headers: HeaderMap, State(ctx): State<AppContext>)
let post_items = load_posts(&ctx).await?;
format::json(
items.into_iter()
items
.into_iter()
.map(|item| build_category_record(&item, &post_items))
.collect::<Vec<_>>(),
)
@@ -312,7 +322,13 @@ pub async fn update_category(
.filter(|value| !value.is_empty())
!= Some(name.as_str())
{
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
content::rewrite_category_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)
.await?;
}
let mut active = item.into_active_model();
@@ -324,7 +340,6 @@ pub async fn update_category(
active.seo_title = Set(trim_to_option(payload.seo_title));
active.seo_description = Set(trim_to_option(payload.seo_description));
let updated = active.update(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
let post_items = load_posts(&ctx).await?;
format::json(build_category_record(&updated, &post_items))
@@ -339,9 +354,8 @@ pub async fn delete_category(
check_auth(&headers)?;
let item = load_category(&ctx, id).await?;
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
content::rewrite_category_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::empty()
}
@@ -349,7 +363,6 @@ pub async fn delete_category(
#[debug_handler]
pub async fn list_tags(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
content::sync_markdown_posts(&ctx).await?;
let items = tags::Entity::find()
.order_by_asc(tags::Column::Slug)
@@ -358,7 +371,8 @@ pub async fn list_tags(headers: HeaderMap, State(ctx): State<AppContext>) -> Res
let post_items = load_posts(&ctx).await?;
format::json(
items.into_iter()
items
.into_iter()
.map(|item| build_tag_record(&item, &post_items))
.collect::<Vec<_>>(),
)
@@ -416,7 +430,13 @@ pub async fn update_tag(
.filter(|value| !value.is_empty())
!= Some(name.as_str())
{
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
content::rewrite_tag_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)
.await?;
}
let mut active = item.into_active_model();
@@ -428,7 +448,6 @@ pub async fn update_tag(
active.seo_title = Set(trim_to_option(payload.seo_title));
active.seo_description = Set(trim_to_option(payload.seo_description));
let updated = active.update(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
let post_items = load_posts(&ctx).await?;
format::json(build_tag_record(&updated, &post_items))
@@ -443,9 +462,8 @@ pub async fn delete_tag(
check_auth(&headers)?;
let item = load_tag(&ctx, id).await?;
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
content::rewrite_tag_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::empty()
}

View File

@@ -112,7 +112,9 @@ fn build_summary(item: &categories::Model, post_items: &[posts::Model]) -> Categ
post.category
.as_deref()
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case(&name) || value.eq_ignore_ascii_case(&item.slug))
.is_some_and(|value| {
value.eq_ignore_ascii_case(&name) || value.eq_ignore_ascii_case(&item.slug)
})
})
.count();
@@ -151,8 +153,6 @@ async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
#[debug_handler]
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let category_items = categories::Entity::find()
.order_by_asc(categories::Column::Slug)
.all(&ctx.db)
@@ -224,10 +224,12 @@ pub async fn update(
!= Some(name.as_str())
{
content::rewrite_category_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)?;
)
.await?;
}
let mut item = item.into_active_model();
@@ -239,16 +241,14 @@ pub async fn update(
item.seo_title = Set(trim_to_option(params.seo_title));
item.seo_description = Set(trim_to_option(params.seo_description));
let item = item.update(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::json(build_record(item))
}
#[debug_handler]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
let item = load_item(&ctx, id).await?;
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
content::rewrite_category_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::empty()
}

View File

@@ -80,7 +80,9 @@ fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
fn effective_status(post: &Model) -> String {
content::effective_post_state(
post.status.as_deref().unwrap_or(content::POST_STATUS_PUBLISHED),
post.status
.as_deref()
.unwrap_or(content::POST_STATUS_PUBLISHED),
post.publish_at,
post.unpublish_at,
Utc::now().fixed_offset(),
@@ -157,16 +159,18 @@ fn parse_optional_markdown_datetime(
return None;
}
chrono::DateTime::parse_from_rfc3339(value).ok().or_else(|| {
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
.and_then(|naive| {
chrono::FixedOffset::east_opt(0)?
.from_local_datetime(&naive)
.single()
})
})
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.or_else(|| {
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
.ok()
.and_then(|date| date.and_hms_opt(0, 0, 0))
.and_then(|naive| {
chrono::FixedOffset::east_opt(0)?
.from_local_datetime(&naive)
.single()
})
})
}
fn markdown_post_listed_publicly(post: &content::MarkdownPost) -> bool {
@@ -253,7 +257,9 @@ fn should_include_post(
}
if let Some(status) = &query.status {
if effective_status(post) != content::normalize_post_status(Some(status)) && effective_status(post) != status.trim().to_ascii_lowercase() {
if effective_status(post) != content::normalize_post_status(Some(status))
&& effective_status(post) != status.trim().to_ascii_lowercase()
{
return false;
}
}
@@ -397,22 +403,22 @@ impl Params {
item.image = Set(self.image.clone());
item.images = Set(self.images.clone());
item.pinned = Set(self.pinned);
item.status = Set(self.status.clone().map(|value| requested_status(Some(value), None)));
item.visibility = Set(
self.visibility
.clone()
.map(|value| normalize_visibility(Some(value))),
);
item.publish_at = Set(
self.publish_at
.clone()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()),
);
item.unpublish_at = Set(
self.unpublish_at
.clone()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()),
);
item.status = Set(self
.status
.clone()
.map(|value| requested_status(Some(value), None)));
item.visibility = Set(self
.visibility
.clone()
.map(|value| normalize_visibility(Some(value))));
item.publish_at = Set(self
.publish_at
.clone()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()));
item.unpublish_at = Set(self
.unpublish_at
.clone()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()));
item.canonical_url = Set(self.canonical_url.clone());
item.noindex = Set(self.noindex);
item.og_image = Set(self.og_image.clone());
@@ -526,8 +532,6 @@ pub async fn list(
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let preview = request_preview_mode(query.preview, &headers);
let include_private = preview && query.include_private.unwrap_or(true);
let include_redirects = query.include_redirects.unwrap_or(preview);
@@ -539,7 +543,9 @@ pub async fn list(
let filtered = posts
.into_iter()
.filter(|post| should_include_post(post, &query, preview, include_private, include_redirects))
.filter(|post| {
should_include_post(post, &query, preview, include_private, include_redirects)
})
.collect::<Vec<_>>();
format::json(filtered)
@@ -551,8 +557,6 @@ pub async fn list_page(
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let preview = request_preview_mode(query.filters.preview, &headers);
let include_private = preview && query.filters.include_private.unwrap_or(true);
let include_redirects = query.filters.include_redirects.unwrap_or(preview);
@@ -672,7 +676,10 @@ pub async fn update(
.into_iter()
.filter_map(|tag| tag.as_str().map(ToString::to_string))
.collect(),
post_type: item.post_type.clone().unwrap_or_else(|| "article".to_string()),
post_type: item
.post_type
.clone()
.unwrap_or_else(|| "article".to_string()),
image: item.image.clone(),
images: item
.images
@@ -684,7 +691,10 @@ pub async fn update(
.filter_map(|tag| tag.as_str().map(ToString::to_string))
.collect(),
pinned: item.pinned.unwrap_or(false),
status: item.status.clone().unwrap_or_else(|| content::POST_STATUS_PUBLISHED.to_string()),
status: item
.status
.clone()
.unwrap_or_else(|| content::POST_STATUS_PUBLISHED.to_string()),
visibility: item
.visibility
.clone()
@@ -696,9 +706,7 @@ pub async fn update(
og_image: item.og_image.clone(),
redirect_from: content::post_redirects_from_json(&item.redirect_from),
redirect_to: item.redirect_to.clone(),
file_path: content::markdown_post_path(&item.slug)
.to_string_lossy()
.to_string(),
file_path: content::virtual_markdown_document_path(&item.slug),
};
let _ = subscriptions::notify_post_published(&ctx, &post).await;
}
@@ -736,7 +744,6 @@ pub async fn get_one(
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let preview = request_preview_mode(query.preview, &headers);
let post = load_item(&ctx, id).await?;
@@ -754,7 +761,6 @@ pub async fn get_by_slug(
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let preview = request_preview_mode(query.preview, &headers);
let include_private = preview && query.include_private.unwrap_or(true);
let post = resolve_post_by_slug(&ctx, &slug).await?;
@@ -780,8 +786,7 @@ pub async fn get_markdown_by_slug(
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
content::sync_markdown_posts(&ctx).await?;
let (path, markdown) = content::read_markdown_document(&slug)?;
let (path, markdown) = content::read_markdown_document_from_store(&ctx, &slug).await?;
format::json(MarkdownDocumentResponse {
slug,
path,
@@ -807,7 +812,7 @@ pub async fn update_markdown_by_slug(
)
.await?;
let updated = content::write_markdown_document(&ctx, &slug, &params.markdown).await?;
let (path, markdown) = content::read_markdown_document(&updated.slug)?;
let (path, markdown) = content::read_markdown_document_from_store(&ctx, &updated.slug).await?;
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),
@@ -874,7 +879,7 @@ pub async fn create_markdown(
},
)
.await?;
let (path, markdown) = content::read_markdown_document(&created.slug)?;
let (path, markdown) = content::read_markdown_document_from_store(&ctx, &created.slug).await?;
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),
@@ -936,7 +941,9 @@ pub async fn import_markdown(
let imported = content::import_markdown_documents(&ctx, files).await?;
for item in &imported {
if let Ok((_path, markdown)) = content::read_markdown_document(&item.slug) {
if let Ok((_path, markdown)) =
content::read_markdown_document_from_store(&ctx, &item.slug).await
{
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),

View File

@@ -63,9 +63,7 @@ fn levenshtein_distance(left: &str, right: &str) -> usize {
let mut curr = vec![i + 1; right_chars.len() + 1];
for (j, right_ch) in right_chars.iter().enumerate() {
let cost = usize::from(left_ch != *right_ch);
curr[j + 1] = (curr[j] + 1)
.min(prev[j + 1] + 1)
.min(prev[j] + cost);
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
prev = curr;
}
@@ -157,7 +155,11 @@ fn candidate_terms(posts: &[posts::Model]) -> Vec<String> {
candidates
}
fn find_spelling_fallback(query: &str, posts: &[posts::Model], synonym_groups: &[Vec<String>]) -> Vec<String> {
fn find_spelling_fallback(
query: &str,
posts: &[posts::Model],
synonym_groups: &[Vec<String>],
) -> Vec<String> {
let primary_token = tokenize(query).into_iter().next().unwrap_or_default();
if primary_token.len() < 3 {
return Vec::new();
@@ -397,7 +399,6 @@ async fn build_search_results(
headers: &HeaderMap,
) -> Result<(String, bool, Vec<SearchResult>)> {
let preview_search = is_preview_search(query, headers);
content::sync_markdown_posts(ctx).await?;
let q = query.q.clone().unwrap_or_default().trim().to_string();
if q.is_empty() {
@@ -442,7 +443,12 @@ async fn build_search_results(
});
}
if let Some(tag) = query.tag.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if let Some(tag) = query
.tag
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
all_posts.retain(|post| post_has_tag(post, tag));
}
@@ -550,7 +556,8 @@ pub async fn search_page(
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
let sort_by = normalize_search_sort_by(query.sort_by.as_deref());
let sort_order = normalize_sort_order(query.sort_order.as_deref(), &sort_by);
let (q, preview_search, mut results) = build_search_results(&ctx, &query.search, &headers).await?;
let (q, preview_search, mut results) =
build_search_results(&ctx, &query.search, &headers).await?;
if q.is_empty() {
return format::json(PagedSearchResponse {

View File

@@ -93,8 +93,12 @@ pub struct SiteSettingsPayload {
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
pub paragraph_comments_enabled: Option<bool>,
#[serde(default, alias = "commentVerificationMode")]
pub comment_verification_mode: Option<String>,
#[serde(default, alias = "commentTurnstileEnabled")]
pub comment_turnstile_enabled: Option<bool>,
#[serde(default, alias = "subscriptionVerificationMode")]
pub subscription_verification_mode: Option<String>,
#[serde(default, alias = "subscriptionTurnstileEnabled")]
pub subscription_turnstile_enabled: Option<bool>,
#[serde(default, alias = "webPushEnabled")]
@@ -195,7 +199,9 @@ pub struct PublicSiteSettingsResponse {
pub music_playlist: Option<serde_json::Value>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
pub comment_turnstile_enabled: bool,
pub subscription_verification_mode: String,
pub subscription_turnstile_enabled: bool,
pub web_push_enabled: bool,
pub turnstile_site_key: Option<String>,
@@ -270,6 +276,9 @@ pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
18
}
const DEFAULT_TURNSTILE_SITE_KEY: &str = "0x4AAAAAACy58kMBSwXwqMhx";
const DEFAULT_TURNSTILE_SECRET_KEY: &str = "0x4AAAAAACy58m3gYfSqM-VIz4QK4wuO73U";
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
values
.into_iter()
@@ -549,11 +558,48 @@ impl SiteSettingsPayload {
if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled {
item.paragraph_comments_enabled = Some(paragraph_comments_enabled);
}
if let Some(comment_turnstile_enabled) = self.comment_turnstile_enabled {
if let Some(comment_verification_mode) = self
.comment_verification_mode
.as_deref()
.and_then(|value| crate::services::turnstile::normalize_verification_mode(Some(value)))
{
item.comment_verification_mode = Some(comment_verification_mode.as_str().to_string());
item.comment_turnstile_enabled = Some(matches!(
comment_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
));
} else if let Some(comment_turnstile_enabled) = self.comment_turnstile_enabled {
item.comment_turnstile_enabled = Some(comment_turnstile_enabled);
item.comment_verification_mode = Some(
if comment_turnstile_enabled {
crate::services::turnstile::VERIFICATION_MODE_TURNSTILE
} else {
crate::services::turnstile::VERIFICATION_MODE_CAPTCHA
}
.to_string(),
);
}
if let Some(subscription_turnstile_enabled) = self.subscription_turnstile_enabled {
if let Some(subscription_verification_mode) = self
.subscription_verification_mode
.as_deref()
.and_then(|value| crate::services::turnstile::normalize_verification_mode(Some(value)))
{
item.subscription_verification_mode =
Some(subscription_verification_mode.as_str().to_string());
item.subscription_turnstile_enabled = Some(matches!(
subscription_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
));
} else if let Some(subscription_turnstile_enabled) = self.subscription_turnstile_enabled {
item.subscription_turnstile_enabled = Some(subscription_turnstile_enabled);
item.subscription_verification_mode = Some(
if subscription_turnstile_enabled {
crate::services::turnstile::VERIFICATION_MODE_TURNSTILE
} else {
crate::services::turnstile::VERIFICATION_MODE_OFF
}
.to_string(),
);
}
if let Some(web_push_enabled) = self.web_push_enabled {
item.web_push_enabled = Some(web_push_enabled);
@@ -763,11 +809,17 @@ fn default_payload() -> SiteSettingsPayload {
]),
ai_enabled: Some(false),
paragraph_comments_enabled: Some(true),
comment_verification_mode: Some(
crate::services::turnstile::VERIFICATION_MODE_CAPTCHA.to_string(),
),
comment_turnstile_enabled: Some(false),
subscription_verification_mode: Some(
crate::services::turnstile::VERIFICATION_MODE_OFF.to_string(),
),
subscription_turnstile_enabled: Some(false),
web_push_enabled: Some(false),
turnstile_site_key: None,
turnstile_secret_key: None,
turnstile_site_key: Some(DEFAULT_TURNSTILE_SITE_KEY.to_string()),
turnstile_secret_key: Some(DEFAULT_TURNSTILE_SECRET_KEY.to_string()),
web_push_vapid_public_key: None,
web_push_vapid_private_key: None,
web_push_vapid_subject: None,
@@ -835,11 +887,11 @@ pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
fn public_response(model: Model) -> PublicSiteSettingsResponse {
let turnstile_site_key = crate::services::turnstile::site_key(&model);
let web_push_vapid_public_key = crate::services::web_push::public_key(&model);
let comment_turnstile_enabled = crate::services::turnstile::is_enabled(
let comment_verification_mode = crate::services::turnstile::effective_mode(
&model,
crate::services::turnstile::TurnstileScope::Comment,
);
let subscription_turnstile_enabled = crate::services::turnstile::is_enabled(
let subscription_verification_mode = crate::services::turnstile::effective_mode(
&model,
crate::services::turnstile::TurnstileScope::Subscription,
);
@@ -866,8 +918,16 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
music_playlist: model.music_playlist,
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
comment_turnstile_enabled,
subscription_turnstile_enabled,
comment_verification_mode: comment_verification_mode.as_str().to_string(),
comment_turnstile_enabled: matches!(
comment_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
),
subscription_verification_mode: subscription_verification_mode.as_str().to_string(),
subscription_turnstile_enabled: matches!(
subscription_verification_mode,
crate::services::turnstile::VerificationMode::Turnstile
),
web_push_enabled,
turnstile_site_key,
web_push_vapid_public_key,
@@ -890,8 +950,6 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
#[debug_handler]
pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let site_settings = public_response(load_current(&ctx).await?);
let posts = posts::Entity::find()
.order_by_desc(posts::Column::CreatedAt)

View File

@@ -14,6 +14,10 @@ pub struct PublicSubscriptionPayload {
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)]
@@ -23,6 +27,10 @@ pub struct PublicBrowserPushSubscriptionPayload {
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)]
@@ -81,6 +89,28 @@ fn public_browser_push_metadata(
})
}
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>,
@@ -94,10 +124,12 @@ pub async fn subscribe(
client_ip.as_deref(),
Some(&email),
)?;
let _ = turnstile::verify_if_enabled(
&ctx,
turnstile::TurnstileScope::Subscription,
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?;
@@ -165,10 +197,11 @@ pub async fn subscribe_browser_push(
.map(ToString::to_string);
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?;
let _ = turnstile::verify_if_enabled(
&ctx,
turnstile::TurnstileScope::Subscription,
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?;

View File

@@ -118,7 +118,10 @@ fn tag_values(post: &posts::Model) -> Vec<String> {
fn build_summary(item: &tags::Model, post_items: &[posts::Model]) -> TagSummary {
let name = tag_name(item);
let aliases = [name.trim().to_ascii_lowercase(), item.slug.trim().to_ascii_lowercase()];
let aliases = [
name.trim().to_ascii_lowercase(),
item.slug.trim().to_ascii_lowercase(),
];
let count = post_items
.iter()
.filter(|post| {
@@ -163,7 +166,6 @@ async fn load_item(ctx: &AppContext, id: i32) -> Result<tags::Model> {
#[debug_handler]
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let tag_items = tags::Entity::find()
.order_by_asc(tags::Column::Slug)
.all(&ctx.db)
@@ -234,10 +236,12 @@ pub async fn update(
!= Some(name.as_str())
{
content::rewrite_tag_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)?;
)
.await?;
}
let mut item = item.into_active_model();
@@ -249,16 +253,14 @@ pub async fn update(
item.seo_title = Set(trim_to_option(params.seo_title));
item.seo_description = Set(trim_to_option(params.seo_description));
let item = item.update(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::json(build_record(item))
}
#[debug_handler]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
let item = load_item(&ctx, id).await?;
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
content::rewrite_tag_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
content::sync_markdown_posts(&ctx).await?;
format::empty()
}