Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
1979 lines
61 KiB
Rust
1979 lines
61 KiB
Rust
use axum::{
|
|
extract::{Multipart, Query},
|
|
http::{header, HeaderMap},
|
|
};
|
|
use loco_rs::prelude::*;
|
|
use sea_orm::{
|
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
|
QueryOrder, QuerySelect, Set,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
controllers::{
|
|
admin::{
|
|
admin_username, check_auth, clear_local_session, clear_local_session_cookie,
|
|
local_login_enabled, proxy_auth_enabled, resolve_admin_identity, start_local_session,
|
|
validate_admin_credentials,
|
|
},
|
|
site_settings::{self, SiteSettingsPayload},
|
|
},
|
|
models::_entities::{
|
|
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
|
|
reviews,
|
|
},
|
|
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
|
|
};
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminLoginPayload {
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminSessionResponse {
|
|
pub authenticated: bool,
|
|
pub username: Option<String>,
|
|
pub email: Option<String>,
|
|
pub auth_source: Option<String>,
|
|
pub auth_provider: Option<String>,
|
|
pub groups: Vec<String>,
|
|
pub proxy_auth_enabled: bool,
|
|
pub local_login_enabled: bool,
|
|
pub can_logout: bool,
|
|
}
|
|
|
|
fn build_session_response(
|
|
identity: Option<crate::controllers::admin::AdminIdentity>,
|
|
) -> AdminSessionResponse {
|
|
let can_logout = matches!(
|
|
identity.as_ref().map(|item| item.source.as_str()),
|
|
Some("local")
|
|
);
|
|
|
|
AdminSessionResponse {
|
|
authenticated: identity.is_some(),
|
|
username: identity.as_ref().map(|item| item.username.clone()),
|
|
email: identity.as_ref().and_then(|item| item.email.clone()),
|
|
auth_source: identity.as_ref().map(|item| item.source.clone()),
|
|
auth_provider: identity.as_ref().and_then(|item| item.provider.clone()),
|
|
groups: identity.map(|item| item.groups).unwrap_or_default(),
|
|
proxy_auth_enabled: proxy_auth_enabled(),
|
|
local_login_enabled: local_login_enabled(),
|
|
can_logout,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardStats {
|
|
pub total_posts: u64,
|
|
pub total_comments: u64,
|
|
pub pending_comments: u64,
|
|
pub draft_posts: u64,
|
|
pub scheduled_posts: u64,
|
|
pub offline_posts: u64,
|
|
pub expired_posts: u64,
|
|
pub private_posts: u64,
|
|
pub unlisted_posts: u64,
|
|
pub total_categories: u64,
|
|
pub total_tags: u64,
|
|
pub total_reviews: u64,
|
|
pub total_links: u64,
|
|
pub pending_links: u64,
|
|
pub ai_chunks: u64,
|
|
pub ai_enabled: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardPostItem {
|
|
pub id: i32,
|
|
pub title: String,
|
|
pub slug: String,
|
|
pub category: String,
|
|
pub post_type: String,
|
|
pub pinned: bool,
|
|
pub status: String,
|
|
pub visibility: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardCommentItem {
|
|
pub id: i32,
|
|
pub author: String,
|
|
pub post_slug: String,
|
|
pub scope: String,
|
|
pub excerpt: String,
|
|
pub approved: bool,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardFriendLinkItem {
|
|
pub id: i32,
|
|
pub site_name: String,
|
|
pub site_url: String,
|
|
pub category: String,
|
|
pub status: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardReviewItem {
|
|
pub id: i32,
|
|
pub title: String,
|
|
pub review_type: String,
|
|
pub rating: i32,
|
|
pub status: String,
|
|
pub review_date: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct DashboardSiteSummary {
|
|
pub site_name: String,
|
|
pub site_url: String,
|
|
pub ai_enabled: bool,
|
|
pub ai_chunks: u64,
|
|
pub ai_last_indexed_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminDashboardResponse {
|
|
pub stats: DashboardStats,
|
|
pub site: DashboardSiteSummary,
|
|
pub recent_posts: Vec<DashboardPostItem>,
|
|
pub pending_comments: Vec<DashboardCommentItem>,
|
|
pub pending_friend_links: Vec<DashboardFriendLinkItem>,
|
|
pub recent_reviews: Vec<DashboardReviewItem>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminSiteSettingsResponse {
|
|
pub id: i32,
|
|
pub site_name: Option<String>,
|
|
pub site_short_name: Option<String>,
|
|
pub site_url: Option<String>,
|
|
pub site_title: Option<String>,
|
|
pub site_description: Option<String>,
|
|
pub hero_title: Option<String>,
|
|
pub hero_subtitle: Option<String>,
|
|
pub owner_name: Option<String>,
|
|
pub owner_title: Option<String>,
|
|
pub owner_bio: Option<String>,
|
|
pub owner_avatar_url: Option<String>,
|
|
pub social_github: Option<String>,
|
|
pub social_twitter: Option<String>,
|
|
pub social_email: Option<String>,
|
|
pub location: Option<String>,
|
|
pub tech_stack: Vec<String>,
|
|
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
|
|
pub ai_enabled: bool,
|
|
pub paragraph_comments_enabled: bool,
|
|
pub comment_turnstile_enabled: bool,
|
|
pub subscription_turnstile_enabled: bool,
|
|
pub web_push_enabled: bool,
|
|
pub turnstile_site_key: Option<String>,
|
|
pub turnstile_secret_key: Option<String>,
|
|
pub web_push_vapid_public_key: Option<String>,
|
|
pub web_push_vapid_private_key: Option<String>,
|
|
pub web_push_vapid_subject: Option<String>,
|
|
pub ai_provider: Option<String>,
|
|
pub ai_api_base: Option<String>,
|
|
pub ai_api_key: Option<String>,
|
|
pub ai_chat_model: Option<String>,
|
|
pub ai_image_provider: Option<String>,
|
|
pub ai_image_api_base: Option<String>,
|
|
pub ai_image_api_key: Option<String>,
|
|
pub ai_image_model: Option<String>,
|
|
pub ai_providers: Vec<site_settings::AiProviderConfig>,
|
|
pub ai_active_provider_id: Option<String>,
|
|
pub ai_embedding_model: Option<String>,
|
|
pub ai_system_prompt: Option<String>,
|
|
pub ai_top_k: Option<i32>,
|
|
pub ai_chunk_size: Option<i32>,
|
|
pub ai_last_indexed_at: Option<String>,
|
|
pub ai_chunks_count: u64,
|
|
pub ai_local_embedding: String,
|
|
pub media_storage_provider: Option<String>,
|
|
pub media_r2_account_id: Option<String>,
|
|
pub media_r2_bucket: Option<String>,
|
|
pub media_r2_public_base_url: Option<String>,
|
|
pub media_r2_access_key_id: Option<String>,
|
|
pub media_r2_secret_access_key: Option<String>,
|
|
pub seo_default_og_image: Option<String>,
|
|
pub seo_default_twitter_handle: Option<String>,
|
|
pub notification_webhook_url: Option<String>,
|
|
pub notification_channel_type: String,
|
|
pub notification_comment_enabled: bool,
|
|
pub notification_friend_link_enabled: bool,
|
|
pub subscription_popup_enabled: bool,
|
|
pub subscription_popup_title: String,
|
|
pub subscription_popup_description: String,
|
|
pub subscription_popup_delay_seconds: i32,
|
|
pub search_synonyms: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminAiReindexResponse {
|
|
pub indexed_chunks: usize,
|
|
pub last_indexed_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminAiProviderTestRequest {
|
|
pub provider: site_settings::AiProviderConfig,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminAiProviderTestResponse {
|
|
pub provider: String,
|
|
pub endpoint: String,
|
|
pub chat_model: String,
|
|
pub reply_preview: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminAiImageProviderTestRequest {
|
|
pub provider: String,
|
|
pub api_base: String,
|
|
pub api_key: String,
|
|
pub image_model: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminAiImageProviderTestResponse {
|
|
pub provider: String,
|
|
pub endpoint: String,
|
|
pub image_model: String,
|
|
pub result_preview: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminImageUploadResponse {
|
|
pub url: String,
|
|
pub key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminR2ConnectivityResponse {
|
|
pub bucket: String,
|
|
pub public_base_url: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaObjectResponse {
|
|
pub key: String,
|
|
pub url: String,
|
|
pub size_bytes: i64,
|
|
pub last_modified: Option<String>,
|
|
pub title: Option<String>,
|
|
pub alt_text: Option<String>,
|
|
pub caption: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaListResponse {
|
|
pub provider: String,
|
|
pub bucket: String,
|
|
pub public_base_url: String,
|
|
pub items: Vec<AdminMediaObjectResponse>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaDeleteResponse {
|
|
pub deleted: bool,
|
|
pub key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaUploadItem {
|
|
pub key: String,
|
|
pub url: String,
|
|
pub size_bytes: i64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaUploadResponse {
|
|
pub uploaded: Vec<AdminMediaUploadItem>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminMediaBatchDeleteRequest {
|
|
#[serde(default)]
|
|
pub keys: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaBatchDeleteResponse {
|
|
pub deleted: Vec<String>,
|
|
pub failed: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaReplaceResponse {
|
|
pub key: String,
|
|
pub url: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminMediaMetadataPayload {
|
|
pub key: String,
|
|
#[serde(default)]
|
|
pub title: Option<String>,
|
|
#[serde(default)]
|
|
pub alt_text: Option<String>,
|
|
#[serde(default)]
|
|
pub caption: Option<String>,
|
|
#[serde(default)]
|
|
pub tags: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminMediaMetadataResponse {
|
|
pub saved: bool,
|
|
pub key: String,
|
|
pub title: Option<String>,
|
|
pub alt_text: Option<String>,
|
|
pub caption: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminMediaListQuery {
|
|
pub prefix: Option<String>,
|
|
pub limit: Option<i32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminMediaDeleteQuery {
|
|
pub key: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminCommentBlacklistItem {
|
|
pub id: i32,
|
|
pub matcher_type: String,
|
|
pub matcher_value: String,
|
|
pub reason: Option<String>,
|
|
pub active: bool,
|
|
pub expires_at: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub effective: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminCommentBlacklistCreateRequest {
|
|
pub matcher_type: String,
|
|
pub matcher_value: String,
|
|
pub reason: Option<String>,
|
|
pub active: Option<bool>,
|
|
pub expires_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminCommentBlacklistUpdateRequest {
|
|
pub reason: Option<String>,
|
|
pub active: Option<bool>,
|
|
pub expires_at: Option<String>,
|
|
#[serde(default)]
|
|
pub clear_expires_at: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminCommentBlacklistDeleteResponse {
|
|
pub deleted: bool,
|
|
pub id: i32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminCommentAnalyzeRequest {
|
|
pub matcher_type: String,
|
|
pub matcher_value: String,
|
|
pub from: Option<String>,
|
|
pub to: Option<String>,
|
|
pub limit: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminCommentAnalyzeLogsQuery {
|
|
pub matcher_type: Option<String>,
|
|
pub matcher_value: Option<String>,
|
|
pub limit: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct AdminCommentAnalyzeSample {
|
|
pub id: i32,
|
|
pub created_at: String,
|
|
pub post_slug: String,
|
|
pub author: String,
|
|
pub email: String,
|
|
pub approved: bool,
|
|
pub content_preview: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminCommentAnalyzeResponse {
|
|
pub matcher_type: String,
|
|
pub matcher_value: String,
|
|
pub total_comments: u64,
|
|
pub pending_comments: u64,
|
|
pub first_seen_at: Option<String>,
|
|
pub latest_seen_at: Option<String>,
|
|
pub distinct_posts: usize,
|
|
pub analysis: String,
|
|
pub samples: Vec<AdminCommentAnalyzeSample>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AdminCommentAnalyzeLogItem {
|
|
pub id: i32,
|
|
pub matcher_type: String,
|
|
pub matcher_value: String,
|
|
pub from_at: Option<String>,
|
|
pub to_at: Option<String>,
|
|
pub total_comments: u64,
|
|
pub pending_comments: u64,
|
|
pub distinct_posts: usize,
|
|
pub analysis: String,
|
|
pub samples: Vec<AdminCommentAnalyzeSample>,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminPostMetadataRequest {
|
|
pub markdown: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminPostPolishRequest {
|
|
pub markdown: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminReviewPolishRequest {
|
|
pub title: String,
|
|
pub review_type: String,
|
|
pub rating: i32,
|
|
pub review_date: Option<String>,
|
|
pub status: String,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
pub description: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
pub struct AdminPostCoverImageRequest {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub category: Option<String>,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
pub post_type: String,
|
|
pub slug: Option<String>,
|
|
pub markdown: String,
|
|
}
|
|
|
|
fn format_timestamp(
|
|
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
|
|
pattern: &str,
|
|
) -> Option<String> {
|
|
value.map(|item| item.format(pattern).to_string())
|
|
}
|
|
|
|
fn required_text(value: Option<&str>, fallback: &str) -> String {
|
|
value
|
|
.map(str::trim)
|
|
.filter(|item| !item.is_empty())
|
|
.unwrap_or(fallback)
|
|
.to_string()
|
|
}
|
|
|
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
|
value.and_then(|item| {
|
|
let trimmed = item.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn parse_optional_timestamp(
|
|
value: Option<&str>,
|
|
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> {
|
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
chrono::DateTime::parse_from_rfc3339(value)
|
|
.map(Some)
|
|
.map_err(|_| Error::BadRequest("expires_at 必须是 RFC3339 时间格式".to_string()))
|
|
}
|
|
|
|
fn parse_optional_datetime_utc(
|
|
value: Option<&str>,
|
|
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
|
|
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(value) {
|
|
return Ok(Some(parsed.with_timezone(&chrono::Utc)));
|
|
}
|
|
|
|
if let Ok(date_only) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
|
|
let Some(naive) = date_only.and_hms_opt(0, 0, 0) else {
|
|
return Ok(None);
|
|
};
|
|
return Ok(Some(
|
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc),
|
|
));
|
|
}
|
|
|
|
Err(Error::BadRequest(
|
|
"from/to 必须是 RFC3339 或 YYYY-MM-DD 格式".to_string(),
|
|
))
|
|
}
|
|
|
|
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.chars().count() <= max_chars {
|
|
return trimmed.to_string();
|
|
}
|
|
|
|
let mut sliced = trimmed.chars().take(max_chars).collect::<String>();
|
|
sliced.push_str("...");
|
|
sliced
|
|
}
|
|
|
|
async fn save_comment_persona_analysis_log(
|
|
ctx: &AppContext,
|
|
matcher_type: &str,
|
|
matcher_value: &str,
|
|
from: Option<chrono::DateTime<chrono::Utc>>,
|
|
to: Option<chrono::DateTime<chrono::Utc>>,
|
|
total_comments: u64,
|
|
pending_comments: u64,
|
|
distinct_posts: usize,
|
|
analysis_text: &str,
|
|
samples: &[AdminCommentAnalyzeSample],
|
|
) -> Result<()> {
|
|
let sample_json = serde_json::to_value(samples).ok();
|
|
|
|
comment_persona_analysis_logs::ActiveModel {
|
|
matcher_type: Set(matcher_type.to_string()),
|
|
matcher_value: Set(matcher_value.to_string()),
|
|
from_at: Set(from.map(|value| value.fixed_offset())),
|
|
to_at: Set(to.map(|value| value.fixed_offset())),
|
|
total_comments: Set(total_comments.min(i32::MAX as u64) as i32),
|
|
pending_comments: Set(pending_comments.min(i32::MAX as u64) as i32),
|
|
distinct_posts: Set(distinct_posts.min(i32::MAX as usize) as i32),
|
|
analysis_text: Set(analysis_text.to_string()),
|
|
sample_json: Set(sample_json),
|
|
..Default::default()
|
|
}
|
|
.insert(&ctx.db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn format_comment_analyze_log_item(
|
|
item: comment_persona_analysis_logs::Model,
|
|
) -> AdminCommentAnalyzeLogItem {
|
|
let samples = item
|
|
.sample_json
|
|
.clone()
|
|
.and_then(|value| serde_json::from_value::<Vec<AdminCommentAnalyzeSample>>(value).ok())
|
|
.unwrap_or_default();
|
|
|
|
AdminCommentAnalyzeLogItem {
|
|
id: item.id,
|
|
matcher_type: item.matcher_type,
|
|
matcher_value: item.matcher_value,
|
|
from_at: format_timestamp(item.from_at, "%Y-%m-%d %H:%M:%S UTC"),
|
|
to_at: format_timestamp(item.to_at, "%Y-%m-%d %H:%M:%S UTC"),
|
|
total_comments: item.total_comments.max(0) as u64,
|
|
pending_comments: item.pending_comments.max(0) as u64,
|
|
distinct_posts: item.distinct_posts.max(0) as usize,
|
|
analysis: item.analysis_text,
|
|
samples,
|
|
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
}
|
|
}
|
|
|
|
fn format_comment_blacklist_item(item: comment_blacklist::Model) -> AdminCommentBlacklistItem {
|
|
let now = chrono::Utc::now();
|
|
let active = item.active.unwrap_or(true);
|
|
let not_expired = item
|
|
.expires_at
|
|
.map(|value| chrono::DateTime::<chrono::Utc>::from(value) > now)
|
|
.unwrap_or(true);
|
|
|
|
AdminCommentBlacklistItem {
|
|
id: item.id,
|
|
matcher_type: item.matcher_type,
|
|
matcher_value: item.matcher_value,
|
|
reason: item.reason,
|
|
active,
|
|
expires_at: format_timestamp(item.expires_at, "%Y-%m-%d %H:%M:%S UTC"),
|
|
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
updated_at: item.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
effective: active && not_expired,
|
|
}
|
|
}
|
|
|
|
fn infer_media_extension(file_name: Option<&str>, content_type: Option<&str>) -> String {
|
|
let from_name = file_name
|
|
.and_then(|name| name.rsplit('.').next())
|
|
.map(str::trim)
|
|
.filter(|ext| !ext.is_empty())
|
|
.map(str::to_ascii_lowercase);
|
|
|
|
if let Some(ext) = from_name
|
|
.as_deref()
|
|
.filter(|ext| ext.chars().all(|ch| ch.is_ascii_alphanumeric()) && ext.len() <= 10)
|
|
{
|
|
return ext.to_string();
|
|
}
|
|
|
|
match content_type
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_ascii_lowercase()
|
|
.as_str()
|
|
{
|
|
"image/png" => "png".to_string(),
|
|
"image/jpeg" => "jpg".to_string(),
|
|
"image/webp" => "webp".to_string(),
|
|
"image/gif" => "gif".to_string(),
|
|
"image/avif" => "avif".to_string(),
|
|
"image/svg+xml" => "svg".to_string(),
|
|
"application/pdf" => "pdf".to_string(),
|
|
_ => "bin".to_string(),
|
|
}
|
|
}
|
|
|
|
fn normalize_media_key(value: Option<String>) -> Option<String> {
|
|
value.and_then(|raw| {
|
|
let trimmed = raw.trim().trim_start_matches('/').to_string();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn build_media_object_response(
|
|
item: storage::StoredObjectSummary,
|
|
metadata: Option<&crate::models::_entities::media_assets::Model>,
|
|
) -> AdminMediaObjectResponse {
|
|
AdminMediaObjectResponse {
|
|
key: item.key,
|
|
url: item.url,
|
|
size_bytes: item.size_bytes,
|
|
last_modified: item.last_modified,
|
|
title: metadata.and_then(|entry| entry.title.clone()),
|
|
alt_text: metadata.and_then(|entry| entry.alt_text.clone()),
|
|
caption: metadata.and_then(|entry| entry.caption.clone()),
|
|
tags: metadata
|
|
.map(media_assets::tag_list)
|
|
.unwrap_or_default(),
|
|
notes: metadata.and_then(|entry| entry.notes.clone()),
|
|
}
|
|
}
|
|
|
|
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
|
value
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|item| item.as_str().map(ToString::to_string))
|
|
.collect()
|
|
}
|
|
|
|
fn music_playlist_values(
|
|
value: &Option<serde_json::Value>,
|
|
) -> Vec<site_settings::MusicTrackPayload> {
|
|
value
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|item| serde_json::from_value::<site_settings::MusicTrackPayload>(item).ok())
|
|
.filter(|item| !item.title.trim().is_empty() && !item.url.trim().is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn build_settings_response(
|
|
item: crate::models::_entities::site_settings::Model,
|
|
ai_chunks_count: u64,
|
|
) -> AdminSiteSettingsResponse {
|
|
let ai_providers = site_settings::ai_provider_configs(&item);
|
|
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
|
|
let turnstile_site_key = crate::services::turnstile::site_key(&item);
|
|
let turnstile_secret_key = crate::services::turnstile::secret_key(&item);
|
|
let web_push_vapid_public_key = crate::services::web_push::public_key(&item);
|
|
let web_push_vapid_private_key = crate::services::web_push::private_key(&item);
|
|
let web_push_vapid_subject = crate::services::web_push::vapid_subject(&item);
|
|
|
|
AdminSiteSettingsResponse {
|
|
id: item.id,
|
|
site_name: item.site_name,
|
|
site_short_name: item.site_short_name,
|
|
site_url: item.site_url,
|
|
site_title: item.site_title,
|
|
site_description: item.site_description,
|
|
hero_title: item.hero_title,
|
|
hero_subtitle: item.hero_subtitle,
|
|
owner_name: item.owner_name,
|
|
owner_title: item.owner_title,
|
|
owner_bio: item.owner_bio,
|
|
owner_avatar_url: item.owner_avatar_url,
|
|
social_github: item.social_github,
|
|
social_twitter: item.social_twitter,
|
|
social_email: item.social_email,
|
|
location: item.location,
|
|
tech_stack: tech_stack_values(&item.tech_stack),
|
|
music_playlist: music_playlist_values(&item.music_playlist),
|
|
ai_enabled: item.ai_enabled.unwrap_or(false),
|
|
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
|
|
comment_turnstile_enabled: item.comment_turnstile_enabled.unwrap_or(false),
|
|
subscription_turnstile_enabled: item.subscription_turnstile_enabled.unwrap_or(false),
|
|
web_push_enabled: item.web_push_enabled.unwrap_or(false),
|
|
turnstile_site_key,
|
|
turnstile_secret_key,
|
|
web_push_vapid_public_key,
|
|
web_push_vapid_private_key,
|
|
web_push_vapid_subject,
|
|
ai_provider: item.ai_provider,
|
|
ai_api_base: item.ai_api_base,
|
|
ai_api_key: item.ai_api_key,
|
|
ai_chat_model: item.ai_chat_model,
|
|
ai_image_provider: item.ai_image_provider,
|
|
ai_image_api_base: item.ai_image_api_base,
|
|
ai_image_api_key: item.ai_image_api_key,
|
|
ai_image_model: item.ai_image_model,
|
|
ai_providers,
|
|
ai_active_provider_id,
|
|
ai_embedding_model: item.ai_embedding_model,
|
|
ai_system_prompt: item.ai_system_prompt,
|
|
ai_top_k: item.ai_top_k,
|
|
ai_chunk_size: item.ai_chunk_size,
|
|
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
|
|
ai_chunks_count,
|
|
ai_local_embedding: ai::local_embedding_label().to_string(),
|
|
media_storage_provider: item.media_storage_provider,
|
|
media_r2_account_id: item.media_r2_account_id,
|
|
media_r2_bucket: item.media_r2_bucket,
|
|
media_r2_public_base_url: item.media_r2_public_base_url,
|
|
media_r2_access_key_id: item.media_r2_access_key_id,
|
|
media_r2_secret_access_key: item.media_r2_secret_access_key,
|
|
seo_default_og_image: item.seo_default_og_image,
|
|
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
|
notification_webhook_url: item.notification_webhook_url,
|
|
notification_channel_type: item
|
|
.notification_channel_type
|
|
.unwrap_or_else(|| "webhook".to_string()),
|
|
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
|
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
|
subscription_popup_enabled: item
|
|
.subscription_popup_enabled
|
|
.unwrap_or_else(site_settings::default_subscription_popup_enabled),
|
|
subscription_popup_title: item
|
|
.subscription_popup_title
|
|
.unwrap_or_else(site_settings::default_subscription_popup_title),
|
|
subscription_popup_description: item
|
|
.subscription_popup_description
|
|
.unwrap_or_else(site_settings::default_subscription_popup_description),
|
|
subscription_popup_delay_seconds: item
|
|
.subscription_popup_delay_seconds
|
|
.unwrap_or_else(site_settings::default_subscription_popup_delay_seconds),
|
|
search_synonyms: tech_stack_values(&item.search_synonyms),
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn session_status(headers: HeaderMap) -> Result<Response> {
|
|
format::json(build_session_response(resolve_admin_identity(&headers)))
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn session_login(
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminLoginPayload>,
|
|
) -> Result<Response> {
|
|
if !local_login_enabled() {
|
|
return unauthorized("Local admin login is disabled");
|
|
}
|
|
|
|
if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) {
|
|
return unauthorized("Invalid credentials");
|
|
}
|
|
|
|
let (identity, _token, cookie) = start_local_session(&admin_username());
|
|
admin_audit::log_event(
|
|
&ctx,
|
|
Some(&identity),
|
|
"admin.login",
|
|
"admin_session",
|
|
None,
|
|
Some(identity.username.clone()),
|
|
None,
|
|
)
|
|
.await?;
|
|
let mut response = format::json(build_session_response(Some(identity.clone())))?;
|
|
response.headers_mut().append(
|
|
header::SET_COOKIE,
|
|
cookie
|
|
.parse()
|
|
.map_err(|error| Error::BadRequest(format!("invalid session cookie: {error}")))?,
|
|
);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
|
let before = resolve_admin_identity(&headers);
|
|
if matches!(
|
|
before.as_ref().map(|item| item.source.as_str()),
|
|
Some("local")
|
|
) {
|
|
clear_local_session(&headers);
|
|
}
|
|
|
|
if let Some(identity) = before.as_ref() {
|
|
admin_audit::log_event(
|
|
&ctx,
|
|
Some(identity),
|
|
"admin.logout",
|
|
"admin_session",
|
|
None,
|
|
identity
|
|
.email
|
|
.clone()
|
|
.or_else(|| Some(identity.username.clone())),
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let after = resolve_admin_identity(&headers).filter(|item| item.source != "local");
|
|
let mut response = format::json(build_session_response(after))?;
|
|
response.headers_mut().append(
|
|
header::SET_COOKIE,
|
|
clear_local_session_cookie()
|
|
.parse()
|
|
.map_err(|error| Error::BadRequest(format!("invalid logout cookie: {error}")))?,
|
|
);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
content::sync_markdown_posts(&ctx).await?;
|
|
|
|
let all_posts = posts::Entity::find().all(&ctx.db).await?;
|
|
let total_posts = all_posts.len() as u64;
|
|
let total_comments = comments::Entity::find().count(&ctx.db).await?;
|
|
let pending_comments = comments::Entity::find()
|
|
.filter(comments::Column::Approved.eq(false))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
let total_categories = crate::models::_entities::categories::Entity::find()
|
|
.count(&ctx.db)
|
|
.await?;
|
|
let total_tags = crate::models::_entities::tags::Entity::find()
|
|
.count(&ctx.db)
|
|
.await?;
|
|
let total_reviews = reviews::Entity::find().count(&ctx.db).await?;
|
|
let total_links = friend_links::Entity::find().count(&ctx.db).await?;
|
|
let pending_links = friend_links::Entity::find()
|
|
.filter(friend_links::Column::Status.eq("pending"))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
|
let site_settings = site_settings::load_current(&ctx).await?;
|
|
let now = chrono::Utc::now().fixed_offset();
|
|
|
|
let mut draft_posts = 0_u64;
|
|
let mut scheduled_posts = 0_u64;
|
|
let mut offline_posts = 0_u64;
|
|
let mut expired_posts = 0_u64;
|
|
let mut private_posts = 0_u64;
|
|
let mut unlisted_posts = 0_u64;
|
|
|
|
for post in &all_posts {
|
|
let effective_state = content::effective_post_state(
|
|
post.status
|
|
.as_deref()
|
|
.unwrap_or(content::POST_STATUS_PUBLISHED),
|
|
post.publish_at,
|
|
post.unpublish_at,
|
|
now,
|
|
);
|
|
let visibility = content::normalize_post_visibility(post.visibility.as_deref());
|
|
|
|
match effective_state.as_str() {
|
|
content::POST_STATUS_DRAFT => draft_posts += 1,
|
|
content::POST_STATUS_OFFLINE => offline_posts += 1,
|
|
"scheduled" => scheduled_posts += 1,
|
|
"expired" => expired_posts += 1,
|
|
_ => {}
|
|
}
|
|
|
|
match visibility.as_str() {
|
|
content::POST_VISIBILITY_PRIVATE => private_posts += 1,
|
|
content::POST_VISIBILITY_UNLISTED => unlisted_posts += 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let mut recent_posts = all_posts.clone().into_iter().collect::<Vec<_>>();
|
|
recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
|
let recent_posts = recent_posts
|
|
.into_iter()
|
|
.take(6)
|
|
.map(|post| DashboardPostItem {
|
|
id: post.id,
|
|
title: required_text(post.title.as_deref(), "Untitled post"),
|
|
slug: post.slug,
|
|
category: required_text(post.category.as_deref(), "Uncategorized"),
|
|
post_type: required_text(post.post_type.as_deref(), "article"),
|
|
pinned: post.pinned.unwrap_or(false),
|
|
status: content::effective_post_state(
|
|
post.status
|
|
.as_deref()
|
|
.unwrap_or(content::POST_STATUS_PUBLISHED),
|
|
post.publish_at,
|
|
post.unpublish_at,
|
|
now,
|
|
),
|
|
visibility: content::normalize_post_visibility(post.visibility.as_deref()),
|
|
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let pending_comment_rows = comments::Entity::find()
|
|
.filter(comments::Column::Approved.eq(false))
|
|
.order_by_desc(comments::Column::CreatedAt)
|
|
.limit(8)
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|comment| DashboardCommentItem {
|
|
id: comment.id,
|
|
author: required_text(comment.author.as_deref(), "Anonymous"),
|
|
post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"),
|
|
scope: required_text(Some(comment.scope.as_str()), "global"),
|
|
excerpt: required_text(comment.content.as_deref(), ""),
|
|
approved: comment.approved.unwrap_or(false),
|
|
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let pending_friend_links = friend_links::Entity::find()
|
|
.filter(friend_links::Column::Status.eq("pending"))
|
|
.order_by_desc(friend_links::Column::CreatedAt)
|
|
.limit(6)
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|link| DashboardFriendLinkItem {
|
|
id: link.id,
|
|
site_name: required_text(link.site_name.as_deref(), "Unnamed site"),
|
|
site_url: link.site_url,
|
|
category: required_text(link.category.as_deref(), "Other"),
|
|
status: required_text(link.status.as_deref(), "pending"),
|
|
created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let recent_reviews = reviews::Entity::find()
|
|
.order_by_desc(reviews::Column::CreatedAt)
|
|
.limit(6)
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|review| DashboardReviewItem {
|
|
id: review.id,
|
|
title: required_text(review.title.as_deref(), "Untitled review"),
|
|
review_type: required_text(review.review_type.as_deref(), "game"),
|
|
rating: review.rating.unwrap_or(0),
|
|
status: required_text(review.status.as_deref(), "completed"),
|
|
review_date: required_text(review.review_date.as_deref(), ""),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
format::json(AdminDashboardResponse {
|
|
stats: DashboardStats {
|
|
total_posts,
|
|
total_comments,
|
|
pending_comments,
|
|
draft_posts,
|
|
scheduled_posts,
|
|
offline_posts,
|
|
expired_posts,
|
|
private_posts,
|
|
unlisted_posts,
|
|
total_categories,
|
|
total_tags,
|
|
total_reviews,
|
|
total_links,
|
|
pending_links,
|
|
ai_chunks: ai_chunks_count,
|
|
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
|
|
},
|
|
site: DashboardSiteSummary {
|
|
site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"),
|
|
site_url: required_text(site_settings.site_url.as_deref(), ""),
|
|
ai_enabled: site_settings.ai_enabled.unwrap_or(false),
|
|
ai_chunks: ai_chunks_count,
|
|
ai_last_indexed_at: format_timestamp(
|
|
site_settings.ai_last_indexed_at,
|
|
"%Y-%m-%d %H:%M:%S UTC",
|
|
),
|
|
},
|
|
recent_posts,
|
|
pending_comments: pending_comment_rows,
|
|
pending_friend_links,
|
|
recent_reviews,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn analytics_overview(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
format::json(analytics::build_admin_analytics(&ctx).await?)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn get_site_settings(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
let current = site_settings::load_current(&ctx).await?;
|
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
|
format::json(build_settings_response(current, ai_chunks_count))
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn update_site_settings(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(params): Json<SiteSettingsPayload>,
|
|
) -> Result<Response> {
|
|
let actor = check_auth(&headers)?;
|
|
|
|
let current = site_settings::load_current(&ctx).await?;
|
|
let mut item = current;
|
|
params.apply(&mut item);
|
|
let item = item.into_active_model().reset_all();
|
|
let updated = item.update(&ctx.db).await?;
|
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
|
admin_audit::log_event(
|
|
&ctx,
|
|
Some(&actor),
|
|
"site_settings.update",
|
|
"site_settings",
|
|
Some(updated.id.to_string()),
|
|
updated.site_name.clone(),
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
format::json(build_settings_response(updated, ai_chunks_count))
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
let summary = ai::rebuild_index(&ctx).await?;
|
|
|
|
format::json(AdminAiReindexResponse {
|
|
indexed_chunks: summary.indexed_chunks,
|
|
last_indexed_at: format_timestamp(
|
|
summary.last_indexed_at.map(Into::into),
|
|
"%Y-%m-%d %H:%M:%S UTC",
|
|
),
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn test_ai_provider(
|
|
headers: HeaderMap,
|
|
Json(payload): Json<AdminAiProviderTestRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let result = ai::test_provider_connectivity(
|
|
&payload.provider.provider,
|
|
payload.provider.api_base.as_deref().unwrap_or_default(),
|
|
payload.provider.api_key.as_deref().unwrap_or_default(),
|
|
payload.provider.chat_model.as_deref().unwrap_or_default(),
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminAiProviderTestResponse {
|
|
provider: result.provider,
|
|
endpoint: result.endpoint,
|
|
chat_model: result.chat_model,
|
|
reply_preview: result.reply_preview,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn test_ai_image_provider(
|
|
headers: HeaderMap,
|
|
Json(payload): Json<AdminAiImageProviderTestRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let result = ai::test_image_provider_connectivity(
|
|
&payload.provider,
|
|
&payload.api_base,
|
|
&payload.api_key,
|
|
&payload.image_model,
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminAiImageProviderTestResponse {
|
|
provider: result.provider,
|
|
endpoint: result.endpoint,
|
|
image_model: result.image_model,
|
|
result_preview: result.result_preview,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn test_r2_storage(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let settings = storage::require_r2_settings(&ctx).await?;
|
|
let bucket = storage::test_r2_connectivity(&ctx).await?;
|
|
|
|
format::json(AdminR2ConnectivityResponse {
|
|
bucket,
|
|
public_base_url: settings.public_base_url,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn list_media_objects(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Query(query): Query<AdminMediaListQuery>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let settings = storage::require_r2_settings(&ctx).await?;
|
|
let objects = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
|
|
.await?;
|
|
let keys = objects
|
|
.iter()
|
|
.map(|item| item.key.clone())
|
|
.collect::<Vec<_>>();
|
|
let metadata_map = media_assets::list_by_keys(&ctx, &keys).await?;
|
|
let items = objects
|
|
.into_iter()
|
|
.map(|item| {
|
|
let metadata = metadata_map.get(&item.key);
|
|
build_media_object_response(item, metadata)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
format::json(AdminMediaListResponse {
|
|
provider: settings.provider_name,
|
|
bucket: settings.bucket,
|
|
public_base_url: settings.public_base_url,
|
|
items,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn delete_media_object(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Query(query): Query<AdminMediaDeleteQuery>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let key = query.key.trim();
|
|
if key.is_empty() {
|
|
return Err(Error::BadRequest("缺少对象 key".to_string()));
|
|
}
|
|
|
|
storage::delete_object(&ctx, key).await?;
|
|
if let Err(error) = media_assets::delete_by_key(&ctx, key).await {
|
|
tracing::warn!(?error, key, "failed to delete media metadata after object deletion");
|
|
}
|
|
|
|
format::json(AdminMediaDeleteResponse {
|
|
deleted: true,
|
|
key: key.to_string(),
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn upload_media_objects(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let mut prefix = "uploads".to_string();
|
|
let mut uploaded = Vec::<AdminMediaUploadItem>::new();
|
|
|
|
while let Some(field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
|
{
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
if name == "prefix" {
|
|
if let Ok(value) = field.text().await {
|
|
if let Some(next_prefix) = trim_to_option(Some(value)) {
|
|
prefix = next_prefix.trim_matches('/').to_string();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let file_name = field.file_name().map(ToString::to_string);
|
|
let content_type = field.content_type().map(ToString::to_string);
|
|
let bytes = field
|
|
.bytes()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?;
|
|
|
|
if bytes.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let extension = infer_media_extension(file_name.as_deref(), content_type.as_deref());
|
|
let key =
|
|
storage::build_object_key(&prefix, file_name.as_deref().unwrap_or("asset"), &extension);
|
|
let stored = storage::upload_bytes_to_r2(
|
|
&ctx,
|
|
&key,
|
|
bytes.to_vec(),
|
|
content_type.as_deref(),
|
|
Some("public, max-age=31536000, immutable"),
|
|
)
|
|
.await?;
|
|
|
|
uploaded.push(AdminMediaUploadItem {
|
|
key: stored.key,
|
|
url: stored.url,
|
|
size_bytes: bytes.len() as i64,
|
|
});
|
|
}
|
|
|
|
if uploaded.is_empty() {
|
|
return Err(Error::BadRequest("请至少选择一个文件上传".to_string()));
|
|
}
|
|
|
|
format::json(AdminMediaUploadResponse { uploaded })
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn batch_delete_media_objects(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminMediaBatchDeleteRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let keys = payload
|
|
.keys
|
|
.into_iter()
|
|
.filter_map(|key| normalize_media_key(Some(key)))
|
|
.collect::<Vec<_>>();
|
|
|
|
if keys.is_empty() {
|
|
return Err(Error::BadRequest("请至少传入一个对象 key".to_string()));
|
|
}
|
|
|
|
let mut deleted = Vec::new();
|
|
let mut failed = Vec::new();
|
|
|
|
for key in keys {
|
|
match storage::delete_object(&ctx, &key).await {
|
|
Ok(()) => {
|
|
if let Err(error) = media_assets::delete_by_key(&ctx, &key).await {
|
|
tracing::warn!(?error, key, "failed to delete media metadata after batch removal");
|
|
}
|
|
deleted.push(key)
|
|
}
|
|
Err(_) => failed.push(key),
|
|
}
|
|
}
|
|
|
|
format::json(AdminMediaBatchDeleteResponse { deleted, failed })
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn update_media_object_metadata(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminMediaMetadataPayload>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let key = payload.key.trim();
|
|
if key.is_empty() {
|
|
return Err(Error::BadRequest("缺少对象 key".to_string()));
|
|
}
|
|
|
|
let metadata = media_assets::upsert_by_key(
|
|
&ctx,
|
|
key,
|
|
media_assets::MediaAssetMetadataInput {
|
|
title: payload.title,
|
|
alt_text: payload.alt_text,
|
|
caption: payload.caption,
|
|
tags: payload.tags,
|
|
notes: payload.notes,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminMediaMetadataResponse {
|
|
saved: true,
|
|
key: metadata.object_key.clone(),
|
|
title: metadata.title.clone(),
|
|
alt_text: metadata.alt_text.clone(),
|
|
caption: metadata.caption.clone(),
|
|
tags: media_assets::tag_list(&metadata),
|
|
notes: metadata.notes.clone(),
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn replace_media_object(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let mut key: Option<String> = None;
|
|
let mut bytes: Option<Vec<u8>> = None;
|
|
let mut content_type: Option<String> = None;
|
|
|
|
while let Some(field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
|
{
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
if name == "key" {
|
|
let text = field
|
|
.text()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?;
|
|
key = normalize_media_key(Some(text));
|
|
continue;
|
|
}
|
|
|
|
if bytes.is_none() {
|
|
content_type = field.content_type().map(ToString::to_string);
|
|
bytes = Some(
|
|
field
|
|
.bytes()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
|
.to_vec(),
|
|
);
|
|
}
|
|
}
|
|
|
|
let key = key.ok_or_else(|| Error::BadRequest("缺少待替换对象 key".to_string()))?;
|
|
let bytes = bytes.ok_or_else(|| Error::BadRequest("请先选择替换文件".to_string()))?;
|
|
|
|
if bytes.is_empty() {
|
|
return Err(Error::BadRequest("替换文件内容为空".to_string()));
|
|
}
|
|
|
|
let stored = storage::upload_bytes_to_r2(
|
|
&ctx,
|
|
&key,
|
|
bytes,
|
|
content_type.as_deref(),
|
|
Some("public, max-age=31536000, immutable"),
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminMediaReplaceResponse {
|
|
key: stored.key,
|
|
url: stored.url,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn list_comment_blacklist(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let items = comment_blacklist::Entity::find()
|
|
.order_by_desc(comment_blacklist::Column::CreatedAt)
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(format_comment_blacklist_item)
|
|
.collect::<Vec<_>>();
|
|
|
|
format::json(items)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn create_comment_blacklist(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminCommentBlacklistCreateRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let matcher_type =
|
|
comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| {
|
|
Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
|
|
})?;
|
|
let matcher_value =
|
|
comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value)
|
|
.ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?;
|
|
let expires_at = parse_optional_timestamp(payload.expires_at.as_deref())?;
|
|
|
|
let item = comment_blacklist::ActiveModel {
|
|
matcher_type: Set(matcher_type.to_string()),
|
|
matcher_value: Set(matcher_value),
|
|
reason: Set(trim_to_option(payload.reason)),
|
|
active: Set(Some(payload.active.unwrap_or(true))),
|
|
expires_at: Set(expires_at),
|
|
..Default::default()
|
|
}
|
|
.insert(&ctx.db)
|
|
.await?;
|
|
|
|
format::json(format_comment_blacklist_item(item))
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn update_comment_blacklist(
|
|
headers: HeaderMap,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminCommentBlacklistUpdateRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let item = comment_blacklist::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or(Error::NotFound)?;
|
|
let mut item = item.into_active_model();
|
|
|
|
if let Some(reason) = payload.reason {
|
|
item.reason = Set(trim_to_option(Some(reason)));
|
|
}
|
|
|
|
if let Some(active) = payload.active {
|
|
item.active = Set(Some(active));
|
|
}
|
|
|
|
if payload.clear_expires_at {
|
|
item.expires_at = Set(None);
|
|
} else if payload.expires_at.is_some() {
|
|
item.expires_at = Set(parse_optional_timestamp(payload.expires_at.as_deref())?);
|
|
}
|
|
|
|
let updated = item.update(&ctx.db).await?;
|
|
format::json(format_comment_blacklist_item(updated))
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn delete_comment_blacklist(
|
|
headers: HeaderMap,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
if let Some(item) = comment_blacklist::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
{
|
|
item.delete(&ctx.db).await?;
|
|
}
|
|
|
|
format::json(AdminCommentBlacklistDeleteResponse { deleted: true, id })
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn list_comment_persona_analysis_logs(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Query(query): Query<AdminCommentAnalyzeLogsQuery>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let matcher_type = query
|
|
.matcher_type
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| {
|
|
comment_guard::normalize_matcher_type(value).ok_or_else(|| {
|
|
Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
|
|
})
|
|
})
|
|
.transpose()?;
|
|
|
|
let matcher_value = query
|
|
.matcher_value
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| {
|
|
if let Some(matcher_type) = matcher_type {
|
|
comment_guard::normalize_matcher_value(matcher_type, value)
|
|
} else {
|
|
Some(value.to_string())
|
|
}
|
|
})
|
|
.flatten();
|
|
|
|
if query.matcher_value.is_some() && matcher_value.is_none() {
|
|
return Err(Error::BadRequest("matcher_value 不能为空".to_string()));
|
|
}
|
|
|
|
let mut query_builder = comment_persona_analysis_logs::Entity::find();
|
|
|
|
if let Some(matcher_type) = matcher_type {
|
|
query_builder = query_builder
|
|
.filter(comment_persona_analysis_logs::Column::MatcherType.eq(matcher_type));
|
|
}
|
|
|
|
if let Some(matcher_value) = matcher_value {
|
|
query_builder = query_builder
|
|
.filter(comment_persona_analysis_logs::Column::MatcherValue.eq(matcher_value));
|
|
}
|
|
|
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
|
|
|
let items = query_builder
|
|
.order_by_desc(comment_persona_analysis_logs::Column::CreatedAt)
|
|
.limit(limit)
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(format_comment_analyze_log_item)
|
|
.collect::<Vec<_>>();
|
|
|
|
format::json(items)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn analyze_comment_persona(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminCommentAnalyzeRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let matcher_type =
|
|
comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| {
|
|
Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
|
|
})?;
|
|
let matcher_value =
|
|
comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value)
|
|
.ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?;
|
|
let from = parse_optional_datetime_utc(payload.from.as_deref())?;
|
|
let to = parse_optional_datetime_utc(payload.to.as_deref())?;
|
|
let limit = payload.limit.unwrap_or(20).clamp(5, 80);
|
|
|
|
let build_query = || {
|
|
let mut query = comments::Entity::find();
|
|
|
|
query = match matcher_type {
|
|
comment_guard::MATCHER_TYPE_IP => {
|
|
query.filter(comments::Column::IpAddress.eq(&matcher_value))
|
|
}
|
|
comment_guard::MATCHER_TYPE_EMAIL => {
|
|
query.filter(comments::Column::Email.eq(&matcher_value))
|
|
}
|
|
comment_guard::MATCHER_TYPE_USER_AGENT => {
|
|
query.filter(comments::Column::UserAgent.eq(&matcher_value))
|
|
}
|
|
_ => query,
|
|
};
|
|
|
|
if let Some(from) = from {
|
|
query = query.filter(comments::Column::CreatedAt.gte(from));
|
|
}
|
|
if let Some(to) = to {
|
|
query = query.filter(comments::Column::CreatedAt.lte(to));
|
|
}
|
|
|
|
query
|
|
};
|
|
|
|
let total_comments = build_query().count(&ctx.db).await?;
|
|
if total_comments == 0 {
|
|
let analysis = "当前条件下没有匹配评论,无法生成画像。".to_string();
|
|
save_comment_persona_analysis_log(
|
|
&ctx,
|
|
matcher_type,
|
|
&matcher_value,
|
|
from,
|
|
to,
|
|
0,
|
|
0,
|
|
0,
|
|
&analysis,
|
|
&[],
|
|
)
|
|
.await?;
|
|
|
|
return format::json(AdminCommentAnalyzeResponse {
|
|
matcher_type: matcher_type.to_string(),
|
|
matcher_value,
|
|
total_comments: 0,
|
|
pending_comments: 0,
|
|
first_seen_at: None,
|
|
latest_seen_at: None,
|
|
distinct_posts: 0,
|
|
analysis,
|
|
samples: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let pending_comments = build_query()
|
|
.filter(comments::Column::Approved.eq(false))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
|
|
let first_item = build_query()
|
|
.order_by_asc(comments::Column::CreatedAt)
|
|
.one(&ctx.db)
|
|
.await?;
|
|
let latest_item = build_query()
|
|
.order_by_desc(comments::Column::CreatedAt)
|
|
.one(&ctx.db)
|
|
.await?;
|
|
|
|
let distinct_posts = build_query()
|
|
.select_only()
|
|
.column(comments::Column::PostSlug)
|
|
.distinct()
|
|
.into_tuple::<Option<String>>()
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.filter_map(|item| item.map(|value| value.trim().to_string()))
|
|
.filter(|value| !value.is_empty())
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.len();
|
|
|
|
let sample_rows = build_query()
|
|
.order_by_desc(comments::Column::CreatedAt)
|
|
.limit(limit)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
let samples = sample_rows
|
|
.iter()
|
|
.map(|item| AdminCommentAnalyzeSample {
|
|
id: item.id,
|
|
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
post_slug: required_text(item.post_slug.as_deref(), "unknown-post"),
|
|
author: required_text(item.author.as_deref(), "匿名"),
|
|
email: required_text(item.email.as_deref(), ""),
|
|
approved: item.approved.unwrap_or(false),
|
|
content_preview: truncate_chars(item.content.as_deref().unwrap_or_default(), 220),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let sample_text = samples
|
|
.iter()
|
|
.map(|item| {
|
|
format!(
|
|
"- [{}] {} | post={} | author={} | status={} | content={}",
|
|
item.id,
|
|
item.created_at,
|
|
item.post_slug,
|
|
item.author,
|
|
if item.approved { "approved" } else { "pending" },
|
|
item.content_preview
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let analysis = ai::admin_chat_completion(
|
|
&ctx,
|
|
"你是博客评论风控分析助手。请输出中文,先结论后细节,不要编造。",
|
|
&format!(
|
|
"请基于以下评论画像数据,输出:\n\
|
|
1) 风险等级(低/中/高)和理由;\n\
|
|
2) 行为特征总结;\n\
|
|
3) 建议动作(通过/观察/限速/临时封禁/永久封禁)及理由;\n\
|
|
4) 误伤风险提示。\n\n\
|
|
画像维度: type={matcher_type}, value={matcher_value}\n\
|
|
评论总数: {total_comments}\n\
|
|
待审核数: {pending_comments}\n\
|
|
涉及文章数: {distinct_posts}\n\
|
|
时间范围: from={} to={}\n\
|
|
样本:\n{}",
|
|
payload.from.as_deref().unwrap_or("-"),
|
|
payload.to.as_deref().unwrap_or("-"),
|
|
sample_text
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
save_comment_persona_analysis_log(
|
|
&ctx,
|
|
matcher_type,
|
|
&matcher_value,
|
|
from,
|
|
to,
|
|
total_comments,
|
|
pending_comments,
|
|
distinct_posts,
|
|
&analysis,
|
|
&samples,
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminCommentAnalyzeResponse {
|
|
matcher_type: matcher_type.to_string(),
|
|
matcher_value,
|
|
total_comments,
|
|
pending_comments,
|
|
first_seen_at: first_item
|
|
.map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()),
|
|
latest_seen_at: latest_item
|
|
.map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()),
|
|
distinct_posts,
|
|
analysis,
|
|
samples,
|
|
})
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn generate_post_metadata(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminPostMetadataRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
format::json(ai::generate_post_metadata(&ctx, &payload.markdown).await?)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn polish_post_markdown(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminPostPolishRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn polish_review_description(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminReviewPolishRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
format::json(
|
|
ai::polish_review_description(
|
|
&ctx,
|
|
&payload.title,
|
|
&payload.review_type,
|
|
payload.rating,
|
|
payload.review_date.as_deref(),
|
|
&payload.status,
|
|
&payload.tags,
|
|
&payload.description,
|
|
)
|
|
.await?,
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn generate_post_cover_image(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
Json(payload): Json<AdminPostCoverImageRequest>,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
format::json(
|
|
ai::generate_post_cover_image(
|
|
&ctx,
|
|
&payload.title,
|
|
payload.description.as_deref(),
|
|
payload.category.as_deref(),
|
|
&payload.tags,
|
|
&payload.post_type,
|
|
payload.slug.as_deref(),
|
|
&payload.markdown,
|
|
)
|
|
.await?,
|
|
)
|
|
}
|
|
|
|
fn review_cover_extension(
|
|
file_name: Option<&str>,
|
|
content_type: Option<&str>,
|
|
) -> Option<&'static str> {
|
|
let from_file_name = file_name
|
|
.and_then(|name| name.rsplit('.').next())
|
|
.map(|ext| ext.trim().to_ascii_lowercase());
|
|
|
|
match from_file_name.as_deref() {
|
|
Some("png") => return Some("png"),
|
|
Some("jpg") | Some("jpeg") => return Some("jpg"),
|
|
Some("webp") => return Some("webp"),
|
|
Some("gif") => return Some("gif"),
|
|
Some("avif") => return Some("avif"),
|
|
Some("svg") => return Some("svg"),
|
|
_ => {}
|
|
}
|
|
|
|
match content_type
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_ascii_lowercase()
|
|
.as_str()
|
|
{
|
|
"image/png" => Some("png"),
|
|
"image/jpeg" => Some("jpg"),
|
|
"image/webp" => Some("webp"),
|
|
"image/gif" => Some("gif"),
|
|
"image/avif" => Some("avif"),
|
|
"image/svg+xml" => Some("svg"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[debug_handler]
|
|
pub async fn upload_review_cover_image(
|
|
headers: HeaderMap,
|
|
State(ctx): State<AppContext>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Response> {
|
|
check_auth(&headers)?;
|
|
|
|
let field = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
|
.ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
|
|
let file_name = field.file_name().map(ToString::to_string);
|
|
let content_type = field.content_type().map(ToString::to_string);
|
|
let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
|
|
.ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
|
|
let bytes = field
|
|
.bytes()
|
|
.await
|
|
.map_err(|error| Error::BadRequest(error.to_string()))?;
|
|
|
|
if bytes.is_empty() {
|
|
return Err(Error::BadRequest("上传的图片内容为空".to_string()));
|
|
}
|
|
|
|
let key = crate::services::storage::build_object_key(
|
|
"review-covers",
|
|
file_name.as_deref().unwrap_or("review-cover"),
|
|
extension,
|
|
);
|
|
let stored = crate::services::storage::upload_bytes_to_r2(
|
|
&ctx,
|
|
&key,
|
|
bytes.to_vec(),
|
|
content_type.as_deref(),
|
|
Some("public, max-age=31536000, immutable"),
|
|
)
|
|
.await?;
|
|
|
|
format::json(AdminImageUploadResponse {
|
|
url: stored.url,
|
|
key: stored.key,
|
|
})
|
|
}
|
|
|
|
pub fn routes() -> Routes {
|
|
Routes::new()
|
|
.prefix("/api/admin")
|
|
.add("/session", get(session_status))
|
|
.add("/session", delete(session_logout))
|
|
.add("/session/login", post(session_login))
|
|
.add("/dashboard", get(dashboard))
|
|
.add("/analytics", get(analytics_overview))
|
|
.add("/site-settings", get(get_site_settings))
|
|
.add("/site-settings", patch(update_site_settings))
|
|
.add("/site-settings", put(update_site_settings))
|
|
.add("/ai/reindex", post(reindex_ai))
|
|
.add("/ai/test-provider", post(test_ai_provider))
|
|
.add("/ai/test-image-provider", post(test_ai_image_provider))
|
|
.add("/storage/r2/test", post(test_r2_storage))
|
|
.add(
|
|
"/storage/media",
|
|
get(list_media_objects)
|
|
.post(upload_media_objects)
|
|
.delete(delete_media_object),
|
|
)
|
|
.add(
|
|
"/storage/media/batch-delete",
|
|
post(batch_delete_media_objects),
|
|
)
|
|
.add("/storage/media/metadata", patch(update_media_object_metadata))
|
|
.add("/storage/media/replace", post(replace_media_object))
|
|
.add(
|
|
"/comments/blacklist",
|
|
get(list_comment_blacklist).post(create_comment_blacklist),
|
|
)
|
|
.add(
|
|
"/comments/blacklist/{id}",
|
|
patch(update_comment_blacklist).delete(delete_comment_blacklist),
|
|
)
|
|
.add(
|
|
"/comments/analyze/logs",
|
|
get(list_comment_persona_analysis_logs),
|
|
)
|
|
.add("/comments/analyze", post(analyze_comment_persona))
|
|
.add("/ai/post-metadata", post(generate_post_metadata))
|
|
.add("/ai/polish-post", post(polish_post_markdown))
|
|
.add("/ai/polish-review", post(polish_review_description))
|
|
.add("/ai/post-cover", post(generate_post_cover_image))
|
|
.add("/storage/review-cover", post(upload_review_cover_image))
|
|
}
|