Files
termi-blog/backend/src/controllers/site_settings.rs

763 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
use crate::{
controllers::admin::check_auth,
models::_entities::{
categories, friend_links, posts, site_settings::{self, ActiveModel, Entity, Model}, tags,
},
services::{ai, content},
};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct MusicTrackPayload {
pub title: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub album: Option<String>,
pub url: String,
#[serde(default, alias = "coverImageUrl")]
pub cover_image_url: Option<String>,
#[serde(default, alias = "accentColor")]
pub accent_color: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AiProviderConfig {
#[serde(default)]
pub id: String,
#[serde(default, alias = "label")]
pub name: String,
#[serde(default)]
pub provider: String,
#[serde(default, alias = "apiBase")]
pub api_base: Option<String>,
#[serde(default, alias = "apiKey")]
pub api_key: Option<String>,
#[serde(default, alias = "chatModel")]
pub chat_model: Option<String>,
#[serde(default, alias = "imageModel")]
pub image_model: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct SiteSettingsPayload {
#[serde(default, alias = "siteName")]
pub site_name: Option<String>,
#[serde(default, alias = "siteShortName")]
pub site_short_name: Option<String>,
#[serde(default, alias = "siteUrl")]
pub site_url: Option<String>,
#[serde(default, alias = "siteTitle")]
pub site_title: Option<String>,
#[serde(default, alias = "siteDescription")]
pub site_description: Option<String>,
#[serde(default, alias = "heroTitle")]
pub hero_title: Option<String>,
#[serde(default, alias = "heroSubtitle")]
pub hero_subtitle: Option<String>,
#[serde(default, alias = "ownerName")]
pub owner_name: Option<String>,
#[serde(default, alias = "ownerTitle")]
pub owner_title: Option<String>,
#[serde(default, alias = "ownerBio")]
pub owner_bio: Option<String>,
#[serde(default, alias = "ownerAvatarUrl")]
pub owner_avatar_url: Option<String>,
#[serde(default, alias = "socialGithub")]
pub social_github: Option<String>,
#[serde(default, alias = "socialTwitter")]
pub social_twitter: Option<String>,
#[serde(default, alias = "socialEmail")]
pub social_email: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default, alias = "techStack")]
pub tech_stack: Option<Vec<String>>,
#[serde(default, alias = "musicPlaylist")]
pub music_playlist: Option<Vec<MusicTrackPayload>>,
#[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
pub paragraph_comments_enabled: Option<bool>,
#[serde(default, alias = "aiProvider")]
pub ai_provider: Option<String>,
#[serde(default, alias = "aiApiBase")]
pub ai_api_base: Option<String>,
#[serde(default, alias = "aiApiKey")]
pub ai_api_key: Option<String>,
#[serde(default, alias = "aiChatModel")]
pub ai_chat_model: Option<String>,
#[serde(default, alias = "aiImageProvider")]
pub ai_image_provider: Option<String>,
#[serde(default, alias = "aiImageApiBase")]
pub ai_image_api_base: Option<String>,
#[serde(default, alias = "aiImageApiKey")]
pub ai_image_api_key: Option<String>,
#[serde(default, alias = "aiImageModel")]
pub ai_image_model: Option<String>,
#[serde(default, alias = "aiProviders")]
pub ai_providers: Option<Vec<AiProviderConfig>>,
#[serde(default, alias = "aiActiveProviderId")]
pub ai_active_provider_id: Option<String>,
#[serde(default, alias = "aiEmbeddingModel")]
pub ai_embedding_model: Option<String>,
#[serde(default, alias = "aiSystemPrompt")]
pub ai_system_prompt: Option<String>,
#[serde(default, alias = "aiTopK")]
pub ai_top_k: Option<i32>,
#[serde(default, alias = "aiChunkSize")]
pub ai_chunk_size: Option<i32>,
#[serde(default, alias = "mediaR2AccountId")]
pub media_r2_account_id: Option<String>,
#[serde(default, alias = "mediaStorageProvider")]
pub media_storage_provider: Option<String>,
#[serde(default, alias = "mediaR2Bucket")]
pub media_r2_bucket: Option<String>,
#[serde(default, alias = "mediaR2PublicBaseUrl")]
pub media_r2_public_base_url: Option<String>,
#[serde(default, alias = "mediaR2AccessKeyId")]
pub media_r2_access_key_id: Option<String>,
#[serde(default, alias = "mediaR2SecretAccessKey")]
pub media_r2_secret_access_key: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublicSiteSettingsResponse {
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: Option<serde_json::Value>,
pub music_playlist: Option<serde_json::Value>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct HomeCategorySummary {
pub id: i32,
pub name: String,
pub slug: String,
pub count: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct HomePageResponse {
pub site_settings: PublicSiteSettingsResponse,
pub posts: Vec<posts::Model>,
pub tags: Vec<tags::Model>,
pub friend_links: Vec<friend_links::Model>,
pub categories: Vec<HomeCategorySummary>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32> {
value.map(|item| item.clamp(min, max))
}
fn create_ai_provider_id() -> String {
format!("provider-{}", Uuid::new_v4().simple())
}
fn default_ai_provider_config() -> AiProviderConfig {
let provider = ai::provider_name(None);
AiProviderConfig {
id: "default".to_string(),
name: "默认提供商".to_string(),
provider: provider.clone(),
api_base: Some(ai::default_api_base().to_string()),
api_key: Some(ai::default_api_key().to_string()),
chat_model: Some(ai::default_chat_model().to_string()),
image_model: Some(ai::default_image_model_for_provider(&provider).to_string()),
}
}
fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProviderConfig> {
let mut seen_ids = HashSet::new();
items
.into_iter()
.enumerate()
.filter_map(|(index, item)| {
let provider = normalize_optional_string(Some(item.provider))
.unwrap_or_else(|| ai::provider_name(None));
let api_base = normalize_optional_string(item.api_base);
let api_key = normalize_optional_string(item.api_key);
let chat_model = normalize_optional_string(item.chat_model);
let image_model = normalize_optional_string(item.image_model);
let has_content = !item.name.trim().is_empty()
|| !provider.trim().is_empty()
|| api_base.is_some()
|| api_key.is_some()
|| chat_model.is_some()
|| image_model.is_some();
if !has_content {
return None;
}
let mut id =
normalize_optional_string(Some(item.id)).unwrap_or_else(create_ai_provider_id);
if !seen_ids.insert(id.clone()) {
id = create_ai_provider_id();
seen_ids.insert(id.clone());
}
let name = normalize_optional_string(Some(item.name))
.unwrap_or_else(|| format!("提供商 {}", index + 1));
Some(AiProviderConfig {
id,
name,
provider,
api_base,
api_key,
chat_model,
image_model,
})
})
.collect()
}
fn legacy_ai_provider_config(model: &Model) -> Option<AiProviderConfig> {
let provider = normalize_optional_string(model.ai_provider.clone());
let api_base = normalize_optional_string(model.ai_api_base.clone());
let api_key = normalize_optional_string(model.ai_api_key.clone());
let chat_model = normalize_optional_string(model.ai_chat_model.clone());
if provider.is_none() && api_base.is_none() && api_key.is_none() && chat_model.is_none() {
return None;
}
let normalized_provider = provider.unwrap_or_else(|| ai::provider_name(None));
Some(AiProviderConfig {
id: "default".to_string(),
name: "当前提供商".to_string(),
provider: normalized_provider.clone(),
api_base,
api_key,
chat_model,
image_model: Some(ai::default_image_model_for_provider(&normalized_provider).to_string()),
})
}
pub(crate) fn ai_provider_configs(model: &Model) -> Vec<AiProviderConfig> {
let parsed = model
.ai_providers
.as_ref()
.and_then(|value| serde_json::from_value::<Vec<AiProviderConfig>>(value.clone()).ok())
.map(normalize_ai_provider_configs)
.unwrap_or_default();
if !parsed.is_empty() {
parsed
} else {
legacy_ai_provider_config(model).into_iter().collect()
}
}
pub(crate) fn active_ai_provider_id(model: &Model) -> Option<String> {
let configs = ai_provider_configs(model);
let requested = normalize_optional_string(model.ai_active_provider_id.clone());
if let Some(active_id) = requested.filter(|id| configs.iter().any(|item| item.id == *id)) {
Some(active_id)
} else {
configs.first().map(|item| item.id.clone())
}
}
fn write_ai_provider_state(
model: &mut Model,
configs: Vec<AiProviderConfig>,
requested_active_id: Option<String>,
) {
let normalized = normalize_ai_provider_configs(configs);
let active_id = requested_active_id
.filter(|id| normalized.iter().any(|item| item.id == *id))
.or_else(|| normalized.first().map(|item| item.id.clone()));
model.ai_providers = (!normalized.is_empty()).then(|| serde_json::json!(normalized.clone()));
model.ai_active_provider_id = active_id.clone();
if let Some(active) = active_id.and_then(|id| normalized.into_iter().find(|item| item.id == id))
{
model.ai_provider = Some(active.provider);
model.ai_api_base = active.api_base;
model.ai_api_key = active.api_key;
model.ai_chat_model = active.chat_model;
} else {
model.ai_provider = None;
model.ai_api_base = None;
model.ai_api_key = None;
model.ai_chat_model = None;
}
}
fn sync_ai_provider_fields(model: &mut Model) {
write_ai_provider_state(
model,
ai_provider_configs(model),
active_ai_provider_id(model),
);
}
fn update_active_provider_from_legacy_fields(model: &mut Model) {
let provider = model.ai_provider.clone();
let api_base = model.ai_api_base.clone();
let api_key = model.ai_api_key.clone();
let chat_model = model.ai_chat_model.clone();
let mut configs = ai_provider_configs(model);
let active_id = active_ai_provider_id(model);
if configs.is_empty() {
let mut config = default_ai_provider_config();
config.provider = provider.unwrap_or_else(|| ai::provider_name(None));
config.api_base = api_base;
config.api_key = api_key;
config.chat_model = chat_model;
config.image_model =
Some(ai::default_image_model_for_provider(&config.provider).to_string());
write_ai_provider_state(
model,
vec![config],
Some(active_id.unwrap_or_else(|| "default".to_string())),
);
return;
}
let target_id = active_id
.clone()
.or_else(|| configs.first().map(|item| item.id.clone()));
if let Some(target_id) = target_id {
for config in &mut configs {
if config.id == target_id {
if let Some(next_provider) = provider.clone() {
config.provider = next_provider;
}
config.api_base = api_base.clone();
config.api_key = api_key.clone();
config.chat_model = chat_model.clone();
if config.image_model.is_none() {
config.image_model =
Some(ai::default_image_model_for_provider(&config.provider).to_string());
}
}
}
}
write_ai_provider_state(model, configs, active_id);
}
fn normalize_music_playlist(items: Vec<MusicTrackPayload>) -> Vec<MusicTrackPayload> {
items
.into_iter()
.map(|item| MusicTrackPayload {
title: item.title.trim().to_string(),
artist: normalize_optional_string(item.artist),
album: normalize_optional_string(item.album),
url: item.url.trim().to_string(),
cover_image_url: normalize_optional_string(item.cover_image_url),
accent_color: normalize_optional_string(item.accent_color),
description: normalize_optional_string(item.description),
})
.filter(|item| !item.title.is_empty() && !item.url.is_empty())
.collect()
}
impl SiteSettingsPayload {
pub(crate) fn apply(self, item: &mut Model) {
if let Some(site_name) = self.site_name {
item.site_name = normalize_optional_string(Some(site_name));
}
if let Some(site_short_name) = self.site_short_name {
item.site_short_name = normalize_optional_string(Some(site_short_name));
}
if let Some(site_url) = self.site_url {
item.site_url = normalize_optional_string(Some(site_url));
}
if let Some(site_title) = self.site_title {
item.site_title = normalize_optional_string(Some(site_title));
}
if let Some(site_description) = self.site_description {
item.site_description = normalize_optional_string(Some(site_description));
}
if let Some(hero_title) = self.hero_title {
item.hero_title = normalize_optional_string(Some(hero_title));
}
if let Some(hero_subtitle) = self.hero_subtitle {
item.hero_subtitle = normalize_optional_string(Some(hero_subtitle));
}
if let Some(owner_name) = self.owner_name {
item.owner_name = normalize_optional_string(Some(owner_name));
}
if let Some(owner_title) = self.owner_title {
item.owner_title = normalize_optional_string(Some(owner_title));
}
if let Some(owner_bio) = self.owner_bio {
item.owner_bio = normalize_optional_string(Some(owner_bio));
}
if let Some(owner_avatar_url) = self.owner_avatar_url {
item.owner_avatar_url = normalize_optional_string(Some(owner_avatar_url));
}
if let Some(social_github) = self.social_github {
item.social_github = normalize_optional_string(Some(social_github));
}
if let Some(social_twitter) = self.social_twitter {
item.social_twitter = normalize_optional_string(Some(social_twitter));
}
if let Some(social_email) = self.social_email {
item.social_email = normalize_optional_string(Some(social_email));
}
if let Some(location) = self.location {
item.location = normalize_optional_string(Some(location));
}
if let Some(tech_stack) = self.tech_stack {
item.tech_stack = Some(serde_json::json!(tech_stack));
}
if let Some(music_playlist) = self.music_playlist {
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
}
if let Some(ai_enabled) = self.ai_enabled {
item.ai_enabled = Some(ai_enabled);
}
if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled {
item.paragraph_comments_enabled = Some(paragraph_comments_enabled);
}
let provider_list_supplied = self.ai_providers.is_some();
let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs);
let requested_active_provider_id = self
.ai_active_provider_id
.and_then(|value| normalize_optional_string(Some(value)));
let legacy_provider_fields_updated = self.ai_provider.is_some()
|| self.ai_api_base.is_some()
|| self.ai_api_key.is_some()
|| self.ai_chat_model.is_some();
if let Some(ai_provider) = self.ai_provider {
item.ai_provider = normalize_optional_string(Some(ai_provider));
}
if let Some(ai_api_base) = self.ai_api_base {
item.ai_api_base = normalize_optional_string(Some(ai_api_base));
}
if let Some(ai_api_key) = self.ai_api_key {
item.ai_api_key = normalize_optional_string(Some(ai_api_key));
}
if let Some(ai_chat_model) = self.ai_chat_model {
item.ai_chat_model = normalize_optional_string(Some(ai_chat_model));
}
if let Some(ai_image_provider) = self.ai_image_provider {
item.ai_image_provider = normalize_optional_string(Some(ai_image_provider));
}
if let Some(ai_image_api_base) = self.ai_image_api_base {
item.ai_image_api_base = normalize_optional_string(Some(ai_image_api_base));
}
if let Some(ai_image_api_key) = self.ai_image_api_key {
item.ai_image_api_key = normalize_optional_string(Some(ai_image_api_key));
}
if let Some(ai_image_model) = self.ai_image_model {
item.ai_image_model = normalize_optional_string(Some(ai_image_model));
}
if let Some(ai_embedding_model) = self.ai_embedding_model {
item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model));
}
if let Some(ai_system_prompt) = self.ai_system_prompt {
item.ai_system_prompt = normalize_optional_string(Some(ai_system_prompt));
}
if self.ai_top_k.is_some() {
item.ai_top_k = normalize_optional_int(self.ai_top_k, 1, 12);
}
if self.ai_chunk_size.is_some() {
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
}
if let Some(media_r2_account_id) = self.media_r2_account_id {
item.media_r2_account_id = normalize_optional_string(Some(media_r2_account_id));
}
if let Some(media_storage_provider) = self.media_storage_provider {
item.media_storage_provider = normalize_optional_string(Some(media_storage_provider));
}
if let Some(media_r2_bucket) = self.media_r2_bucket {
item.media_r2_bucket = normalize_optional_string(Some(media_r2_bucket));
}
if let Some(media_r2_public_base_url) = self.media_r2_public_base_url {
item.media_r2_public_base_url =
normalize_optional_string(Some(media_r2_public_base_url));
}
if let Some(media_r2_access_key_id) = self.media_r2_access_key_id {
item.media_r2_access_key_id = normalize_optional_string(Some(media_r2_access_key_id));
}
if let Some(media_r2_secret_access_key) = self.media_r2_secret_access_key {
item.media_r2_secret_access_key =
normalize_optional_string(Some(media_r2_secret_access_key));
}
if provider_list_supplied {
write_ai_provider_state(
item,
provided_ai_providers.unwrap_or_default(),
requested_active_provider_id.or_else(|| item.ai_active_provider_id.clone()),
);
} else if legacy_provider_fields_updated {
update_active_provider_from_legacy_fields(item);
} else {
sync_ai_provider_fields(item);
}
}
}
fn default_payload() -> SiteSettingsPayload {
SiteSettingsPayload {
site_name: Some("InitCool".to_string()),
site_short_name: Some("Termi".to_string()),
site_url: Some("https://init.cool".to_string()),
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()),
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
owner_name: Some("InitCool".to_string()),
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
owner_bio: Some(
"InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
.to_string(),
),
owner_avatar_url: Some("https://github.com/limitcool.png".to_string()),
social_github: Some("https://github.com/limitcool".to_string()),
social_twitter: None,
social_email: Some("mailto:initcoool@gmail.com".to_string()),
location: Some("Hong Kong".to_string()),
tech_stack: Some(vec![
"Rust".to_string(),
"Go".to_string(),
"Python".to_string(),
"Svelte".to_string(),
"Astro".to_string(),
"Loco.rs".to_string(),
]),
music_playlist: Some(vec![
MusicTrackPayload {
title: "山中来信".to_string(),
artist: Some("InitCool Radio".to_string()),
album: Some("站点默认歌单".to_string()),
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3".to_string(),
cover_image_url: Some(
"https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80"
.to_string(),
),
accent_color: Some("#2f6b5f".to_string()),
description: Some("适合文章阅读时循环播放的轻氛围曲。".to_string()),
},
MusicTrackPayload {
title: "风吹松声".to_string(),
artist: Some("InitCool Radio".to_string()),
album: Some("站点默认歌单".to_string()),
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3".to_string(),
cover_image_url: Some(
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80"
.to_string(),
),
accent_color: Some("#8a5b35".to_string()),
description: Some("偏木质感的器乐氛围,适合深夜浏览。".to_string()),
},
MusicTrackPayload {
title: "夜航小记".to_string(),
artist: Some("InitCool Radio".to_string()),
album: Some("站点默认歌单".to_string()),
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3".to_string(),
cover_image_url: Some(
"https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
.to_string(),
),
accent_color: Some("#375a7f".to_string()),
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
},
]),
ai_enabled: Some(false),
paragraph_comments_enabled: Some(true),
ai_provider: Some(ai::provider_name(None)),
ai_api_base: Some(ai::default_api_base().to_string()),
ai_api_key: Some(ai::default_api_key().to_string()),
ai_chat_model: Some(ai::default_chat_model().to_string()),
ai_image_provider: None,
ai_image_api_base: None,
ai_image_api_key: None,
ai_image_model: None,
ai_providers: Some(vec![default_ai_provider_config()]),
ai_active_provider_id: Some("default".to_string()),
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
ai_system_prompt: Some(
"你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
.to_string(),
),
ai_top_k: Some(4),
ai_chunk_size: Some(1200),
media_storage_provider: None,
media_r2_account_id: None,
media_r2_bucket: None,
media_r2_public_base_url: None,
media_r2_access_key_id: None,
media_r2_secret_access_key: None,
}
}
pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
if let Some(settings) = Entity::find()
.order_by_asc(site_settings::Column::Id)
.one(&ctx.db)
.await?
{
return Ok(settings);
}
let inserted = ActiveModel {
id: Set(1),
..Default::default()
}
.insert(&ctx.db)
.await?;
let mut model = inserted;
default_payload().apply(&mut model);
Ok(model
.into_active_model()
.reset_all()
.update(&ctx.db)
.await?)
}
fn public_response(model: Model) -> PublicSiteSettingsResponse {
PublicSiteSettingsResponse {
id: model.id,
site_name: model.site_name,
site_short_name: model.site_short_name,
site_url: model.site_url,
site_title: model.site_title,
site_description: model.site_description,
hero_title: model.hero_title,
hero_subtitle: model.hero_subtitle,
owner_name: model.owner_name,
owner_title: model.owner_title,
owner_bio: model.owner_bio,
owner_avatar_url: model.owner_avatar_url,
social_github: model.social_github,
social_twitter: model.social_twitter,
social_email: model.social_email,
location: model.location,
tech_stack: model.tech_stack,
music_playlist: model.music_playlist,
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
}
}
#[debug_handler]
pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
content::sync_markdown_posts(&ctx).await?;
let site_settings = public_response(load_current(&ctx).await?);
let posts = posts::Entity::find()
.order_by_desc(posts::Column::CreatedAt)
.all(&ctx.db)
.await?;
let tags = tags::Entity::find().all(&ctx.db).await?;
let friend_links = friend_links::Entity::find()
.order_by_desc(friend_links::Column::CreatedAt)
.all(&ctx.db)
.await?;
let category_items = categories::Entity::find()
.order_by_asc(categories::Column::Slug)
.all(&ctx.db)
.await?;
let categories = category_items
.into_iter()
.map(|category| {
let name = category
.name
.clone()
.unwrap_or_else(|| category.slug.clone());
let count = posts
.iter()
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
.count();
HomeCategorySummary {
id: category.id,
name,
slug: category.slug,
count,
}
})
.collect::<Vec<_>>();
format::json(HomePageResponse {
site_settings,
posts,
tags,
friend_links,
categories,
})
}
#[debug_handler]
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?))
}
#[debug_handler]
pub async fn update(
State(ctx): State<AppContext>,
Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
check_auth()?;
let current = 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?;
format::json(public_response(updated))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/site_settings/")
.add("home", get(home))
.add("/", get(show))
.add("/", put(update))
.add("/", patch(update))
}