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

@@ -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;