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
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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms.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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user