feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
@@ -22,7 +22,7 @@ use crate::{
|
||||
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
|
||||
reviews,
|
||||
},
|
||||
services::{admin_audit, ai, analytics, comment_guard, content, storage},
|
||||
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -170,6 +170,14 @@ pub struct AdminSiteSettingsResponse {
|
||||
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
pub comment_turnstile_enabled: bool,
|
||||
pub subscription_turnstile_enabled: bool,
|
||||
pub web_push_enabled: bool,
|
||||
pub turnstile_site_key: Option<String>,
|
||||
pub turnstile_secret_key: Option<String>,
|
||||
pub web_push_vapid_public_key: Option<String>,
|
||||
pub web_push_vapid_private_key: Option<String>,
|
||||
pub web_push_vapid_subject: Option<String>,
|
||||
pub ai_provider: Option<String>,
|
||||
pub ai_api_base: Option<String>,
|
||||
pub ai_api_key: Option<String>,
|
||||
@@ -196,6 +204,7 @@ pub struct AdminSiteSettingsResponse {
|
||||
pub seo_default_og_image: Option<String>,
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
pub notification_webhook_url: Option<String>,
|
||||
pub notification_channel_type: String,
|
||||
pub notification_comment_enabled: bool,
|
||||
pub notification_friend_link_enabled: bool,
|
||||
pub subscription_popup_enabled: bool,
|
||||
@@ -258,6 +267,11 @@ pub struct AdminMediaObjectResponse {
|
||||
pub url: String,
|
||||
pub size_bytes: i64,
|
||||
pub last_modified: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub alt_text: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -304,6 +318,32 @@ pub struct AdminMediaReplaceResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminMediaMetadataPayload {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub alt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub caption: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminMediaMetadataResponse {
|
||||
pub saved: bool,
|
||||
pub key: String,
|
||||
pub title: Option<String>,
|
||||
pub alt_text: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminMediaListQuery {
|
||||
pub prefix: Option<String>,
|
||||
@@ -634,6 +674,25 @@ fn normalize_media_key(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_media_object_response(
|
||||
item: storage::StoredObjectSummary,
|
||||
metadata: Option<&crate::models::_entities::media_assets::Model>,
|
||||
) -> AdminMediaObjectResponse {
|
||||
AdminMediaObjectResponse {
|
||||
key: item.key,
|
||||
url: item.url,
|
||||
size_bytes: item.size_bytes,
|
||||
last_modified: item.last_modified,
|
||||
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(),
|
||||
notes: metadata.and_then(|entry| entry.notes.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
||||
value
|
||||
.as_ref()
|
||||
@@ -665,6 +724,11 @@ 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 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);
|
||||
let web_push_vapid_private_key = crate::services::web_push::private_key(&item);
|
||||
let web_push_vapid_subject = crate::services::web_push::vapid_subject(&item);
|
||||
|
||||
AdminSiteSettingsResponse {
|
||||
id: item.id,
|
||||
@@ -687,6 +751,14 @@ 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),
|
||||
web_push_enabled: item.web_push_enabled.unwrap_or(false),
|
||||
turnstile_site_key,
|
||||
turnstile_secret_key,
|
||||
web_push_vapid_public_key,
|
||||
web_push_vapid_private_key,
|
||||
web_push_vapid_subject,
|
||||
ai_provider: item.ai_provider,
|
||||
ai_api_base: item.ai_api_base,
|
||||
ai_api_key: item.ai_api_key,
|
||||
@@ -713,6 +785,9 @@ fn build_settings_response(
|
||||
seo_default_og_image: item.seo_default_og_image,
|
||||
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
||||
notification_webhook_url: item.notification_webhook_url,
|
||||
notification_channel_type: item
|
||||
.notification_channel_type
|
||||
.unwrap_or_else(|| "webhook".to_string()),
|
||||
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
||||
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
||||
subscription_popup_enabled: item
|
||||
@@ -1115,14 +1190,18 @@ pub async fn list_media_objects(
|
||||
check_auth(&headers)?;
|
||||
|
||||
let settings = storage::require_r2_settings(&ctx).await?;
|
||||
let items = 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())
|
||||
.collect::<Vec<_>>();
|
||||
let metadata_map = media_assets::list_by_keys(&ctx, &keys).await?;
|
||||
let items = objects
|
||||
.into_iter()
|
||||
.map(|item| AdminMediaObjectResponse {
|
||||
key: item.key,
|
||||
url: item.url,
|
||||
size_bytes: item.size_bytes,
|
||||
last_modified: item.last_modified,
|
||||
.map(|item| {
|
||||
let metadata = metadata_map.get(&item.key);
|
||||
build_media_object_response(item, metadata)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -1148,6 +1227,9 @@ 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");
|
||||
}
|
||||
|
||||
format::json(AdminMediaDeleteResponse {
|
||||
deleted: true,
|
||||
@@ -1241,7 +1323,12 @@ pub async fn batch_delete_media_objects(
|
||||
|
||||
for key in keys {
|
||||
match storage::delete_object(&ctx, &key).await {
|
||||
Ok(()) => deleted.push(key),
|
||||
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");
|
||||
}
|
||||
deleted.push(key)
|
||||
}
|
||||
Err(_) => failed.push(key),
|
||||
}
|
||||
}
|
||||
@@ -1249,6 +1336,43 @@ pub async fn batch_delete_media_objects(
|
||||
format::json(AdminMediaBatchDeleteResponse { deleted, failed })
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_media_object_metadata(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<AdminMediaMetadataPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let key = payload.key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(Error::BadRequest("缺少对象 key".to_string()));
|
||||
}
|
||||
|
||||
let metadata = media_assets::upsert_by_key(
|
||||
&ctx,
|
||||
key,
|
||||
media_assets::MediaAssetMetadataInput {
|
||||
title: payload.title,
|
||||
alt_text: payload.alt_text,
|
||||
caption: payload.caption,
|
||||
tags: payload.tags,
|
||||
notes: payload.notes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(AdminMediaMetadataResponse {
|
||||
saved: true,
|
||||
key: metadata.object_key.clone(),
|
||||
title: metadata.title.clone(),
|
||||
alt_text: metadata.alt_text.clone(),
|
||||
caption: metadata.caption.clone(),
|
||||
tags: media_assets::tag_list(&metadata),
|
||||
notes: metadata.notes.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn replace_media_object(
|
||||
headers: HeaderMap,
|
||||
@@ -1831,6 +1955,7 @@ 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/replace", post(replace_media_object))
|
||||
.add(
|
||||
"/comments/blacklist",
|
||||
|
||||
@@ -11,7 +11,10 @@ use crate::{
|
||||
models::_entities::{
|
||||
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
|
||||
},
|
||||
services::{admin_audit, post_revisions as revision_service, subscriptions as subscription_service},
|
||||
services::{
|
||||
admin_audit, backups, post_revisions as revision_service,
|
||||
subscriptions as subscription_service,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
@@ -82,6 +85,13 @@ pub struct DigestDispatchRequest {
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SiteBackupImportRequest {
|
||||
pub backup: backups::SiteBackupDocument,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PostRevisionListItem {
|
||||
pub id: i32,
|
||||
@@ -440,6 +450,25 @@ pub async fn send_subscription_digest(
|
||||
format::json(summary)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn export_site_backup(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(backups::export_site_backup(&ctx).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn import_site_backup(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SiteBackupImportRequest>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(backups::import_site_backup(&ctx, payload.backup, payload.mode.as_deref()).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
@@ -452,4 +481,6 @@ pub fn routes() -> Routes {
|
||||
.add("/subscriptions/digest", post(send_subscription_digest))
|
||||
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
|
||||
.add("/subscriptions/{id}/test", post(test_subscription))
|
||||
.add("/site-backup/export", get(export_site_backup))
|
||||
.add("/site-backup/import", post(import_site_backup))
|
||||
}
|
||||
|
||||
465
backend/src/controllers/admin_taxonomy.rs
Normal file
465
backend/src/controllers/admin_taxonomy.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::{categories, posts, tags},
|
||||
services::content,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TaxonomyPayload {
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminCategoryRecord {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: usize,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminTagRecord {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: usize,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in value.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalized_name(params: &TaxonomyPayload, label: &str) -> Result<String> {
|
||||
params
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| Error::BadRequest(format!("{label}名称不能为空")))
|
||||
}
|
||||
|
||||
fn normalized_slug(value: Option<&str>, fallback: &str, label: &str) -> Result<String> {
|
||||
let slug = value
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| slugify(fallback));
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{label} slug 不能为空,请填写英文字母 / 数字 / 连字符"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
fn normalized_token(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn post_tag_values(post: &posts::Model) -> Vec<String> {
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(|value| serde_json::from_value::<Vec<String>>(value.clone()).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| normalized_token(&item))
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn category_name(item: &categories::Model) -> String {
|
||||
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||
}
|
||||
|
||||
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 {
|
||||
let name = category_name(item);
|
||||
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
|
||||
let count = post_items
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
post.category
|
||||
.as_deref()
|
||||
.map(normalized_token)
|
||||
.is_some_and(|value| aliases.iter().any(|alias| alias == &value))
|
||||
})
|
||||
.count();
|
||||
|
||||
AdminCategoryRecord {
|
||||
id: item.id,
|
||||
name,
|
||||
slug: item.slug.clone(),
|
||||
count,
|
||||
description: item.description.clone(),
|
||||
cover_image: item.cover_image.clone(),
|
||||
accent_color: item.accent_color.clone(),
|
||||
seo_title: item.seo_title.clone(),
|
||||
seo_description: item.seo_description.clone(),
|
||||
created_at: item.created_at.to_rfc3339(),
|
||||
updated_at: item.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tag_record(item: &tags::Model, post_items: &[posts::Model]) -> AdminTagRecord {
|
||||
let name = tag_name(item);
|
||||
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
|
||||
let count = post_items
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
post_tag_values(post)
|
||||
.into_iter()
|
||||
.any(|value| aliases.iter().any(|alias| alias == &value))
|
||||
})
|
||||
.count();
|
||||
|
||||
AdminTagRecord {
|
||||
id: item.id,
|
||||
name,
|
||||
slug: item.slug.clone(),
|
||||
count,
|
||||
description: item.description.clone(),
|
||||
cover_image: item.cover_image.clone(),
|
||||
accent_color: item.accent_color.clone(),
|
||||
seo_title: item.seo_title.clone(),
|
||||
seo_description: item.seo_description.clone(),
|
||||
created_at: item.created_at.to_rfc3339(),
|
||||
updated_at: item.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_category(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
categories::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
async fn load_tag(ctx: &AppContext, id: i32) -> Result<tags::Model> {
|
||||
tags::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
async fn ensure_category_slug_unique(
|
||||
ctx: &AppContext,
|
||||
slug: &str,
|
||||
exclude_id: Option<i32>,
|
||||
) -> Result<()> {
|
||||
if let Some(existing) = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(slug))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
{
|
||||
if Some(existing.id) != exclude_id {
|
||||
return Err(Error::BadRequest("分类 slug 已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)
|
||||
.await?
|
||||
{
|
||||
if Some(existing.id) != exclude_id {
|
||||
return Err(Error::BadRequest("标签 slug 已存在".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_posts(ctx: &AppContext) -> Result<Vec<posts::Model>> {
|
||||
Ok(posts::Entity::find().all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
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)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let post_items = load_posts(&ctx).await?;
|
||||
|
||||
format::json(
|
||||
items.into_iter()
|
||||
.map(|item| build_category_record(&item, &post_items))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn create_category(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<TaxonomyPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let name = normalized_name(&payload, "分类")?;
|
||||
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
|
||||
ensure_category_slug_unique(&ctx, &slug, None).await?;
|
||||
|
||||
let item = categories::ActiveModel {
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
description: Set(trim_to_option(payload.description)),
|
||||
cover_image: Set(trim_to_option(payload.cover_image)),
|
||||
accent_color: Set(trim_to_option(payload.accent_color)),
|
||||
seo_title: Set(trim_to_option(payload.seo_title)),
|
||||
seo_description: Set(trim_to_option(payload.seo_description)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
let post_items = load_posts(&ctx).await?;
|
||||
|
||||
format::json(build_category_record(&item, &post_items))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_category(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<TaxonomyPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let name = normalized_name(&payload, "分类")?;
|
||||
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
|
||||
ensure_category_slug_unique(&ctx, &slug, Some(id)).await?;
|
||||
|
||||
let item = load_category(&ctx, id).await?;
|
||||
let previous_name = item.name.clone();
|
||||
let previous_slug = item.slug.clone();
|
||||
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
}
|
||||
|
||||
let mut active = item.into_active_model();
|
||||
active.name = Set(Some(name));
|
||||
active.slug = Set(slug);
|
||||
active.description = Set(trim_to_option(payload.description));
|
||||
active.cover_image = Set(trim_to_option(payload.cover_image));
|
||||
active.accent_color = Set(trim_to_option(payload.accent_color));
|
||||
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))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_category(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let item = load_category(&ctx, id).await?;
|
||||
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[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)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let post_items = load_posts(&ctx).await?;
|
||||
|
||||
format::json(
|
||||
items.into_iter()
|
||||
.map(|item| build_tag_record(&item, &post_items))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn create_tag(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<TaxonomyPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let name = normalized_name(&payload, "标签")?;
|
||||
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
|
||||
ensure_tag_slug_unique(&ctx, &slug, None).await?;
|
||||
|
||||
let item = tags::ActiveModel {
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
description: Set(trim_to_option(payload.description)),
|
||||
cover_image: Set(trim_to_option(payload.cover_image)),
|
||||
accent_color: Set(trim_to_option(payload.accent_color)),
|
||||
seo_title: Set(trim_to_option(payload.seo_title)),
|
||||
seo_description: Set(trim_to_option(payload.seo_description)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
let post_items = load_posts(&ctx).await?;
|
||||
|
||||
format::json(build_tag_record(&item, &post_items))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_tag(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<TaxonomyPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let name = normalized_name(&payload, "标签")?;
|
||||
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
|
||||
ensure_tag_slug_unique(&ctx, &slug, Some(id)).await?;
|
||||
|
||||
let item = load_tag(&ctx, id).await?;
|
||||
let previous_name = item.name.clone();
|
||||
let previous_slug = item.slug.clone();
|
||||
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
}
|
||||
|
||||
let mut active = item.into_active_model();
|
||||
active.name = Set(Some(name));
|
||||
active.slug = Set(slug);
|
||||
active.description = Set(trim_to_option(payload.description));
|
||||
active.cover_image = Set(trim_to_option(payload.cover_image));
|
||||
active.accent_color = Set(trim_to_option(payload.accent_color));
|
||||
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))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_tag(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let item = load_tag(&ctx, id).await?;
|
||||
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
format::empty()
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add(
|
||||
"/api/admin/categories",
|
||||
get(list_categories).post(create_category),
|
||||
)
|
||||
.add(
|
||||
"/api/admin/categories/{id}",
|
||||
patch(update_category).delete(delete_category),
|
||||
)
|
||||
.add("/api/admin/tags", get(list_tags).post(create_tag))
|
||||
.add("/api/admin/tags/{id}", patch(update_tag).delete(delete_tag))
|
||||
}
|
||||
@@ -14,12 +14,41 @@ pub struct CategorySummary {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: usize,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CategoryRecord {
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub name: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_description: Option<String>,
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
@@ -39,6 +68,17 @@ fn slugify(value: &str) -> String {
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_name(params: &Params) -> Result<String> {
|
||||
let name = params
|
||||
.name
|
||||
@@ -60,6 +100,50 @@ fn normalized_slug(params: &Params, fallback: &str) -> String {
|
||||
.unwrap_or_else(|| slugify(fallback))
|
||||
}
|
||||
|
||||
fn category_name(item: &categories::Model) -> String {
|
||||
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||
}
|
||||
|
||||
fn build_summary(item: &categories::Model, post_items: &[posts::Model]) -> CategorySummary {
|
||||
let name = category_name(item);
|
||||
let count = post_items
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
post.category
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(&name) || value.eq_ignore_ascii_case(&item.slug))
|
||||
})
|
||||
.count();
|
||||
|
||||
CategorySummary {
|
||||
id: item.id,
|
||||
name,
|
||||
slug: item.slug.clone(),
|
||||
count,
|
||||
description: item.description.clone(),
|
||||
cover_image: item.cover_image.clone(),
|
||||
accent_color: item.accent_color.clone(),
|
||||
seo_title: item.seo_title.clone(),
|
||||
seo_description: item.seo_description.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_record(item: categories::Model) -> CategoryRecord {
|
||||
CategoryRecord {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
description: item.description,
|
||||
cover_image: item.cover_image,
|
||||
accent_color: item.accent_color,
|
||||
seo_title: item.seo_title,
|
||||
seo_description: item.seo_description,
|
||||
created_at: item.created_at.to_rfc3339(),
|
||||
updated_at: item.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
let item = categories::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or(Error::NotFound)
|
||||
@@ -77,23 +161,7 @@ pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
|
||||
let categories = category_items
|
||||
.into_iter()
|
||||
.map(|category| {
|
||||
let name = category
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| category.slug.clone());
|
||||
let count = post_items
|
||||
.iter()
|
||||
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
|
||||
.count();
|
||||
|
||||
CategorySummary {
|
||||
id: category.id,
|
||||
name,
|
||||
slug: category.slug,
|
||||
count,
|
||||
}
|
||||
})
|
||||
.map(|category| build_summary(&category, &post_items))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(categories)
|
||||
@@ -113,18 +181,28 @@ pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> R
|
||||
let mut model = existing_category.into_active_model();
|
||||
model.name = Set(Some(name));
|
||||
model.slug = Set(slug);
|
||||
model.description = Set(trim_to_option(params.description));
|
||||
model.cover_image = Set(trim_to_option(params.cover_image));
|
||||
model.accent_color = Set(trim_to_option(params.accent_color));
|
||||
model.seo_title = Set(trim_to_option(params.seo_title));
|
||||
model.seo_description = Set(trim_to_option(params.seo_description));
|
||||
model.update(&ctx.db).await?
|
||||
} else {
|
||||
categories::ActiveModel {
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
description: Set(trim_to_option(params.description)),
|
||||
cover_image: Set(trim_to_option(params.cover_image)),
|
||||
accent_color: Set(trim_to_option(params.accent_color)),
|
||||
seo_title: Set(trim_to_option(params.seo_title)),
|
||||
seo_description: Set(trim_to_option(params.seo_description)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
format::json(item)
|
||||
format::json(build_record(item))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -155,9 +233,14 @@ pub async fn update(
|
||||
let mut item = item.into_active_model();
|
||||
item.name = Set(Some(name));
|
||||
item.slug = Set(slug);
|
||||
item.description = Set(trim_to_option(params.description));
|
||||
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||
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(item)
|
||||
format::json(build_record(item))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -171,7 +254,7 @@ pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Resul
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
format::json(build_record(load_item(&ctx, id).await?))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
@@ -122,6 +122,8 @@ pub struct CreateCommentRequest {
|
||||
pub captcha_token: Option<String>,
|
||||
#[serde(default, alias = "captchaAnswer")]
|
||||
pub captcha_answer: Option<String>,
|
||||
#[serde(default, alias = "turnstileToken")]
|
||||
pub turnstile_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub website: Option<String>,
|
||||
}
|
||||
@@ -383,6 +385,7 @@ pub async fn add(
|
||||
author: author.as_deref(),
|
||||
content: content.as_deref(),
|
||||
honeypot_website: params.website.as_deref(),
|
||||
turnstile_token: params.turnstile_token.as_deref(),
|
||||
captcha_token: params.captcha_token.as_deref(),
|
||||
captcha_answer: params.captcha_answer.as_deref(),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod admin_api;
|
||||
pub mod admin_taxonomy;
|
||||
pub mod admin_ops;
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
|
||||
@@ -95,6 +95,60 @@ fn publicly_accessible(post: &Model) -> bool {
|
||||
content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
|
||||
}
|
||||
|
||||
fn normalize_post_sort_by(value: Option<&str>) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"updated_at" | "updated" => "updated_at".to_string(),
|
||||
"title" => "title".to_string(),
|
||||
_ => "created_at".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_sort_order(value: Option<&str>) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"asc" => "asc".to_string(),
|
||||
_ => "desc".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_posts(items: &mut [Model], sort_by: &str, sort_order: &str) {
|
||||
items.sort_by(|left, right| {
|
||||
let ordering = match sort_by {
|
||||
"updated_at" => left.updated_at.cmp(&right.updated_at),
|
||||
"title" => left
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&left.slug)
|
||||
.to_ascii_lowercase()
|
||||
.cmp(
|
||||
&right
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&right.slug)
|
||||
.to_ascii_lowercase(),
|
||||
),
|
||||
_ => left.created_at.cmp(&right.created_at),
|
||||
};
|
||||
|
||||
let ordering = if sort_order == "asc" {
|
||||
ordering
|
||||
} else {
|
||||
ordering.reverse()
|
||||
};
|
||||
|
||||
ordering.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_optional_markdown_datetime(
|
||||
value: Option<&str>,
|
||||
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||
@@ -388,6 +442,28 @@ pub struct ListQuery {
|
||||
pub preview: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct PagedPostsQuery {
|
||||
#[serde(flatten)]
|
||||
pub filters: ListQuery,
|
||||
pub page: Option<u64>,
|
||||
#[serde(alias = "page_size")]
|
||||
pub page_size: Option<u64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PagedPostsResponse {
|
||||
pub items: Vec<Model>,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total: usize,
|
||||
pub total_pages: u64,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct LookupQuery {
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
@@ -469,6 +545,61 @@ pub async fn list(
|
||||
format::json(filtered)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_page(
|
||||
Query(query): Query<PagedPostsQuery>,
|
||||
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);
|
||||
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||||
let sort_by = normalize_post_sort_by(query.sort_by.as_deref());
|
||||
let sort_order = normalize_sort_order(query.sort_order.as_deref());
|
||||
|
||||
let mut filtered = Entity::find()
|
||||
.order_by_desc(Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|post| {
|
||||
should_include_post(
|
||||
post,
|
||||
&query.filters,
|
||||
preview,
|
||||
include_private,
|
||||
include_redirects,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sort_posts(&mut filtered, &sort_by, &sort_order);
|
||||
|
||||
let total = filtered.len();
|
||||
let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
|
||||
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||
let start = ((page - 1) * page_size) as usize;
|
||||
let end = std::cmp::min(start + page_size as usize, total);
|
||||
let items = if start >= total {
|
||||
Vec::new()
|
||||
} else {
|
||||
filtered[start..end].to_vec()
|
||||
};
|
||||
|
||||
format::json(PagedPostsResponse {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages,
|
||||
sort_by,
|
||||
sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(
|
||||
headers: HeaderMap,
|
||||
@@ -876,6 +1007,7 @@ pub async fn delete_markdown_by_slug(
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/posts/")
|
||||
.add("page", get(list_page))
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("markdown", post(create_markdown))
|
||||
|
||||
@@ -274,6 +274,71 @@ fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn normalize_search_sort_by(value: Option<&str>) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"newest" | "created_at" => "newest".to_string(),
|
||||
"oldest" => "oldest".to_string(),
|
||||
"title" => "title".to_string(),
|
||||
_ => "relevance".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_sort_order(value: Option<&str>, sort_by: &str) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"asc" => "asc".to_string(),
|
||||
"desc" => "desc".to_string(),
|
||||
_ if sort_by == "title" => "asc".to_string(),
|
||||
_ => "desc".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_search_results(items: &mut [SearchResult], sort_by: &str, sort_order: &str) {
|
||||
items.sort_by(|left, right| {
|
||||
let ordering = match sort_by {
|
||||
"newest" => right.created_at.cmp(&left.created_at),
|
||||
"oldest" => left.created_at.cmp(&right.created_at),
|
||||
"title" => left
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&left.slug)
|
||||
.to_ascii_lowercase()
|
||||
.cmp(
|
||||
&right
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&right.slug)
|
||||
.to_ascii_lowercase(),
|
||||
),
|
||||
_ => right
|
||||
.rank
|
||||
.partial_cmp(&left.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at)),
|
||||
};
|
||||
|
||||
if sort_by == "relevance" || sort_by == "newest" || sort_by == "oldest" {
|
||||
return ordering;
|
||||
}
|
||||
|
||||
let ordering = if sort_order == "asc" {
|
||||
ordering
|
||||
} else {
|
||||
ordering.reverse()
|
||||
};
|
||||
ordering.then_with(|| left.slug.cmp(&right.slug))
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
@@ -286,6 +351,17 @@ pub struct SearchQuery {
|
||||
pub preview: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SearchPageQuery {
|
||||
#[serde(flatten)]
|
||||
pub search: SearchQuery,
|
||||
pub page: Option<u64>,
|
||||
#[serde(alias = "page_size")]
|
||||
pub page_size: Option<u64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub id: i32,
|
||||
@@ -296,37 +372,47 @@ pub struct SearchResult {
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<Value>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub rank: f64,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
let preview_search = is_preview_search(&query, &headers);
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PagedSearchResponse {
|
||||
pub query: String,
|
||||
pub items: Vec<SearchResult>,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total: usize,
|
||||
pub total_pages: u64,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
}
|
||||
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
async fn build_search_results(
|
||||
ctx: &AppContext,
|
||||
query: &SearchQuery,
|
||||
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() {
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
return Ok((q, preview_search, Vec::new()));
|
||||
}
|
||||
|
||||
if !preview_search {
|
||||
abuse_guard::enforce_public_scope(
|
||||
"search",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
abuse_guard::detect_client_ip(headers).as_deref(),
|
||||
Some(&q),
|
||||
)?;
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||
let settings = site_settings::load_current(&ctx).await.ok();
|
||||
let settings = site_settings::load_current(ctx).await.ok();
|
||||
let synonym_groups = settings
|
||||
.as_ref()
|
||||
.map(|item| parse_synonym_groups(&item.search_synonyms))
|
||||
@@ -342,7 +428,12 @@ pub async fn search(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(category) = query
|
||||
.category
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
all_posts.retain(|post| {
|
||||
post.category
|
||||
.as_deref()
|
||||
@@ -355,7 +446,12 @@ pub async fn search(
|
||||
all_posts.retain(|post| post_has_tag(post, tag));
|
||||
}
|
||||
|
||||
if let Some(post_type) = query.post_type.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(post_type) = query
|
||||
.post_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
all_posts.retain(|post| {
|
||||
post.post_type
|
||||
.as_deref()
|
||||
@@ -378,6 +474,7 @@ pub async fn search(
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
image: post.image.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
@@ -401,6 +498,7 @@ pub async fn search(
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
image: post.image.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
@@ -410,13 +508,22 @@ pub async fn search(
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|left, right| {
|
||||
right
|
||||
.rank
|
||||
.partial_cmp(&left.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
sort_search_results(&mut results, "relevance", "desc");
|
||||
Ok((q, preview_search, results))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||
let (q, preview_search, mut results) = build_search_results(&ctx, &query, &headers).await?;
|
||||
if q.is_empty() {
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
}
|
||||
results.truncate(limit);
|
||||
|
||||
if !preview_search {
|
||||
@@ -433,6 +540,70 @@ pub async fn search(
|
||||
format::json(results)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().prefix("api/search/").add("/", get(search))
|
||||
#[debug_handler]
|
||||
pub async fn search_page(
|
||||
Query(query): Query<SearchPageQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
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?;
|
||||
|
||||
if q.is_empty() {
|
||||
return format::json(PagedSearchResponse {
|
||||
query: q,
|
||||
items: Vec::new(),
|
||||
page: 1,
|
||||
page_size,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
sort_by,
|
||||
sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
sort_search_results(&mut results, &sort_by, &sort_order);
|
||||
|
||||
let total = results.len();
|
||||
let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
|
||||
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||
let start = ((page - 1) * page_size) as usize;
|
||||
let end = std::cmp::min(start + page_size as usize, total);
|
||||
let items = if start >= total {
|
||||
Vec::new()
|
||||
} else {
|
||||
results[start..end].to_vec()
|
||||
};
|
||||
|
||||
if !preview_search {
|
||||
analytics::record_search_event(
|
||||
&ctx,
|
||||
&q,
|
||||
total,
|
||||
&headers,
|
||||
started_at.elapsed().as_millis() as i64,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
format::json(PagedSearchResponse {
|
||||
query: q,
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages,
|
||||
sort_by,
|
||||
sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/search/")
|
||||
.add("page", get(search_page))
|
||||
.add("/", get(search))
|
||||
}
|
||||
|
||||
@@ -93,6 +93,22 @@ pub struct SiteSettingsPayload {
|
||||
pub ai_enabled: Option<bool>,
|
||||
#[serde(default, alias = "paragraphCommentsEnabled")]
|
||||
pub paragraph_comments_enabled: Option<bool>,
|
||||
#[serde(default, alias = "commentTurnstileEnabled")]
|
||||
pub comment_turnstile_enabled: Option<bool>,
|
||||
#[serde(default, alias = "subscriptionTurnstileEnabled")]
|
||||
pub subscription_turnstile_enabled: Option<bool>,
|
||||
#[serde(default, alias = "webPushEnabled")]
|
||||
pub web_push_enabled: Option<bool>,
|
||||
#[serde(default, alias = "turnstileSiteKey")]
|
||||
pub turnstile_site_key: Option<String>,
|
||||
#[serde(default, alias = "turnstileSecretKey")]
|
||||
pub turnstile_secret_key: Option<String>,
|
||||
#[serde(default, alias = "webPushVapidPublicKey")]
|
||||
pub web_push_vapid_public_key: Option<String>,
|
||||
#[serde(default, alias = "webPushVapidPrivateKey")]
|
||||
pub web_push_vapid_private_key: Option<String>,
|
||||
#[serde(default, alias = "webPushVapidSubject")]
|
||||
pub web_push_vapid_subject: Option<String>,
|
||||
#[serde(default, alias = "aiProvider")]
|
||||
pub ai_provider: Option<String>,
|
||||
#[serde(default, alias = "aiApiBase")]
|
||||
@@ -139,6 +155,8 @@ pub struct SiteSettingsPayload {
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
#[serde(default, alias = "notificationWebhookUrl")]
|
||||
pub notification_webhook_url: Option<String>,
|
||||
#[serde(default, alias = "notificationChannelType")]
|
||||
pub notification_channel_type: Option<String>,
|
||||
#[serde(default, alias = "notificationCommentEnabled")]
|
||||
pub notification_comment_enabled: Option<bool>,
|
||||
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
||||
@@ -177,6 +195,11 @@ pub struct PublicSiteSettingsResponse {
|
||||
pub music_playlist: Option<serde_json::Value>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
pub comment_turnstile_enabled: bool,
|
||||
pub subscription_turnstile_enabled: bool,
|
||||
pub web_push_enabled: bool,
|
||||
pub turnstile_site_key: Option<String>,
|
||||
pub web_push_vapid_public_key: Option<String>,
|
||||
pub subscription_popup_enabled: bool,
|
||||
pub subscription_popup_title: String,
|
||||
pub subscription_popup_description: String,
|
||||
@@ -220,6 +243,17 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
||||
value.map(|item| item.clamp(min, max))
|
||||
}
|
||||
|
||||
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let normalized = item.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"ntfy" => Some("ntfy".to_string()),
|
||||
"webhook" => Some("webhook".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn default_subscription_popup_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -515,6 +549,32 @@ 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 {
|
||||
item.comment_turnstile_enabled = Some(comment_turnstile_enabled);
|
||||
}
|
||||
if let Some(subscription_turnstile_enabled) = self.subscription_turnstile_enabled {
|
||||
item.subscription_turnstile_enabled = Some(subscription_turnstile_enabled);
|
||||
}
|
||||
if let Some(web_push_enabled) = self.web_push_enabled {
|
||||
item.web_push_enabled = Some(web_push_enabled);
|
||||
}
|
||||
if let Some(turnstile_site_key) = self.turnstile_site_key {
|
||||
item.turnstile_site_key = normalize_optional_string(Some(turnstile_site_key));
|
||||
}
|
||||
if let Some(turnstile_secret_key) = self.turnstile_secret_key {
|
||||
item.turnstile_secret_key = normalize_optional_string(Some(turnstile_secret_key));
|
||||
}
|
||||
if let Some(web_push_vapid_public_key) = self.web_push_vapid_public_key {
|
||||
item.web_push_vapid_public_key =
|
||||
normalize_optional_string(Some(web_push_vapid_public_key));
|
||||
}
|
||||
if let Some(web_push_vapid_private_key) = self.web_push_vapid_private_key {
|
||||
item.web_push_vapid_private_key =
|
||||
normalize_optional_string(Some(web_push_vapid_private_key));
|
||||
}
|
||||
if let Some(web_push_vapid_subject) = self.web_push_vapid_subject {
|
||||
item.web_push_vapid_subject = normalize_optional_string(Some(web_push_vapid_subject));
|
||||
}
|
||||
let provider_list_supplied = self.ai_providers.is_some();
|
||||
let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs);
|
||||
let requested_active_provider_id = self
|
||||
@@ -591,6 +651,10 @@ impl SiteSettingsPayload {
|
||||
item.notification_webhook_url =
|
||||
normalize_optional_string(Some(notification_webhook_url));
|
||||
}
|
||||
if self.notification_channel_type.is_some() {
|
||||
item.notification_channel_type =
|
||||
normalize_notification_channel_type(self.notification_channel_type);
|
||||
}
|
||||
if let Some(notification_comment_enabled) = self.notification_comment_enabled {
|
||||
item.notification_comment_enabled = Some(notification_comment_enabled);
|
||||
}
|
||||
@@ -699,6 +763,14 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
]),
|
||||
ai_enabled: Some(false),
|
||||
paragraph_comments_enabled: Some(true),
|
||||
comment_turnstile_enabled: Some(false),
|
||||
subscription_turnstile_enabled: Some(false),
|
||||
web_push_enabled: Some(false),
|
||||
turnstile_site_key: None,
|
||||
turnstile_secret_key: None,
|
||||
web_push_vapid_public_key: None,
|
||||
web_push_vapid_private_key: None,
|
||||
web_push_vapid_subject: None,
|
||||
ai_provider: Some(ai::provider_name(None)),
|
||||
ai_api_base: Some(ai::default_api_base().to_string()),
|
||||
ai_api_key: Some(ai::default_api_key().to_string()),
|
||||
@@ -725,6 +797,7 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
seo_default_og_image: None,
|
||||
seo_default_twitter_handle: None,
|
||||
notification_webhook_url: None,
|
||||
notification_channel_type: Some("webhook".to_string()),
|
||||
notification_comment_enabled: Some(false),
|
||||
notification_friend_link_enabled: Some(false),
|
||||
subscription_popup_enabled: Some(default_subscription_popup_enabled()),
|
||||
@@ -760,6 +833,18 @@ 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(
|
||||
&model,
|
||||
crate::services::turnstile::TurnstileScope::Comment,
|
||||
);
|
||||
let subscription_turnstile_enabled = crate::services::turnstile::is_enabled(
|
||||
&model,
|
||||
crate::services::turnstile::TurnstileScope::Subscription,
|
||||
);
|
||||
let web_push_enabled = crate::services::web_push::is_enabled(&model);
|
||||
|
||||
PublicSiteSettingsResponse {
|
||||
id: model.id,
|
||||
site_name: model.site_name,
|
||||
@@ -781,6 +866,11 @@ 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,
|
||||
web_push_enabled,
|
||||
turnstile_site_key,
|
||||
web_push_vapid_public_key,
|
||||
subscription_popup_enabled: model
|
||||
.subscription_popup_enabled
|
||||
.unwrap_or_else(default_subscription_popup_enabled),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::services::{abuse_guard, admin_audit, subscriptions};
|
||||
use axum::http::header;
|
||||
|
||||
use crate::services::{abuse_guard, admin_audit, subscriptions, turnstile};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PublicSubscriptionPayload {
|
||||
@@ -10,6 +12,17 @@ pub struct PublicSubscriptionPayload {
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default, alias = "turnstileToken")]
|
||||
pub turnstile_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PublicBrowserPushSubscriptionPayload {
|
||||
pub subscription: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
#[serde(default, alias = "turnstileToken")]
|
||||
pub turnstile_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -55,6 +68,19 @@ fn public_subscription_metadata(source: Option<String>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn public_browser_push_metadata(
|
||||
source: Option<String>,
|
||||
subscription: serde_json::Value,
|
||||
user_agent: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"source": source,
|
||||
"kind": "browser-push",
|
||||
"subscription": subscription,
|
||||
"user_agent": user_agent,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn subscribe(
|
||||
State(ctx): State<AppContext>,
|
||||
@@ -62,11 +88,19 @@ pub async fn subscribe(
|
||||
Json(payload): Json<PublicSubscriptionPayload>,
|
||||
) -> Result<Response> {
|
||||
let email = payload.email.trim().to_ascii_lowercase();
|
||||
let client_ip = abuse_guard::detect_client_ip(&headers);
|
||||
abuse_guard::enforce_public_scope(
|
||||
"subscription",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
client_ip.as_deref(),
|
||||
Some(&email),
|
||||
)?;
|
||||
let _ = turnstile::verify_if_enabled(
|
||||
&ctx,
|
||||
turnstile::TurnstileScope::Subscription,
|
||||
payload.turnstile_token.as_deref(),
|
||||
client_ip.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = subscriptions::create_public_email_subscription(
|
||||
&ctx,
|
||||
@@ -103,6 +137,76 @@ pub async fn subscribe(
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn subscribe_browser_push(
|
||||
State(ctx): State<AppContext>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<PublicBrowserPushSubscriptionPayload>,
|
||||
) -> Result<Response> {
|
||||
let settings = crate::controllers::site_settings::load_current(&ctx).await?;
|
||||
if !crate::services::web_push::is_enabled(&settings) {
|
||||
return Err(Error::BadRequest("浏览器推送未启用".to_string()));
|
||||
}
|
||||
|
||||
let endpoint = payload
|
||||
.subscription
|
||||
.get("endpoint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()))?
|
||||
.to_string();
|
||||
let client_ip = abuse_guard::detect_client_ip(&headers);
|
||||
let user_agent = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?;
|
||||
let _ = turnstile::verify_if_enabled(
|
||||
&ctx,
|
||||
turnstile::TurnstileScope::Subscription,
|
||||
payload.turnstile_token.as_deref(),
|
||||
client_ip.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = subscriptions::create_public_web_push_subscription(
|
||||
&ctx,
|
||||
payload.subscription.clone(),
|
||||
Some(public_browser_push_metadata(
|
||||
payload.source,
|
||||
payload.subscription,
|
||||
user_agent,
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
None,
|
||||
"subscription.public.web_push.active",
|
||||
"subscription",
|
||||
Some(result.subscription.id.to_string()),
|
||||
Some(result.subscription.target.clone()),
|
||||
Some(serde_json::json!({
|
||||
"channel_type": result.subscription.channel_type,
|
||||
"status": result.subscription.status,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(PublicSubscriptionResponse {
|
||||
ok: true,
|
||||
subscription_id: result.subscription.id,
|
||||
status: result.subscription.status,
|
||||
requires_confirmation: false,
|
||||
message: result.message,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn confirm(
|
||||
State(ctx): State<AppContext>,
|
||||
@@ -196,6 +300,7 @@ pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/subscriptions")
|
||||
.add("/", post(subscribe))
|
||||
.add("/browser-push", post(subscribe_browser_push))
|
||||
.add("/confirm", post(confirm))
|
||||
.add("/manage", get(manage).patch(update_manage))
|
||||
.add("/unsubscribe", post(unsubscribe))
|
||||
|
||||
@@ -2,43 +2,217 @@
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::_entities::tags::{ActiveModel, Entity, Model};
|
||||
use crate::models::_entities::{posts, tags};
|
||||
use crate::services::content;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TagSummary {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: usize,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TagRecord {
|
||||
pub id: i32,
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub seo_title: Option<String>,
|
||||
pub seo_description: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub name: Option<String>,
|
||||
pub slug: String,
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub seo_description: Option<String>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn update(&self, item: &mut ActiveModel) {
|
||||
item.name = Set(self.name.clone());
|
||||
item.slug = Set(self.slug.clone());
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in value.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalized_name(params: &Params) -> Result<String> {
|
||||
params
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| Error::BadRequest("tag name is required".to_string()))
|
||||
}
|
||||
|
||||
fn normalized_slug(params: &Params, fallback: &str) -> String {
|
||||
params
|
||||
.slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| slugify(fallback))
|
||||
}
|
||||
|
||||
fn tag_name(item: &tags::Model) -> String {
|
||||
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||
}
|
||||
|
||||
fn tag_values(post: &posts::Model) -> Vec<String> {
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|item| item.as_str().map(|value| value.trim().to_ascii_lowercase()))
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
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 count = post_items
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
tag_values(post)
|
||||
.into_iter()
|
||||
.any(|value| aliases.iter().any(|alias| alias == &value))
|
||||
})
|
||||
.count();
|
||||
|
||||
TagSummary {
|
||||
id: item.id,
|
||||
name,
|
||||
slug: item.slug.clone(),
|
||||
count,
|
||||
description: item.description.clone(),
|
||||
cover_image: item.cover_image.clone(),
|
||||
accent_color: item.accent_color.clone(),
|
||||
seo_title: item.seo_title.clone(),
|
||||
seo_description: item.seo_description.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
fn build_record(item: tags::Model) -> TagRecord {
|
||||
TagRecord {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
description: item.description,
|
||||
cover_image: item.cover_image,
|
||||
accent_color: item.accent_color,
|
||||
seo_title: item.seo_title,
|
||||
seo_description: item.seo_description,
|
||||
created_at: item.created_at.to_rfc3339(),
|
||||
updated_at: item.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<tags::Model> {
|
||||
let item = tags::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(Entity::find().all(&ctx.db).await?)
|
||||
let tag_items = tags::Entity::find()
|
||||
.order_by_asc(tags::Column::Slug)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let post_items = posts::Entity::find().all(&ctx.db).await?;
|
||||
|
||||
format::json(
|
||||
tag_items
|
||||
.into_iter()
|
||||
.map(|item| build_summary(&item, &post_items))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
let name = normalized_name(¶ms)?;
|
||||
let slug = normalized_slug(¶ms, &name);
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Slug.eq(&slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let item = if let Some(existing_tag) = existing {
|
||||
let mut item = existing_tag.into_active_model();
|
||||
item.name = Set(Some(name));
|
||||
item.slug = Set(slug);
|
||||
item.description = Set(trim_to_option(params.description));
|
||||
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||
item.seo_title = Set(trim_to_option(params.seo_title));
|
||||
item.seo_description = Set(trim_to_option(params.seo_description));
|
||||
item.update(&ctx.db).await?
|
||||
} else {
|
||||
tags::ActiveModel {
|
||||
name: Set(Some(name)),
|
||||
slug: Set(slug),
|
||||
description: Set(trim_to_option(params.description)),
|
||||
cover_image: Set(trim_to_option(params.cover_image)),
|
||||
accent_color: Set(trim_to_option(params.accent_color)),
|
||||
seo_title: Set(trim_to_option(params.seo_title)),
|
||||
seo_description: Set(trim_to_option(params.seo_description)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
};
|
||||
params.update(&mut item);
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
|
||||
format::json(build_record(item))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -47,35 +221,36 @@ pub async fn update(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let name = normalized_name(¶ms)?;
|
||||
let slug = normalized_slug(¶ms, &name);
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let previous_name = item.name.clone();
|
||||
let previous_slug = item.slug.clone();
|
||||
let next_name = params
|
||||
.name
|
||||
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if let Some(next_name) = next_name {
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(next_name)
|
||||
{
|
||||
content::rewrite_tag_references(
|
||||
previous_name.as_deref(),
|
||||
&previous_slug,
|
||||
Some(next_name),
|
||||
)?;
|
||||
}
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
content::rewrite_tag_references(
|
||||
previous_name.as_deref(),
|
||||
&previous_slug,
|
||||
Some(&name),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
item.name = Set(Some(name));
|
||||
item.slug = Set(slug);
|
||||
item.description = Set(trim_to_option(params.description));
|
||||
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||
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(item)
|
||||
format::json(build_record(item))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -89,7 +264,7 @@ pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Resul
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
format::json(build_record(load_item(&ctx, id).await?))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
Reference in New Issue
Block a user