feat: ship blog platform admin and deploy stack
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
455
backend/src/controllers/admin_ops.rs
Normal file
455
backend/src/controllers/admin_ops.rs
Normal 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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
68
backend/src/controllers/content_analytics.rs
Normal file
68
backend/src/controllers/content_analytics.rs
Normal 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))
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
|
||||
13
backend/src/controllers/health.rs
Normal file
13
backend/src/controllers/health.rs
Normal 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¤t_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(¤t_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, ¶ms.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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
202
backend/src/controllers/subscription.rs
Normal file
202
backend/src/controllers/subscription.rs
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user