#![allow(clippy::missing_errors_doc)] #![allow(clippy::unnecessary_struct_initialization)] #![allow(clippy::unused_async)] use axum::http::HeaderMap; use loco_rs::prelude::*; use sha2::{Digest, Sha256}; 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, #[serde(default)] pub album: Option, pub url: String, #[serde(default, alias = "coverImageUrl")] pub cover_image_url: Option, #[serde(default, alias = "accentColor")] pub accent_color: Option, #[serde(default)] pub description: Option, } #[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, #[serde(default, alias = "apiKey")] pub api_key: Option, #[serde(default, alias = "chatModel")] pub chat_model: Option, #[serde(default, alias = "imageModel")] pub image_model: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SiteSettingsPayload { #[serde(default, alias = "siteName")] pub site_name: Option, #[serde(default, alias = "siteShortName")] pub site_short_name: Option, #[serde(default, alias = "siteUrl")] pub site_url: Option, #[serde(default, alias = "siteTitle")] pub site_title: Option, #[serde(default, alias = "siteDescription")] pub site_description: Option, #[serde(default, alias = "heroTitle")] pub hero_title: Option, #[serde(default, alias = "heroSubtitle")] pub hero_subtitle: Option, #[serde(default, alias = "ownerName")] pub owner_name: Option, #[serde(default, alias = "ownerTitle")] pub owner_title: Option, #[serde(default, alias = "ownerBio")] pub owner_bio: Option, #[serde(default, alias = "ownerAvatarUrl")] pub owner_avatar_url: Option, #[serde(default, alias = "socialGithub")] pub social_github: Option, #[serde(default, alias = "socialTwitter")] pub social_twitter: Option, #[serde(default, alias = "socialEmail")] pub social_email: Option, #[serde(default)] pub location: Option, #[serde(default, alias = "techStack")] pub tech_stack: Option>, #[serde(default, alias = "musicPlaylist")] pub music_playlist: Option>, #[serde(default, alias = "musicEnabled")] pub music_enabled: Option, #[serde(default, alias = "maintenanceModeEnabled")] pub maintenance_mode_enabled: Option, #[serde(default, alias = "maintenanceAccessCode")] pub maintenance_access_code: Option, #[serde(default, alias = "aiEnabled")] pub ai_enabled: Option, #[serde(default, alias = "paragraphCommentsEnabled")] pub paragraph_comments_enabled: Option, #[serde(default, alias = "commentVerificationMode")] pub comment_verification_mode: Option, #[serde(default, alias = "commentTurnstileEnabled")] pub comment_turnstile_enabled: Option, #[serde(default, alias = "subscriptionVerificationMode")] pub subscription_verification_mode: Option, #[serde(default, alias = "subscriptionTurnstileEnabled")] pub subscription_turnstile_enabled: Option, #[serde(default, alias = "webPushEnabled")] pub web_push_enabled: Option, #[serde(default, alias = "turnstileSiteKey")] pub turnstile_site_key: Option, #[serde(default, alias = "turnstileSecretKey")] pub turnstile_secret_key: Option, #[serde(default, alias = "webPushVapidPublicKey")] pub web_push_vapid_public_key: Option, #[serde(default, alias = "webPushVapidPrivateKey")] pub web_push_vapid_private_key: Option, #[serde(default, alias = "webPushVapidSubject")] pub web_push_vapid_subject: Option, #[serde(default, alias = "aiProvider")] pub ai_provider: Option, #[serde(default, alias = "aiApiBase")] pub ai_api_base: Option, #[serde(default, alias = "aiApiKey")] pub ai_api_key: Option, #[serde(default, alias = "aiChatModel")] pub ai_chat_model: Option, #[serde(default, alias = "aiImageProvider")] pub ai_image_provider: Option, #[serde(default, alias = "aiImageApiBase")] pub ai_image_api_base: Option, #[serde(default, alias = "aiImageApiKey")] pub ai_image_api_key: Option, #[serde(default, alias = "aiImageModel")] pub ai_image_model: Option, #[serde(default, alias = "aiProviders")] pub ai_providers: Option>, #[serde(default, alias = "aiActiveProviderId")] pub ai_active_provider_id: Option, #[serde(default, alias = "aiEmbeddingModel")] pub ai_embedding_model: Option, #[serde(default, alias = "aiSystemPrompt")] pub ai_system_prompt: Option, #[serde(default, alias = "aiTopK")] pub ai_top_k: Option, #[serde(default, alias = "aiChunkSize")] pub ai_chunk_size: Option, #[serde(default, alias = "mediaR2AccountId")] pub media_r2_account_id: Option, #[serde(default, alias = "mediaStorageProvider")] pub media_storage_provider: Option, #[serde(default, alias = "mediaR2Bucket")] pub media_r2_bucket: Option, #[serde(default, alias = "mediaR2PublicBaseUrl")] pub media_r2_public_base_url: Option, #[serde(default, alias = "mediaR2AccessKeyId")] pub media_r2_access_key_id: Option, #[serde(default, alias = "mediaR2SecretAccessKey")] pub media_r2_secret_access_key: Option, #[serde(default, alias = "seoDefaultOgImage")] pub seo_default_og_image: Option, #[serde(default, alias = "seoDefaultTwitterHandle")] pub seo_default_twitter_handle: Option, #[serde(default, alias = "seoWechatShareQrEnabled")] pub seo_wechat_share_qr_enabled: Option, #[serde(default, alias = "notificationWebhookUrl")] pub notification_webhook_url: Option, #[serde(default, alias = "notificationChannelType")] pub notification_channel_type: Option, #[serde(default, alias = "notificationCommentEnabled")] pub notification_comment_enabled: Option, #[serde(default, alias = "notificationFriendLinkEnabled")] pub notification_friend_link_enabled: Option, #[serde(default, alias = "subscriptionPopupEnabled")] pub subscription_popup_enabled: Option, #[serde(default, alias = "subscriptionPopupTitle")] pub subscription_popup_title: Option, #[serde(default, alias = "subscriptionPopupDescription")] pub subscription_popup_description: Option, #[serde(default, alias = "subscriptionPopupDelaySeconds")] pub subscription_popup_delay_seconds: Option, #[serde(default, alias = "searchSynonyms")] pub search_synonyms: Option>, } #[derive(Clone, Debug, Serialize)] pub struct PublicSiteSettingsResponse { pub id: i32, pub site_name: Option, pub site_short_name: Option, pub site_url: Option, pub site_title: Option, pub site_description: Option, pub hero_title: Option, pub hero_subtitle: Option, pub owner_name: Option, pub owner_title: Option, pub owner_bio: Option, pub owner_avatar_url: Option, pub social_github: Option, pub social_twitter: Option, pub social_email: Option, pub location: Option, pub tech_stack: Option, pub music_playlist: Option, pub music_enabled: bool, pub ai_enabled: bool, pub paragraph_comments_enabled: bool, pub comment_verification_mode: String, pub comment_turnstile_enabled: bool, pub subscription_verification_mode: String, pub subscription_turnstile_enabled: bool, pub web_push_enabled: bool, pub turnstile_site_key: Option, pub web_push_vapid_public_key: Option, pub subscription_popup_enabled: bool, pub subscription_popup_title: String, pub subscription_popup_description: String, pub subscription_popup_delay_seconds: i32, pub seo_default_og_image: Option, pub seo_default_twitter_handle: Option, pub seo_wechat_share_qr_enabled: bool, } #[derive(Clone, Debug, Default, Deserialize)] pub struct MaintenanceAccessTokenPayload { #[serde(default, alias = "accessToken")] pub access_token: Option, } #[derive(Clone, Debug, Default, Deserialize)] pub struct MaintenanceVerifyPayload { #[serde(default)] pub code: Option, } #[derive(Clone, Debug, Serialize)] pub struct MaintenanceAccessStatusResponse { pub maintenance_mode_enabled: bool, pub access_granted: bool, } #[derive(Clone, Debug, Serialize)] pub struct MaintenanceVerifyResponse { pub maintenance_mode_enabled: bool, pub access_granted: bool, pub access_token: Option, } #[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, pub tags: Vec, pub friend_links: Vec, pub categories: Vec, pub content_overview: crate::services::analytics::ContentAnalyticsOverview, pub popular_posts: Vec, pub content_ranges: Vec, } fn normalize_optional_string(value: Option) -> Option { value.and_then(|item| { let trimmed = item.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } }) } fn normalize_optional_int(value: Option, min: i32, max: i32) -> Option { value.map(|item| item.clamp(min, max)) } fn maintenance_mode_enabled(model: &Model) -> bool { model.maintenance_mode_enabled.unwrap_or(false) } fn maintenance_access_code(model: &Model) -> Option { normalize_optional_string(model.maintenance_access_code.clone()) } fn maintenance_access_token_from_secret(secret: &str) -> String { let mut hasher = Sha256::new(); hasher.update(b"termi-maintenance-access:v1:"); hasher.update(secret.as_bytes()); let digest = hasher.finalize(); digest .iter() .map(|byte| format!("{byte:02x}")) .collect::() } fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool { let Some(candidate) = token.and_then(|item| { let trimmed = item.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) }) else { return false; }; let Some(secret) = maintenance_access_code(model) else { return false; }; candidate == maintenance_access_token_from_secret(&secret) } fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option { let candidate = code.and_then(|item| { let trimmed = item.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) })?; let secret = maintenance_access_code(model)?; (candidate == secret).then(|| maintenance_access_token_from_secret(&secret)) } fn normalize_notification_channel_type(value: Option) -> Option { value.and_then(|item| { let normalized = item.trim().to_ascii_lowercase(); match normalized.as_str() { "ntfy" => Some("ntfy".to_string()), "webhook" => Some("webhook".to_string()), _ => None, } }) } pub(crate) fn default_subscription_popup_enabled() -> bool { true } pub(crate) fn default_subscription_popup_title() -> String { "订阅更新".to_string() } pub(crate) fn default_subscription_popup_description() -> String { "有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份。".to_string() } pub(crate) fn default_subscription_popup_delay_seconds() -> i32 { 18 } const DEFAULT_TURNSTILE_SITE_KEY: &str = "0x4AAAAAACy58kMBSwXwqMhx"; const DEFAULT_TURNSTILE_SECRET_KEY: &str = "0x4AAAAAACy58m3gYfSqM-VIz4QK4wuO73U"; fn normalize_string_list(values: Vec) -> Vec { values .into_iter() .filter_map(|item| normalize_optional_string(Some(item))) .collect() } 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) -> Vec { 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 { 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 { let parsed = model .ai_providers .as_ref() .and_then(|value| serde_json::from_value::>(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 { 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, requested_active_id: Option, ) { 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) -> Vec { 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(music_enabled) = self.music_enabled { item.music_enabled = Some(music_enabled); } if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled { item.maintenance_mode_enabled = Some(maintenance_mode_enabled); } if self.maintenance_access_code.is_some() { item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code); } 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); } if let Some(comment_verification_mode) = self .comment_verification_mode .as_deref() .and_then(|value| crate::services::turnstile::normalize_verification_mode(Some(value))) { item.comment_verification_mode = Some(comment_verification_mode.as_str().to_string()); item.comment_turnstile_enabled = Some(matches!( comment_verification_mode, crate::services::turnstile::VerificationMode::Turnstile )); } else if let Some(comment_turnstile_enabled) = self.comment_turnstile_enabled { item.comment_turnstile_enabled = Some(comment_turnstile_enabled); item.comment_verification_mode = Some( if comment_turnstile_enabled { crate::services::turnstile::VERIFICATION_MODE_TURNSTILE } else { crate::services::turnstile::VERIFICATION_MODE_CAPTCHA } .to_string(), ); } if let Some(subscription_verification_mode) = self .subscription_verification_mode .as_deref() .and_then(|value| crate::services::turnstile::normalize_verification_mode(Some(value))) { item.subscription_verification_mode = Some(subscription_verification_mode.as_str().to_string()); item.subscription_turnstile_enabled = Some(matches!( subscription_verification_mode, crate::services::turnstile::VerificationMode::Turnstile )); } else if let Some(subscription_turnstile_enabled) = self.subscription_turnstile_enabled { item.subscription_turnstile_enabled = Some(subscription_turnstile_enabled); item.subscription_verification_mode = Some( if subscription_turnstile_enabled { crate::services::turnstile::VERIFICATION_MODE_TURNSTILE } else { crate::services::turnstile::VERIFICATION_MODE_OFF } .to_string(), ); } if let Some(web_push_enabled) = self.web_push_enabled { item.web_push_enabled = Some(web_push_enabled); } if let Some(turnstile_site_key) = self.turnstile_site_key { item.turnstile_site_key = normalize_optional_string(Some(turnstile_site_key)); } if let Some(turnstile_secret_key) = self.turnstile_secret_key { item.turnstile_secret_key = normalize_optional_string(Some(turnstile_secret_key)); } if let Some(web_push_vapid_public_key) = self.web_push_vapid_public_key { item.web_push_vapid_public_key = normalize_optional_string(Some(web_push_vapid_public_key)); } if let Some(web_push_vapid_private_key) = self.web_push_vapid_private_key { item.web_push_vapid_private_key = normalize_optional_string(Some(web_push_vapid_private_key)); } if let Some(web_push_vapid_subject) = self.web_push_vapid_subject { item.web_push_vapid_subject = normalize_optional_string(Some(web_push_vapid_subject)); } 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 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(seo_wechat_share_qr_enabled) = self.seo_wechat_share_qr_enabled { item.seo_wechat_share_qr_enabled = Some(seo_wechat_share_qr_enabled); } if let Some(notification_webhook_url) = self.notification_webhook_url { item.notification_webhook_url = normalize_optional_string(Some(notification_webhook_url)); } if self.notification_channel_type.is_some() { item.notification_channel_type = normalize_notification_channel_type(self.notification_channel_type); } 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(subscription_popup_enabled) = self.subscription_popup_enabled { item.subscription_popup_enabled = Some(subscription_popup_enabled); } if let Some(subscription_popup_title) = self.subscription_popup_title { item.subscription_popup_title = normalize_optional_string(Some(subscription_popup_title)); } if let Some(subscription_popup_description) = self.subscription_popup_description { item.subscription_popup_description = normalize_optional_string(Some(subscription_popup_description)); } if self.subscription_popup_delay_seconds.is_some() { item.subscription_popup_delay_seconds = normalize_optional_int(self.subscription_popup_delay_seconds, 3, 120); } 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( 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("欢迎来到 InitCool".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( "InitCool,GitHub 用户名 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()), }, ]), music_enabled: Some(true), maintenance_mode_enabled: Some(false), maintenance_access_code: None, ai_enabled: Some(false), paragraph_comments_enabled: Some(true), comment_verification_mode: Some( crate::services::turnstile::VERIFICATION_MODE_CAPTCHA.to_string(), ), comment_turnstile_enabled: Some(false), subscription_verification_mode: Some( crate::services::turnstile::VERIFICATION_MODE_OFF.to_string(), ), subscription_turnstile_enabled: Some(false), web_push_enabled: Some(false), turnstile_site_key: Some(DEFAULT_TURNSTILE_SITE_KEY.to_string()), turnstile_secret_key: Some(DEFAULT_TURNSTILE_SECRET_KEY.to_string()), web_push_vapid_public_key: None, web_push_vapid_private_key: None, web_push_vapid_subject: None, 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, seo_default_og_image: None, seo_default_twitter_handle: None, seo_wechat_share_qr_enabled: Some(false), notification_webhook_url: None, notification_channel_type: Some("webhook".to_string()), notification_comment_enabled: Some(false), notification_friend_link_enabled: Some(false), subscription_popup_enabled: Some(default_subscription_popup_enabled()), subscription_popup_title: Some(default_subscription_popup_title()), subscription_popup_description: Some(default_subscription_popup_description()), subscription_popup_delay_seconds: Some(default_subscription_popup_delay_seconds()), search_synonyms: Some(Vec::new()), } } pub(crate) async fn load_current(ctx: &AppContext) -> Result { 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 { let turnstile_site_key = crate::services::turnstile::site_key(&model); let web_push_vapid_public_key = crate::services::web_push::public_key(&model); let comment_verification_mode = crate::services::turnstile::effective_mode( &model, crate::services::turnstile::TurnstileScope::Comment, ); let subscription_verification_mode = crate::services::turnstile::effective_mode( &model, crate::services::turnstile::TurnstileScope::Subscription, ); let web_push_enabled = crate::services::web_push::is_enabled(&model); 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, music_enabled: model.music_enabled.unwrap_or(true), ai_enabled: model.ai_enabled.unwrap_or(false), paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true), comment_verification_mode: comment_verification_mode.as_str().to_string(), comment_turnstile_enabled: matches!( comment_verification_mode, crate::services::turnstile::VerificationMode::Turnstile ), subscription_verification_mode: subscription_verification_mode.as_str().to_string(), subscription_turnstile_enabled: matches!( subscription_verification_mode, crate::services::turnstile::VerificationMode::Turnstile ), web_push_enabled, turnstile_site_key, web_push_vapid_public_key, subscription_popup_enabled: model .subscription_popup_enabled .unwrap_or_else(default_subscription_popup_enabled), subscription_popup_title: model .subscription_popup_title .unwrap_or_else(default_subscription_popup_title), subscription_popup_description: model .subscription_popup_description .unwrap_or_else(default_subscription_popup_description), subscription_popup_delay_seconds: model .subscription_popup_delay_seconds .unwrap_or_else(default_subscription_popup_delay_seconds), seo_default_og_image: model.seo_default_og_image, seo_default_twitter_handle: model.seo_default_twitter_handle, seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false), } } #[debug_handler] pub async fn home(State(ctx): State) -> Result { let site_settings = public_response(load_current(&ctx).await?); let posts = posts::Entity::find() .order_by_desc(posts::Column::CreatedAt) .all(&ctx.db) .await? .into_iter() .filter(|post| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset())) .collect::>(); 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?; 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::>(); 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, posts, tags, friend_links, categories, content_overview: content_highlights.overview, popular_posts: content_highlights.popular_posts, content_ranges, }) } #[debug_handler] pub async fn show(State(ctx): State) -> Result { format::json(public_response(load_current(&ctx).await?)) } #[debug_handler] pub async fn maintenance_status( State(ctx): State, Json(params): Json, ) -> Result { let current = load_current(&ctx).await?; let enabled = maintenance_mode_enabled(¤t); let access_granted = if enabled { validate_maintenance_access_token(¤t, params.access_token.as_deref()) } else { true }; format::json(MaintenanceAccessStatusResponse { maintenance_mode_enabled: enabled, access_granted, }) } #[debug_handler] pub async fn maintenance_verify( State(ctx): State, Json(params): Json, ) -> Result { let current = load_current(&ctx).await?; let enabled = maintenance_mode_enabled(¤t); if !enabled { return format::json(MaintenanceVerifyResponse { maintenance_mode_enabled: false, access_granted: true, access_token: None, }); } let access_token = verify_maintenance_access_code(¤t, params.code.as_deref()); format::json(MaintenanceVerifyResponse { maintenance_mode_enabled: true, access_granted: access_token.is_some(), access_token, }) } #[debug_handler] pub async fn update( headers: HeaderMap, State(ctx): State, Json(params): Json, ) -> Result { check_auth(&headers)?; 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("maintenance/status", post(maintenance_status)) .add("maintenance/verify", post(maintenance_verify)) .add("/", get(show)) .add("/", put(update)) .add("/", patch(update)) }