feat: ship blog platform admin and deploy stack

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

View File

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