feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,455 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
models::_entities::{
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
},
services::{admin_audit, post_revisions as revision_service, subscriptions as subscription_service},
};
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AuditLogQuery {
pub action: Option<String>,
pub target_type: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct RevisionQuery {
pub slug: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DeliveriesQuery {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionPayload {
#[serde(alias = "channelType")]
pub channel_type: String,
pub target: String,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub filters: Option<serde_json::Value>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionUpdatePayload {
#[serde(default, alias = "channelType")]
pub channel_type: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub filters: Option<serde_json::Value>,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RestoreRevisionRequest {
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct DigestDispatchRequest {
pub period: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PostRevisionListItem {
pub id: i32,
pub post_slug: String,
pub post_title: Option<String>,
pub operation: String,
pub revision_reason: Option<String>,
pub actor_username: Option<String>,
pub actor_email: Option<String>,
pub actor_source: Option<String>,
pub created_at: String,
pub has_markdown: bool,
pub metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PostRevisionDetailResponse {
#[serde(flatten)]
pub item: PostRevisionListItem,
pub markdown: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RestoreRevisionResponse {
pub restored: bool,
pub revision_id: i32,
pub post_slug: String,
pub mode: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SubscriptionListResponse {
pub subscriptions: Vec<subscriptions::Model>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DeliveryListResponse {
pub deliveries: Vec<notification_deliveries::Model>,
}
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 format_revision(item: post_revisions::Model) -> PostRevisionListItem {
PostRevisionListItem {
id: item.id,
post_slug: item.post_slug,
post_title: item.post_title,
operation: item.operation,
revision_reason: item.revision_reason,
actor_username: item.actor_username,
actor_email: item.actor_email,
actor_source: item.actor_source,
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(),
metadata: item.metadata,
}
}
#[debug_handler]
pub async fn list_audit_logs(
headers: HeaderMap,
Query(query): Query<AuditLogQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action));
}
if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type));
}
format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?)
}
#[debug_handler]
pub async fn list_post_revisions(
headers: HeaderMap,
Query(query): Query<RevisionQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?;
format::json(items.into_iter().map(format_revision).collect::<Vec<_>>())
}
#[debug_handler]
pub async fn get_post_revision(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let item = revision_service::get_revision(&ctx, id).await?;
format::json(PostRevisionDetailResponse {
item: format_revision(item.clone()),
markdown: item.markdown,
})
}
#[debug_handler]
pub async fn restore_post_revision(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(payload): Json<RestoreRevisionRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let mode = payload.mode.unwrap_or_else(|| "full".to_string());
let restored =
revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.revision.restore",
"post_revision",
Some(restored.id.to_string()),
Some(restored.post_slug.clone()),
Some(serde_json::json!({
"post_slug": restored.post_slug,
"source_revision_id": id,
"mode": mode,
})),
)
.await?;
format::json(RestoreRevisionResponse {
restored: true,
revision_id: id,
post_slug: restored.post_slug,
mode,
})
}
#[debug_handler]
pub async fn list_subscriptions(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(SubscriptionListResponse {
subscriptions: subscription_service::list_subscriptions(&ctx, None, None).await?,
})
}
#[debug_handler]
pub async fn list_subscription_deliveries(
headers: HeaderMap,
Query(query): Query<DeliveriesQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(DeliveryListResponse {
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?,
})
}
#[debug_handler]
pub async fn create_subscription(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<SubscriptionPayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let channel_type = subscription_service::normalize_channel_type(&payload.channel_type);
let target = payload.target.trim().to_string();
if target.is_empty() {
return Err(Error::BadRequest("target 不能为空".to_string()));
}
let created = subscriptions::ActiveModel {
channel_type: Set(channel_type.clone()),
target: Set(target.clone()),
display_name: Set(trim_to_option(payload.display_name)),
status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))),
filters: Set(subscription_service::normalize_filters(payload.filters)),
metadata: Set(payload.metadata),
secret: Set(trim_to_option(payload.secret)),
notes: Set(trim_to_option(payload.notes)),
confirm_token: Set(None),
manage_token: Set(Some(subscription_service::generate_subscription_token())),
verified_at: Set(Some(chrono::Utc::now().to_rfc3339())),
failure_count: Set(Some(0)),
..Default::default()
}
.insert(&ctx.db)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"subscription.create",
"subscription",
Some(created.id.to_string()),
Some(format!("{}:{}", created.channel_type, created.target)),
Some(serde_json::json!({ "channel_type": created.channel_type, "target": created.target })),
)
.await?;
format::json(created)
}
#[debug_handler]
pub async fn update_subscription(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(payload): Json<SubscriptionUpdatePayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = subscriptions::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let mut active = item.clone().into_active_model();
if let Some(channel_type) = payload.channel_type {
active.channel_type = Set(subscription_service::normalize_channel_type(&channel_type));
}
if let Some(target) = payload.target {
let normalized_target = target.trim().to_string();
if normalized_target.is_empty() {
return Err(Error::BadRequest("target 不能为空".to_string()));
}
active.target = Set(normalized_target);
}
if payload.display_name.is_some() {
active.display_name = Set(trim_to_option(payload.display_name));
}
if let Some(status) = payload.status {
active.status = Set(subscription_service::normalize_status(&status));
}
if payload.filters.is_some() {
active.filters = Set(subscription_service::normalize_filters(payload.filters));
}
if payload.metadata.is_some() {
active.metadata = Set(payload.metadata);
}
if payload.secret.is_some() {
active.secret = Set(trim_to_option(payload.secret));
}
if payload.notes.is_some() {
active.notes = Set(trim_to_option(payload.notes));
}
let updated = active.update(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"subscription.update",
"subscription",
Some(updated.id.to_string()),
Some(format!("{}:{}", updated.channel_type, updated.target)),
None,
)
.await?;
format::json(updated)
}
#[debug_handler]
pub async fn delete_subscription(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = subscriptions::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let label = format!("{}:{}", item.channel_type, item.target);
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"subscription.delete",
"subscription",
Some(id.to_string()),
Some(label),
None,
)
.await?;
format::empty()
}
#[debug_handler]
pub async fn test_subscription(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = subscriptions::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let delivery = subscription_service::send_test_notification(&ctx, &item).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"subscription.test",
"subscription",
Some(item.id.to_string()),
Some(format!("{}:{}", item.channel_type, item.target)),
None,
)
.await?;
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id }))
}
#[debug_handler]
pub async fn send_subscription_digest(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"subscription.digest.send",
"subscription_digest",
None,
Some(summary.period.clone()),
Some(serde_json::json!({
"period": summary.period,
"post_count": summary.post_count,
"queued": summary.queued,
"skipped": summary.skipped,
})),
)
.await?;
format::json(summary)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
.add("/audit-logs", get(list_audit_logs))
.add("/post-revisions", get(list_post_revisions))
.add("/post-revisions/{id}", get(get_post_revision))
.add("/post-revisions/{id}/restore", post(restore_post_revision))
.add("/subscriptions", get(list_subscriptions).post(create_subscription))
.add("/subscriptions/deliveries", get(list_subscription_deliveries))
.add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
.add("/subscriptions/{id}/test", post(test_subscription))
}

View File

@@ -16,7 +16,7 @@ use std::time::Instant;
use crate::{
controllers::{admin::check_auth, site_settings},
services::{ai, analytics},
services::{abuse_guard, ai, analytics},
};
#[derive(Clone, Debug, Deserialize)]
@@ -212,6 +212,11 @@ pub async fn ask(
let started_at = Instant::now();
let question = payload.question.trim().to_string();
let (provider, chat_model) = current_provider_metadata(&ctx).await;
abuse_guard::enforce_public_scope(
"ai_ask",
abuse_guard::detect_client_ip(&headers).as_deref(),
Some(&question),
)?;
match ai::answer_question(&ctx, &payload.question).await {
Ok(result) => {
@@ -263,6 +268,11 @@ pub async fn ask_stream(
let request_headers = headers.clone();
let question = payload.question.trim().to_string();
let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await;
abuse_guard::enforce_public_scope(
"ai_stream",
abuse_guard::detect_client_ip(&headers).as_deref(),
Some(&question),
)?;
let stream = stream! {
let started_at = Instant::now();
@@ -503,8 +513,8 @@ pub async fn ask_stream(
}
#[debug_handler]
pub async fn reindex(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let summary = ai::rebuild_index(&ctx).await?;
format::json(ReindexResponse {

View File

@@ -5,11 +5,23 @@ use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::net::SocketAddr;
use axum::{
extract::{rejection::ExtensionRejection, ConnectInfo},
http::{header, HeaderMap},
};
use crate::models::_entities::{
comments::{ActiveModel, Column, Entity, Model},
posts,
};
use crate::services::{
admin_audit,
comment_guard::{self, CommentGuardInput},
notifications,
};
use crate::controllers::admin::check_auth;
const ARTICLE_SCOPE: &str = "article";
const PARAGRAPH_SCOPE: &str = "paragraph";
@@ -106,6 +118,12 @@ pub struct CreateCommentRequest {
pub paragraph_excerpt: Option<String>,
#[serde(default)]
pub approved: Option<bool>,
#[serde(default, alias = "captchaToken")]
pub captcha_token: Option<String>,
#[serde(default, alias = "captchaAnswer")]
pub captcha_answer: Option<String>,
#[serde(default)]
pub website: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -125,6 +143,50 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
})
}
fn normalize_with_limit(value: Option<&str>, max_chars: usize) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(max_chars).collect::<String>())
})
}
fn header_value<'a>(headers: &'a HeaderMap, key: header::HeaderName) -> Option<&'a str> {
headers.get(key).and_then(|value| value.to_str().ok())
}
fn first_forwarded_ip(value: &str) -> Option<&str> {
value
.split(',')
.map(str::trim)
.find(|item| !item.is_empty())
}
fn detect_client_ip(
headers: &HeaderMap,
connect_info: Option<&ConnectInfo<SocketAddr>>,
) -> Option<String> {
let forwarded = header_value(headers, header::HeaderName::from_static("x-forwarded-for"))
.and_then(first_forwarded_ip);
let real_ip = header_value(headers, header::HeaderName::from_static("x-real-ip"));
let cf_connecting_ip =
header_value(headers, header::HeaderName::from_static("cf-connecting-ip"));
let true_client_ip = header_value(headers, header::HeaderName::from_static("true-client-ip"));
let remote_addr = connect_info.map(|addr| addr.0.ip().to_string());
normalize_with_limit(
forwarded
.or(real_ip)
.or(cf_connecting_ip)
.or(true_client_ip)
.or(remote_addr.as_deref()),
96,
)
}
fn normalized_scope(value: Option<String>) -> Result<String> {
match value
.unwrap_or_else(|| ARTICLE_SCOPE.to_string())
@@ -171,7 +233,12 @@ async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result<Option<String>
pub async fn list(
Query(query): Query<ListQuery>,
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
if query.approved != Some(true) {
check_auth(&headers)?;
}
let mut db_query = Entity::find().order_by_asc(Column::CreatedAt);
let post_slug = if let Some(post_slug) = query.post_slug {
@@ -252,9 +319,22 @@ pub async fn paragraph_summary(
format::json(summary)
}
#[debug_handler]
pub async fn captcha_challenge(
headers: HeaderMap,
connect_info: Result<ConnectInfo<SocketAddr>, ExtensionRejection>,
) -> Result<Response> {
let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok());
format::json(comment_guard::create_captcha_challenge(
ip_address.as_deref(),
)?)
}
#[debug_handler]
pub async fn add(
State(ctx): State<AppContext>,
headers: HeaderMap,
connect_info: Result<ConnectInfo<SocketAddr>, ExtensionRejection>,
Json(params): Json<CreateCommentRequest>,
) -> Result<Response> {
let scope = normalized_scope(params.scope.clone())?;
@@ -271,6 +351,9 @@ pub async fn add(
let email = normalize_optional_string(params.email);
let avatar = normalize_optional_string(params.avatar);
let content = normalize_optional_string(params.content);
let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok());
let user_agent = normalize_with_limit(header_value(&headers, header::USER_AGENT), 512);
let referer = normalize_with_limit(header_value(&headers, header::REFERER), 1024);
let paragraph_key = normalize_optional_string(params.paragraph_key);
let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt)
.or_else(|| content.as_deref().and_then(preview_excerpt));
@@ -291,6 +374,21 @@ pub async fn add(
return Err(Error::BadRequest("paragraph_key is required".to_string()));
}
comment_guard::enforce_comment_guard(
&ctx,
&CommentGuardInput {
ip_address: ip_address.as_deref(),
email: email.as_deref(),
user_agent: user_agent.as_deref(),
author: author.as_deref(),
content: content.as_deref(),
honeypot_website: params.website.as_deref(),
captcha_token: params.captcha_token.as_deref(),
captcha_answer: params.captcha_answer.as_deref(),
},
)
.await?;
let mut item = ActiveModel {
..Default::default()
};
@@ -302,6 +400,9 @@ pub async fn add(
item.author = Set(author);
item.email = Set(email);
item.avatar = Set(avatar);
item.ip_address = Set(ip_address);
item.user_agent = Set(user_agent);
item.referer = Set(referer);
item.content = Set(content);
item.scope = Set(scope);
item.paragraph_key = Set(paragraph_key);
@@ -313,36 +414,72 @@ pub async fn add(
item.reply_to_comment_id = Set(params.reply_to_comment_id);
item.approved = Set(Some(params.approved.unwrap_or(false)));
let item = item.insert(&ctx.db).await?;
notifications::notify_new_comment(&ctx, &item).await;
format::json(item)
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let mut item = item.into_active_model();
params.update(&mut item);
let item = item.update(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"comment.update",
"comment",
Some(item.id.to_string()),
item.post_slug.clone(),
Some(serde_json::json!({ "approved": item.approved })),
)
.await?;
format::json(item)
}
#[debug_handler]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
load_item(&ctx, id).await?.delete(&ctx.db).await?;
pub async fn remove(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let label = item.post_slug.clone();
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"comment.delete",
"comment",
Some(id.to_string()),
label,
None,
)
.await?;
format::empty()
}
#[debug_handler]
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
pub async fn get_one(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(load_item(&ctx, id).await?)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/comments/")
.add("captcha", get(captcha_challenge))
.add("/", get(list))
.add("paragraphs/summary", get(paragraph_summary))
.add("/", post(add))

View File

@@ -0,0 +1,68 @@
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::services::analytics;
#[derive(Clone, Debug, Deserialize)]
pub struct ContentAnalyticsEventPayload {
pub event_type: String,
pub path: String,
#[serde(default)]
pub post_slug: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub duration_ms: Option<i32>,
#[serde(default)]
pub progress_percent: Option<i32>,
#[serde(default)]
pub metadata: Option<Value>,
#[serde(default)]
pub referrer: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ContentAnalyticsEventResponse {
pub recorded: bool,
}
#[debug_handler]
pub async fn record(
State(ctx): State<AppContext>,
headers: HeaderMap,
Json(payload): Json<ContentAnalyticsEventPayload>,
) -> Result<Response> {
let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers);
if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() {
request_context.referrer = payload.referrer;
}
analytics::record_content_event(
&ctx,
analytics::ContentEventDraft {
event_type: payload.event_type,
path: payload.path,
post_slug: payload.post_slug,
session_id: payload.session_id,
request_context,
duration_ms: payload.duration_ms,
progress_percent: payload.progress_percent,
metadata: payload.metadata,
},
)
.await;
format::json(ContentAnalyticsEventResponse { recorded: true })
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/analytics/")
.add("content", post(record))
}

View File

@@ -1,11 +1,14 @@
#![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, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use crate::controllers::admin::check_auth;
use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model};
use crate::services::{admin_audit, notifications};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
@@ -69,11 +72,15 @@ async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
pub async fn list(
Query(query): Query<ListQuery>,
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
let authenticated = check_auth(&headers).ok();
let mut db_query = Entity::find().order_by_desc(Column::CreatedAt);
if let Some(status) = query.status {
db_query = db_query.filter(Column::Status.eq(status));
} else if authenticated.is_none() {
db_query = db_query.filter(Column::Status.eq("approved"));
}
if let Some(category) = query.category {
@@ -98,30 +105,65 @@ pub async fn add(
item.category = Set(params.category);
item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string())));
let item = item.insert(&ctx.db).await?;
notifications::notify_new_friend_link(&ctx, &item).await;
format::json(item)
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let mut item = item.into_active_model();
params.update(&mut item);
let item = item.update(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"friend_link.update",
"friend_link",
Some(item.id.to_string()),
item.site_name.clone().or_else(|| Some(item.site_url.clone())),
Some(serde_json::json!({ "status": item.status })),
)
.await?;
format::json(item)
}
#[debug_handler]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
load_item(&ctx, id).await?.delete(&ctx.db).await?;
pub async fn remove(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let label = item.site_name.clone().or_else(|| Some(item.site_url.clone()));
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"friend_link.delete",
"friend_link",
Some(id.to_string()),
label,
None,
)
.await?;
format::empty()
}
#[debug_handler]
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
pub async fn get_one(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(load_item(&ctx, id).await?)
}

View File

@@ -0,0 +1,13 @@
use loco_rs::prelude::*;
#[debug_handler]
pub async fn healthz() -> Result<Response> {
format::json(serde_json::json!({
"ok": true,
"service": "backend",
}))
}
pub fn routes() -> Routes {
Routes::new().add("/healthz", get(healthz))
}

View File

@@ -1,12 +1,16 @@
pub mod admin;
pub mod admin_api;
pub mod admin_ops;
pub mod ai;
pub mod auth;
pub mod content_analytics;
pub mod category;
pub mod comment;
pub mod friend_link;
pub mod health;
pub mod post;
pub mod review;
pub mod search;
pub mod site_settings;
pub mod subscription;
pub mod tag;

View File

@@ -1,13 +1,312 @@
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use axum::extract::Multipart;
use std::collections::HashSet;
use axum::{extract::Multipart, http::HeaderMap};
use chrono::{TimeZone, Utc};
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model};
use crate::services::content;
use crate::{
controllers::admin::check_auth,
services::{admin_audit, content, post_revisions, subscriptions},
};
fn deserialize_boolish_option<'de, D>(
deserializer: D,
) -> std::result::Result<Option<bool>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(deserializer)?;
raw.map(|value| match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => Err(serde::de::Error::custom(format!(
"invalid boolean value `{other}`"
))),
})
.transpose()
}
fn normalize_slug_key(value: &str) -> String {
value.trim().trim_matches('/').to_string()
}
fn request_preview_mode(preview: Option<bool>, headers: &HeaderMap) -> bool {
preview.unwrap_or(false)
|| headers
.get("x-termi-post-mode")
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("preview"))
.unwrap_or(false)
}
fn requested_status(status: Option<String>, published: Option<bool>) -> String {
if let Some(status) = status.as_deref() {
return content::normalize_post_status(Some(status));
}
if published == Some(false) {
content::POST_STATUS_DRAFT.to_string()
} else {
content::POST_STATUS_PUBLISHED.to_string()
}
}
fn normalize_visibility(value: Option<String>) -> String {
content::normalize_post_visibility(value.as_deref())
}
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
let wanted = wanted_tag.trim().to_lowercase();
post.tags
.as_ref()
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
let normalized = tag.trim().to_lowercase();
normalized == wanted
})
})
.unwrap_or(false)
}
fn effective_status(post: &Model) -> String {
content::effective_post_state(
post.status.as_deref().unwrap_or(content::POST_STATUS_PUBLISHED),
post.publish_at,
post.unpublish_at,
Utc::now().fixed_offset(),
)
}
fn listed_publicly(post: &Model) -> bool {
content::is_post_listed_publicly(post, Utc::now().fixed_offset())
}
fn publicly_accessible(post: &Model) -> bool {
content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
}
fn parse_optional_markdown_datetime(
value: Option<&str>,
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
let value = value?.trim();
if value.is_empty() {
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()
})
})
}
fn markdown_post_listed_publicly(post: &content::MarkdownPost) -> bool {
content::effective_post_state(
&post.status,
parse_optional_markdown_datetime(post.publish_at.as_deref()),
parse_optional_markdown_datetime(post.unpublish_at.as_deref()),
Utc::now().fixed_offset(),
) == content::POST_STATUS_PUBLISHED
&& post.visibility == content::POST_VISIBILITY_PUBLIC
}
fn should_include_post(
post: &Model,
query: &ListQuery,
preview: bool,
include_private: bool,
include_redirects: bool,
) -> bool {
if !preview && !listed_publicly(post) {
return false;
}
if query.listed_only.unwrap_or(!preview) && !listed_publicly(post) {
return false;
}
if !include_private
&& content::normalize_post_visibility(post.visibility.as_deref())
== content::POST_VISIBILITY_PRIVATE
{
return false;
}
if !include_redirects
&& post
.redirect_to
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return false;
}
if let Some(slug) = &query.slug {
if post.slug != *slug {
return false;
}
}
if let Some(category) = &query.category {
if post
.category
.as_deref()
.map(|value| !value.eq_ignore_ascii_case(category))
.unwrap_or(true)
{
return false;
}
}
if let Some(post_type) = &query.post_type {
if post
.post_type
.as_deref()
.map(|value| !value.eq_ignore_ascii_case(post_type))
.unwrap_or(true)
{
return false;
}
}
if let Some(pinned) = query.pinned {
if post.pinned.unwrap_or(false) != pinned {
return false;
}
}
if let Some(tag) = &query.tag {
if !post_has_tag(post, tag) {
return false;
}
}
if let Some(status) = &query.status {
if effective_status(post) != content::normalize_post_status(Some(status)) && effective_status(post) != status.trim().to_ascii_lowercase() {
return false;
}
}
if let Some(visibility) = &query.visibility {
if content::normalize_post_visibility(post.visibility.as_deref())
!= content::normalize_post_visibility(Some(visibility))
{
return false;
}
}
if let Some(search) = &query.search {
let wanted = search.trim().to_lowercase();
let haystack = [
post.title.as_deref().unwrap_or_default(),
post.description.as_deref().unwrap_or_default(),
post.content.as_deref().unwrap_or_default(),
post.category.as_deref().unwrap_or_default(),
&post.slug,
]
.join("\n")
.to_lowercase();
if !haystack.contains(&wanted)
&& !post
.tags
.as_ref()
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| tag.as_str())
.any(|tag| tag.to_lowercase().contains(&wanted))
})
.unwrap_or(false)
{
return false;
}
}
true
}
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
let item = Entity::find_by_id(id).one(&ctx.db).await?;
item.ok_or(Error::NotFound)
}
async fn load_item_by_slug_once(ctx: &AppContext, slug: &str) -> Result<Option<Model>> {
Entity::find()
.filter(Column::Slug.eq(slug))
.one(&ctx.db)
.await
.map_err(Into::into)
}
async fn resolve_post_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
let mut current_slug = normalize_slug_key(slug);
if current_slug.is_empty() {
return Err(Error::NotFound);
}
let mut visited = HashSet::new();
loop {
if !visited.insert(current_slug.clone()) {
return Err(Error::NotFound);
}
if let Some(post) = load_item_by_slug_once(ctx, &current_slug).await? {
let next_slug = post
.redirect_to
.as_deref()
.map(normalize_slug_key)
.filter(|value| !value.is_empty() && *value != post.slug);
if let Some(next_slug) = next_slug {
current_slug = next_slug;
continue;
}
return Ok(post);
}
let candidates = Entity::find().all(&ctx.db).await?;
let Some(candidate) = candidates.into_iter().find(|item| {
content::post_redirects_from_json(&item.redirect_from)
.into_iter()
.any(|redirect| redirect.eq_ignore_ascii_case(&current_slug))
}) else {
return Err(Error::NotFound);
};
let next_slug = candidate
.redirect_to
.as_deref()
.map(normalize_slug_key)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| candidate.slug.clone());
if next_slug == candidate.slug {
return Ok(candidate);
}
current_slug = next_slug;
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
@@ -21,6 +320,15 @@ pub struct Params {
pub image: Option<String>,
pub images: Option<serde_json::Value>,
pub pinned: Option<bool>,
pub status: Option<String>,
pub visibility: Option<String>,
pub publish_at: Option<String>,
pub unpublish_at: Option<String>,
pub canonical_url: Option<String>,
pub noindex: Option<bool>,
pub og_image: Option<String>,
pub redirect_from: Option<serde_json::Value>,
pub redirect_to: Option<String>,
}
impl Params {
@@ -35,6 +343,27 @@ 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.canonical_url = Set(self.canonical_url.clone());
item.noindex = Set(self.noindex);
item.og_image = Set(self.og_image.clone());
item.redirect_from = Set(self.redirect_from.clone());
item.redirect_to = Set(self.redirect_to.clone());
}
}
@@ -47,6 +376,24 @@ pub struct ListQuery {
#[serde(alias = "type")]
pub post_type: Option<String>,
pub pinned: Option<bool>,
pub status: Option<String>,
pub visibility: Option<String>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub listed_only: Option<bool>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub include_private: Option<bool>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub include_redirects: Option<bool>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub preview: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct LookupQuery {
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub preview: Option<bool>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub include_private: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -66,6 +413,15 @@ pub struct MarkdownCreateParams {
pub image: Option<String>,
pub images: Option<Vec<String>>,
pub pinned: Option<bool>,
pub status: Option<String>,
pub visibility: Option<String>,
pub publish_at: Option<String>,
pub unpublish_at: Option<String>,
pub canonical_url: Option<String>,
pub noindex: Option<bool>,
pub og_image: Option<String>,
pub redirect_from: Option<Vec<String>>,
pub redirect_to: Option<String>,
pub published: Option<bool>,
}
@@ -88,174 +444,211 @@ pub struct MarkdownImportResponse {
pub slugs: Vec<String>,
}
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
let item = Entity::find_by_id(id).one(&ctx.db).await?;
item.ok_or_else(|| Error::NotFound)
}
async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
let item = Entity::find()
.filter(Column::Slug.eq(slug))
.one(&ctx.db)
.await?;
item.ok_or_else(|| Error::NotFound)
}
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
let wanted = wanted_tag.trim().to_lowercase();
post.tags
.as_ref()
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
let normalized = tag.trim().to_lowercase();
normalized == wanted
})
})
.unwrap_or(false)
}
#[debug_handler]
pub async fn list(
Query(query): Query<ListQuery>,
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);
let posts = Entity::find()
.order_by_desc(Column::CreatedAt)
.all(&ctx.db)
.await?;
let filtered: Vec<Model> = posts
let filtered = posts
.into_iter()
.filter(|post| {
if let Some(slug) = &query.slug {
if post.slug != *slug {
return false;
}
}
if let Some(category) = &query.category {
if post
.category
.as_deref()
.map(|value| !value.eq_ignore_ascii_case(category))
.unwrap_or(true)
{
return false;
}
}
if let Some(post_type) = &query.post_type {
if post
.post_type
.as_deref()
.map(|value| !value.eq_ignore_ascii_case(post_type))
.unwrap_or(true)
{
return false;
}
}
if let Some(pinned) = query.pinned {
if post.pinned.unwrap_or(false) != pinned {
return false;
}
}
if let Some(tag) = &query.tag {
if !post_has_tag(post, tag) {
return false;
}
}
if let Some(search) = &query.search {
let wanted = search.trim().to_lowercase();
let haystack = [
post.title.as_deref().unwrap_or_default(),
post.description.as_deref().unwrap_or_default(),
post.content.as_deref().unwrap_or_default(),
post.category.as_deref().unwrap_or_default(),
&post.slug,
]
.join("\n")
.to_lowercase();
if !haystack.contains(&wanted)
&& !post
.tags
.as_ref()
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| tag.as_str())
.any(|tag| tag.to_lowercase().contains(&wanted))
})
.unwrap_or(false)
{
return false;
}
}
true
})
.collect();
.filter(|post| should_include_post(post, &query, preview, include_private, include_redirects))
.collect::<Vec<_>>();
format::json(filtered)
}
#[debug_handler]
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
pub async fn add(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let mut item = ActiveModel {
..Default::default()
};
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.create",
"post",
Some(item.id.to_string()),
Some(item.slug.clone()),
None,
)
.await?;
format::json(item)
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(params): Json<Params>,
) -> Result<Response> {
let item = load_item(&ctx, id).await?;
let mut item = item.into_active_model();
let actor = check_auth(&headers)?;
let previous = load_item(&ctx, id).await?;
let was_public = content::is_post_listed_publicly(&previous, Utc::now().fixed_offset());
let previous_slug = previous.slug.clone();
let mut item = previous.into_active_model();
params.update(&mut item);
let item = item.update(&ctx.db).await?;
let is_public = content::is_post_listed_publicly(&item, Utc::now().fixed_offset());
admin_audit::log_event(
&ctx,
Some(&actor),
"post.update",
"post",
Some(item.id.to_string()),
Some(item.slug.clone()),
Some(serde_json::json!({
"previous_slug": previous_slug,
"published": is_public,
})),
)
.await?;
if is_public && !was_public {
let post = content::MarkdownPost {
title: item.title.clone().unwrap_or_else(|| item.slug.clone()),
slug: item.slug.clone(),
description: item.description.clone(),
content: item.content.clone().unwrap_or_default(),
category: item.category.clone(),
tags: item
.tags
.as_ref()
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default()
.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()),
image: item.image.clone(),
images: item
.images
.as_ref()
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default()
.into_iter()
.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()),
visibility: item
.visibility
.clone()
.unwrap_or_else(|| content::POST_VISIBILITY_PUBLIC.to_string()),
publish_at: item.publish_at.map(|value| value.to_rfc3339()),
unpublish_at: item.unpublish_at.map(|value| value.to_rfc3339()),
canonical_url: item.canonical_url.clone(),
noindex: item.noindex.unwrap_or(false),
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(),
};
let _ = subscriptions::notify_post_published(&ctx, &post).await;
}
format::json(item)
}
#[debug_handler]
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
load_item(&ctx, id).await?.delete(&ctx.db).await?;
pub async fn remove(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let slug = item.slug.clone();
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.delete",
"post",
Some(id.to_string()),
Some(slug),
None,
)
.await?;
format::empty()
}
#[debug_handler]
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
pub async fn get_one(
Path(id): Path<i32>,
Query(query): Query<LookupQuery>,
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
format::json(load_item(&ctx, id).await?)
let preview = request_preview_mode(query.preview, &headers);
let post = load_item(&ctx, id).await?;
if !preview && !publicly_accessible(&post) {
return Err(Error::NotFound);
}
format::json(post)
}
#[debug_handler]
pub async fn get_by_slug(
Path(slug): Path<String>,
Query(query): Query<LookupQuery>,
State(ctx): State<AppContext>,
headers: HeaderMap,
) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
format::json(load_item_by_slug(&ctx, &slug).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?;
if !preview && !publicly_accessible(&post) {
return Err(Error::NotFound);
}
if !include_private
&& content::normalize_post_visibility(post.visibility.as_deref())
== content::POST_VISIBILITY_PRIVATE
{
return Err(Error::NotFound);
}
format::json(post)
}
#[debug_handler]
pub async fn get_markdown_by_slug(
headers: HeaderMap,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
content::sync_markdown_posts(&ctx).await?;
let (path, markdown) = content::read_markdown_document(&slug)?;
format::json(MarkdownDocumentResponse {
@@ -267,12 +660,43 @@ pub async fn get_markdown_by_slug(
#[debug_handler]
pub async fn update_markdown_by_slug(
headers: HeaderMap,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
Json(params): Json<MarkdownUpdateParams>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let _ = post_revisions::capture_current_snapshot(
&ctx,
Some(&actor),
&slug,
"update",
Some("保存文章前的自动快照"),
None,
)
.await?;
let updated = content::write_markdown_document(&ctx, &slug, &params.markdown).await?;
let (path, markdown) = content::read_markdown_document(&updated.slug)?;
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),
&updated.slug,
&markdown,
"saved",
Some("保存后的当前版本"),
None,
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.markdown.update",
"post",
None,
Some(updated.slug.clone()),
None,
)
.await?;
format::json(MarkdownDocumentResponse {
slug: updated.slug,
@@ -283,9 +707,11 @@ pub async fn update_markdown_by_slug(
#[debug_handler]
pub async fn create_markdown(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(params): Json<MarkdownCreateParams>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let title = params.title.trim();
if title.is_empty() {
return Err(Error::BadRequest("title is required".to_string()));
@@ -305,11 +731,42 @@ pub async fn create_markdown(
image: params.image,
images: params.images.unwrap_or_default(),
pinned: params.pinned.unwrap_or(false),
published: params.published.unwrap_or(true),
status: requested_status(params.status, params.published),
visibility: normalize_visibility(params.visibility),
publish_at: params.publish_at,
unpublish_at: params.unpublish_at,
canonical_url: params.canonical_url,
noindex: params.noindex.unwrap_or(false),
og_image: params.og_image,
redirect_from: params.redirect_from.unwrap_or_default(),
redirect_to: params.redirect_to,
},
)
.await?;
let (path, markdown) = content::read_markdown_document(&created.slug)?;
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),
&created.slug,
&markdown,
"create",
Some("新建文章"),
None,
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.markdown.create",
"post",
None,
Some(created.slug.clone()),
None,
)
.await?;
if markdown_post_listed_publicly(&created) {
let _ = subscriptions::notify_post_published(&ctx, &created).await;
}
format::json(MarkdownDocumentResponse {
slug: created.slug,
@@ -320,9 +777,11 @@ pub async fn create_markdown(
#[debug_handler]
pub async fn import_markdown(
headers: HeaderMap,
State(ctx): State<AppContext>,
mut multipart: Multipart,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let mut files = Vec::new();
while let Some(field) = multipart
@@ -345,6 +804,35 @@ 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) {
let _ = post_revisions::capture_snapshot_from_markdown(
&ctx,
Some(&actor),
&item.slug,
&markdown,
"import",
Some("批量导入 Markdown"),
None,
)
.await;
}
if markdown_post_listed_publicly(item) {
let _ = subscriptions::notify_post_published(&ctx, item).await;
}
}
admin_audit::log_event(
&ctx,
Some(&actor),
"post.markdown.import",
"post_import",
None,
Some(format!("{} files", imported.len())),
Some(serde_json::json!({
"slugs": imported.iter().map(|item| item.slug.clone()).collect::<Vec<_>>(),
})),
)
.await?;
format::json(MarkdownImportResponse {
count: imported.len(),
@@ -354,10 +842,31 @@ pub async fn import_markdown(
#[debug_handler]
pub async fn delete_markdown_by_slug(
headers: HeaderMap,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let _ = post_revisions::capture_current_snapshot(
&ctx,
Some(&actor),
&slug,
"delete",
Some("删除前自动快照"),
None,
)
.await?;
content::delete_markdown_post(&ctx, &slug).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"post.markdown.delete",
"post",
None,
Some(slug.clone()),
None,
)
.await?;
format::json(MarkdownDeleteResponse {
slug,
deleted: true,

View File

@@ -1,11 +1,15 @@
use axum::extract::{Path, State};
use axum::{
extract::{Path, State},
http::HeaderMap,
};
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
models::_entities::reviews::{self, Entity as ReviewEntity},
services::storage,
services::{admin_audit, storage},
};
#[derive(Serialize, Deserialize, Debug)]
@@ -56,9 +60,11 @@ pub async fn get_one(
}
pub async fn create(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(req): Json<CreateReviewRequest>,
) -> Result<impl IntoResponse> {
let actor = check_auth(&headers)?;
let new_review = reviews::ActiveModel {
title: Set(Some(req.title)),
review_type: Set(Some(req.review_type)),
@@ -76,14 +82,26 @@ pub async fn create(
};
let review = new_review.insert(&ctx.db).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"review.create",
"review",
Some(review.id.to_string()),
review.title.clone(),
None,
)
.await?;
format::json(review)
}
pub async fn update(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(req): Json<UpdateReviewRequest>,
) -> Result<impl IntoResponse> {
let actor = check_auth(&headers)?;
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
let Some(existing_review) = review else {
@@ -132,24 +150,47 @@ pub async fn update(
tracing::warn!("failed to cleanup replaced review cover: {error}");
}
}
admin_audit::log_event(
&ctx,
Some(&actor),
"review.update",
"review",
Some(review.id.to_string()),
review.title.clone(),
None,
)
.await?;
format::json(review)
}
pub async fn remove(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<impl IntoResponse> {
let actor = check_auth(&headers)?;
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review {
Some(r) => {
let cover = r.cover.clone();
let title = r.title.clone();
r.delete(&ctx.db).await?;
if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) {
if let Err(error) = storage::delete_managed_url(&ctx, &cover).await {
tracing::warn!("failed to cleanup deleted review cover: {error}");
}
}
admin_audit::log_event(
&ctx,
Some(&actor),
"review.delete",
"review",
Some(id.to_string()),
title,
None,
)
.await?;
format::empty()
}
None => Err(Error::NotFound),

View File

@@ -1,12 +1,14 @@
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::time::Instant;
use std::{collections::HashSet, time::Instant};
use crate::models::_entities::posts;
use crate::services::{analytics, content};
use crate::{
controllers::site_settings,
models::_entities::posts,
services::{abuse_guard, analytics, content},
};
fn deserialize_boolish_option<'de, D>(
deserializer: D,
@@ -26,6 +28,243 @@ where
.transpose()
}
fn normalize_text(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_ascii_lowercase()
}
fn tokenize(value: &str) -> Vec<String> {
value
.split(|ch: char| !ch.is_alphanumeric() && ch != '-' && ch != '_')
.map(normalize_text)
.filter(|item| !item.is_empty())
.collect()
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut prev = (0..=right_chars.len()).collect::<Vec<_>>();
for (i, left_ch) in left.chars().enumerate() {
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);
}
prev = curr;
}
prev[right_chars.len()]
}
fn parse_synonym_groups(value: &Option<Value>) -> Vec<Vec<String>> {
value
.as_ref()
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| item.as_str().map(ToString::to_string))
.map(|item| {
let normalized = item.replace("=>", ",").replace('|', ",");
normalized
.split([',', ''])
.map(normalize_text)
.filter(|token| !token.is_empty())
.collect::<Vec<_>>()
})
.filter(|group| !group.is_empty())
.collect()
}
fn expand_search_terms(query: &str, synonym_groups: &[Vec<String>]) -> Vec<String> {
let normalized_query = normalize_text(query);
let query_tokens = tokenize(query);
let mut expanded = Vec::new();
let mut seen = HashSet::new();
if !normalized_query.is_empty() && seen.insert(normalized_query.clone()) {
expanded.push(normalized_query.clone());
}
for token in &query_tokens {
if seen.insert(token.clone()) {
expanded.push(token.clone());
}
}
for group in synonym_groups {
let matched = group.iter().any(|item| {
*item == normalized_query
|| query_tokens.iter().any(|token| token == item)
|| normalized_query.contains(item)
});
if matched {
for token in group {
if seen.insert(token.clone()) {
expanded.push(token.clone());
}
}
}
}
expanded
}
fn candidate_terms(posts: &[posts::Model]) -> Vec<String> {
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for post in posts {
for source in [
post.title.as_deref().unwrap_or_default(),
post.category.as_deref().unwrap_or_default(),
&post.slug,
] {
for token in tokenize(source) {
if token.len() >= 3 && seen.insert(token.clone()) {
candidates.push(token);
}
}
}
if let Some(tags) = post.tags.as_ref().and_then(Value::as_array) {
for token in tags.iter().filter_map(Value::as_str).flat_map(tokenize) {
if token.len() >= 2 && seen.insert(token.clone()) {
candidates.push(token);
}
}
}
}
candidates
}
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();
}
let mut nearest = candidate_terms(posts)
.into_iter()
.map(|candidate| {
let distance = levenshtein_distance(&primary_token, &candidate);
(candidate, distance)
})
.filter(|(_, distance)| *distance <= 2)
.collect::<Vec<_>>();
nearest.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
nearest
.into_iter()
.take(3)
.flat_map(|(candidate, _)| expand_search_terms(&candidate, synonym_groups))
.collect()
}
fn post_has_tag(post: &posts::Model, wanted_tag: &str) -> bool {
let wanted = normalize_text(wanted_tag);
post.tags
.as_ref()
.and_then(Value::as_array)
.map(|tags| {
tags.iter()
.filter_map(Value::as_str)
.map(normalize_text)
.any(|tag| tag == wanted)
})
.unwrap_or(false)
}
fn score_post(post: &posts::Model, query: &str, terms: &[String]) -> f64 {
let normalized_query = normalize_text(query);
let title = normalize_text(post.title.as_deref().unwrap_or_default());
let description = normalize_text(post.description.as_deref().unwrap_or_default());
let content_text = normalize_text(post.content.as_deref().unwrap_or_default());
let category = normalize_text(post.category.as_deref().unwrap_or_default());
let slug = normalize_text(&post.slug);
let tags = post
.tags
.as_ref()
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| item.as_str().map(normalize_text))
.collect::<Vec<_>>();
let mut score = 0.0;
if !normalized_query.is_empty() {
if title.contains(&normalized_query) {
score += 6.0;
}
if description.contains(&normalized_query) {
score += 4.0;
}
if slug.contains(&normalized_query) {
score += 4.0;
}
if category.contains(&normalized_query) {
score += 3.0;
}
if tags.iter().any(|tag| tag.contains(&normalized_query)) {
score += 4.0;
}
if content_text.contains(&normalized_query) {
score += 2.0;
}
}
for term in terms {
if term.is_empty() {
continue;
}
if title.contains(term) {
score += 3.5;
}
if description.contains(term) {
score += 2.2;
}
if slug.contains(term) {
score += 2.0;
}
if category.contains(term) {
score += 1.8;
}
if tags.iter().any(|tag| tag == term) {
score += 2.5;
} else if tags.iter().any(|tag| tag.contains(term)) {
score += 1.5;
}
if content_text.contains(term) {
score += 0.8;
}
}
score
}
fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
query.preview.unwrap_or(false)
|| headers
@@ -39,11 +278,15 @@ fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
pub struct SearchQuery {
pub q: Option<String>,
pub limit: Option<u64>,
pub category: Option<String>,
pub tag: Option<String>,
#[serde(alias = "type")]
pub post_type: Option<String>,
#[serde(default, deserialize_with = "deserialize_boolish_option")]
pub preview: Option<bool>,
}
#[derive(Clone, Debug, Serialize, FromQueryResult)]
#[derive(Clone, Debug, Serialize)]
pub struct SearchResult {
pub id: i32,
pub title: Option<String>,
@@ -59,131 +302,6 @@ pub struct SearchResult {
pub rank: f64,
}
fn search_sql() -> &'static str {
r#"
SELECT
p.id,
p.title,
p.slug,
p.description,
p.content,
p.category,
p.tags,
p.post_type,
p.pinned,
p.created_at,
p.updated_at,
ts_rank_cd(
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'),
plainto_tsquery('simple', $1)
)::float8 AS rank
FROM posts p
WHERE (
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D')
) @@ plainto_tsquery('simple', $1)
ORDER BY rank DESC, p.created_at DESC
LIMIT $2
"#
}
fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 {
let wanted_lower = wanted.to_lowercase();
let mut rank = 0.0;
if post
.title
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&wanted_lower)
{
rank += 4.0;
}
if post
.description
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&wanted_lower)
{
rank += 2.5;
}
if post
.content
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&wanted_lower)
{
rank += 1.0;
}
if post
.category
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&wanted_lower)
{
rank += 1.5;
}
if post
.tags
.as_ref()
.and_then(Value::as_array)
.map(|tags| {
tags.iter()
.filter_map(Value::as_str)
.any(|tag| tag.to_lowercase().contains(&wanted_lower))
})
.unwrap_or(false)
{
rank += 2.0;
}
rank
}
async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<SearchResult>> {
let mut results = posts::Entity::find().all(&ctx.db).await?;
results.sort_by(|left, right| right.created_at.cmp(&left.created_at));
Ok(results
.into_iter()
.map(|post| {
let rank = app_level_rank(&post, q);
(post, rank)
})
.filter(|(_, rank)| *rank > 0.0)
.take(limit as usize)
.map(|(post, rank)| SearchResult {
id: post.id,
title: post.title,
slug: post.slug,
description: post.description,
content: post.content,
category: post.category,
tags: post.tags,
post_type: post.post_type,
pinned: post.pinned,
created_at: post.created_at.into(),
updated_at: post.updated_at.into(),
rank,
})
.collect())
}
#[debug_handler]
pub async fn search(
Query(query): Query<SearchQuery>,
@@ -199,26 +317,107 @@ pub async fn search(
return format::json(Vec::<SearchResult>::new());
}
let limit = query.limit.unwrap_or(20).clamp(1, 100);
if !preview_search {
abuse_guard::enforce_public_scope(
"search",
abuse_guard::detect_client_ip(&headers).as_deref(),
Some(&q),
)?;
}
let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres {
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
search_sql(),
[q.clone().into(), (limit as i64).into()],
);
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
let settings = site_settings::load_current(&ctx).await.ok();
let synonym_groups = settings
.as_ref()
.map(|item| parse_synonym_groups(&item.search_synonyms))
.unwrap_or_default();
match SearchResult::find_by_statement(statement)
.all(&ctx.db)
.await
{
Ok(rows) if !rows.is_empty() => rows,
Ok(_) => fallback_search(&ctx, &q, limit).await?,
Err(_) => fallback_search(&ctx, &q, limit).await?,
let mut all_posts = posts::Entity::find()
.all(&ctx.db)
.await?
.into_iter()
.filter(|post| {
preview_search
|| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset())
})
.collect::<Vec<_>>();
if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
all_posts.retain(|post| {
post.category
.as_deref()
.map(|value| value.eq_ignore_ascii_case(category))
.unwrap_or(false)
});
}
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));
}
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()
.map(|value| value.eq_ignore_ascii_case(post_type))
.unwrap_or(false)
});
}
let mut expanded_terms = expand_search_terms(&q, &synonym_groups);
let mut results = all_posts
.iter()
.map(|post| (post, score_post(post, &q, &expanded_terms)))
.filter(|(_, rank)| *rank > 0.0)
.map(|(post, rank)| SearchResult {
id: post.id,
title: post.title.clone(),
slug: post.slug.clone(),
description: post.description.clone(),
content: post.content.clone(),
category: post.category.clone(),
tags: post.tags.clone(),
post_type: post.post_type.clone(),
pinned: post.pinned,
created_at: post.created_at.into(),
updated_at: post.updated_at.into(),
rank,
})
.collect::<Vec<_>>();
if results.is_empty() {
expanded_terms = find_spelling_fallback(&q, &all_posts, &synonym_groups);
if !expanded_terms.is_empty() {
results = all_posts
.iter()
.map(|post| (post, score_post(post, &q, &expanded_terms)))
.filter(|(_, rank)| *rank > 0.0)
.map(|(post, rank)| SearchResult {
id: post.id,
title: post.title.clone(),
slug: post.slug.clone(),
description: post.description.clone(),
content: post.content.clone(),
category: post.category.clone(),
tags: post.tags.clone(),
post_type: post.post_type.clone(),
pinned: post.pinned,
created_at: post.created_at.into(),
updated_at: post.updated_at.into(),
rank,
})
.collect::<Vec<_>>();
}
} else {
fallback_search(&ctx, &q, limit).await?
};
}
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))
});
results.truncate(limit);
if !preview_search {
analytics::record_search_event(

View File

@@ -2,6 +2,7 @@
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
@@ -11,7 +12,9 @@ use uuid::Uuid;
use crate::{
controllers::admin::check_auth,
models::_entities::{
categories, friend_links, posts, site_settings::{self, ActiveModel, Entity, Model}, tags,
categories, friend_links, posts,
site_settings::{self, ActiveModel, Entity, Model},
tags,
},
services::{ai, content},
};
@@ -130,6 +133,18 @@ pub struct SiteSettingsPayload {
pub media_r2_access_key_id: Option<String>,
#[serde(default, alias = "mediaR2SecretAccessKey")]
pub media_r2_secret_access_key: Option<String>,
#[serde(default, alias = "seoDefaultOgImage")]
pub seo_default_og_image: Option<String>,
#[serde(default, alias = "seoDefaultTwitterHandle")]
pub seo_default_twitter_handle: Option<String>,
#[serde(default, alias = "notificationWebhookUrl")]
pub notification_webhook_url: Option<String>,
#[serde(default, alias = "notificationCommentEnabled")]
pub notification_comment_enabled: Option<bool>,
#[serde(default, alias = "notificationFriendLinkEnabled")]
pub notification_friend_link_enabled: Option<bool>,
#[serde(default, alias = "searchSynonyms")]
pub search_synonyms: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize)]
@@ -154,6 +169,8 @@ pub struct PublicSiteSettingsResponse {
pub music_playlist: Option<serde_json::Value>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -171,6 +188,9 @@ pub struct HomePageResponse {
pub tags: Vec<tags::Model>,
pub friend_links: Vec<friend_links::Model>,
pub categories: Vec<HomeCategorySummary>,
pub content_overview: crate::services::analytics::ContentAnalyticsOverview,
pub popular_posts: Vec<crate::services::analytics::AnalyticsPopularPost>,
pub content_ranges: Vec<crate::services::analytics::PublicContentWindowHighlights>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -188,6 +208,13 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
value.map(|item| item.clamp(min, max))
}
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.filter_map(|item| normalize_optional_string(Some(item)))
.collect()
}
fn create_ai_provider_id() -> String {
format!("provider-{}", Uuid::new_v4().simple())
}
@@ -525,6 +552,27 @@ impl SiteSettingsPayload {
item.media_r2_secret_access_key =
normalize_optional_string(Some(media_r2_secret_access_key));
}
if let Some(seo_default_og_image) = self.seo_default_og_image {
item.seo_default_og_image = normalize_optional_string(Some(seo_default_og_image));
}
if let Some(seo_default_twitter_handle) = self.seo_default_twitter_handle {
item.seo_default_twitter_handle =
normalize_optional_string(Some(seo_default_twitter_handle));
}
if let Some(notification_webhook_url) = self.notification_webhook_url {
item.notification_webhook_url =
normalize_optional_string(Some(notification_webhook_url));
}
if let Some(notification_comment_enabled) = self.notification_comment_enabled {
item.notification_comment_enabled = Some(notification_comment_enabled);
}
if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled {
item.notification_friend_link_enabled = Some(notification_friend_link_enabled);
}
if let Some(search_synonyms) = self.search_synonyms {
let normalized = normalize_string_list(search_synonyms);
item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized));
}
if provider_list_supplied {
write_ai_provider_state(
@@ -631,6 +679,12 @@ fn default_payload() -> SiteSettingsPayload {
media_r2_public_base_url: None,
media_r2_access_key_id: None,
media_r2_secret_access_key: None,
seo_default_og_image: None,
seo_default_twitter_handle: None,
notification_webhook_url: None,
notification_comment_enabled: Some(false),
notification_friend_link_enabled: Some(false),
search_synonyms: Some(Vec::new()),
}
}
@@ -680,6 +734,8 @@ 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),
seo_default_og_image: model.seo_default_og_image,
seo_default_twitter_handle: model.seo_default_twitter_handle,
}
}
@@ -691,9 +747,13 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
let posts = posts::Entity::find()
.order_by_desc(posts::Column::CreatedAt)
.all(&ctx.db)
.await?;
.await?
.into_iter()
.filter(|post| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset()))
.collect::<Vec<_>>();
let tags = tags::Entity::find().all(&ctx.db).await?;
let friend_links = friend_links::Entity::find()
.filter(friend_links::Column::Status.eq("approved"))
.order_by_desc(friend_links::Column::CreatedAt)
.all(&ctx.db)
.await?;
@@ -722,6 +782,9 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
}
})
.collect::<Vec<_>>();
let content_highlights =
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
format::json(HomePageResponse {
site_settings,
@@ -729,6 +792,9 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
tags,
friend_links,
categories,
content_overview: content_highlights.overview,
popular_posts: content_highlights.popular_posts,
content_ranges,
})
}
@@ -739,10 +805,11 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
#[debug_handler]
pub async fn update(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
check_auth()?;
check_auth(&headers)?;
let current = load_current(&ctx).await?;
let mut item = current;

View File

@@ -0,0 +1,202 @@
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::services::{abuse_guard, admin_audit, subscriptions};
#[derive(Clone, Debug, Deserialize)]
pub struct PublicSubscriptionPayload {
pub email: String,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionTokenPayload {
pub token: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionManageQuery {
pub token: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionManageUpdatePayload {
pub token: String,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub filters: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublicSubscriptionResponse {
pub ok: bool,
pub subscription_id: i32,
pub status: String,
pub requires_confirmation: bool,
pub message: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SubscriptionManageResponse {
pub ok: bool,
pub subscription: subscriptions::PublicSubscriptionView,
}
fn public_subscription_metadata(source: Option<String>) -> serde_json::Value {
serde_json::json!({
"source": source,
"kind": "public-form",
})
}
#[debug_handler]
pub async fn subscribe(
State(ctx): State<AppContext>,
headers: axum::http::HeaderMap,
Json(payload): Json<PublicSubscriptionPayload>,
) -> Result<Response> {
let email = payload.email.trim().to_ascii_lowercase();
abuse_guard::enforce_public_scope(
"subscription",
abuse_guard::detect_client_ip(&headers).as_deref(),
Some(&email),
)?;
let result = subscriptions::create_public_email_subscription(
&ctx,
&email,
payload.display_name,
Some(public_subscription_metadata(payload.source)),
)
.await?;
admin_audit::log_event(
&ctx,
None,
if result.requires_confirmation {
"subscription.public.pending"
} else {
"subscription.public.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: result.requires_confirmation,
message: result.message,
})
}
#[debug_handler]
pub async fn confirm(
State(ctx): State<AppContext>,
Json(payload): Json<SubscriptionTokenPayload>,
) -> Result<Response> {
let item = subscriptions::confirm_subscription(&ctx, &payload.token).await?;
admin_audit::log_event(
&ctx,
None,
"subscription.public.confirm",
"subscription",
Some(item.id.to_string()),
Some(item.target.clone()),
Some(serde_json::json!({ "channel_type": item.channel_type })),
)
.await?;
format::json(SubscriptionManageResponse {
ok: true,
subscription: subscriptions::to_public_subscription_view(&item),
})
}
#[debug_handler]
pub async fn manage(
State(ctx): State<AppContext>,
Query(query): Query<SubscriptionManageQuery>,
) -> Result<Response> {
let item = subscriptions::get_subscription_by_manage_token(&ctx, &query.token).await?;
format::json(SubscriptionManageResponse {
ok: true,
subscription: subscriptions::to_public_subscription_view(&item),
})
}
#[debug_handler]
pub async fn update_manage(
State(ctx): State<AppContext>,
Json(payload): Json<SubscriptionManageUpdatePayload>,
) -> Result<Response> {
let item = subscriptions::update_subscription_preferences(
&ctx,
&payload.token,
payload.display_name,
payload.status,
payload.filters,
)
.await?;
admin_audit::log_event(
&ctx,
None,
"subscription.public.update",
"subscription",
Some(item.id.to_string()),
Some(item.target.clone()),
None,
)
.await?;
format::json(SubscriptionManageResponse {
ok: true,
subscription: subscriptions::to_public_subscription_view(&item),
})
}
#[debug_handler]
pub async fn unsubscribe(
State(ctx): State<AppContext>,
Json(payload): Json<SubscriptionTokenPayload>,
) -> Result<Response> {
let item = subscriptions::unsubscribe_subscription(&ctx, &payload.token).await?;
admin_audit::log_event(
&ctx,
None,
"subscription.public.unsubscribe",
"subscription",
Some(item.id.to_string()),
Some(item.target.clone()),
None,
)
.await?;
format::json(SubscriptionManageResponse {
ok: true,
subscription: subscriptions::to_public_subscription_view(&item),
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/subscriptions")
.add("/", post(subscribe))
.add("/confirm", post(confirm))
.add("/manage", get(manage).patch(update_manage))
.add("/unsubscribe", post(unsubscribe))
}