Files
termi-blog/backend/src/controllers/site_settings.rs
limitcool 9665c933b5
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
feat: update tag and timeline share panel copy for clarity and conciseness
style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
2026-04-02 23:05:49 +08:00

1183 lines
47 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 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<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 = "musicEnabled")]
pub music_enabled: Option<bool>,
#[serde(default, alias = "maintenanceModeEnabled")]
pub maintenance_mode_enabled: Option<bool>,
#[serde(default, alias = "maintenanceAccessCode")]
pub maintenance_access_code: Option<String>,
#[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
pub paragraph_comments_enabled: Option<bool>,
#[serde(default, alias = "commentVerificationMode")]
pub comment_verification_mode: Option<String>,
#[serde(default, alias = "commentTurnstileEnabled")]
pub comment_turnstile_enabled: Option<bool>,
#[serde(default, alias = "subscriptionVerificationMode")]
pub subscription_verification_mode: Option<String>,
#[serde(default, alias = "subscriptionTurnstileEnabled")]
pub subscription_turnstile_enabled: Option<bool>,
#[serde(default, alias = "webPushEnabled")]
pub web_push_enabled: Option<bool>,
#[serde(default, alias = "turnstileSiteKey")]
pub turnstile_site_key: Option<String>,
#[serde(default, alias = "turnstileSecretKey")]
pub turnstile_secret_key: Option<String>,
#[serde(default, alias = "webPushVapidPublicKey")]
pub web_push_vapid_public_key: Option<String>,
#[serde(default, alias = "webPushVapidPrivateKey")]
pub web_push_vapid_private_key: Option<String>,
#[serde(default, alias = "webPushVapidSubject")]
pub web_push_vapid_subject: Option<String>,
#[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>,
#[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 = "seoWechatShareQrEnabled")]
pub seo_wechat_share_qr_enabled: Option<bool>,
#[serde(default, alias = "notificationWebhookUrl")]
pub notification_webhook_url: Option<String>,
#[serde(default, alias = "notificationChannelType")]
pub notification_channel_type: 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 = "subscriptionPopupEnabled")]
pub subscription_popup_enabled: Option<bool>,
#[serde(default, alias = "subscriptionPopupTitle")]
pub subscription_popup_title: Option<String>,
#[serde(default, alias = "subscriptionPopupDescription")]
pub subscription_popup_description: Option<String>,
#[serde(default, alias = "subscriptionPopupDelaySeconds")]
pub subscription_popup_delay_seconds: Option<i32>,
#[serde(default, alias = "searchSynonyms")]
pub search_synonyms: Option<Vec<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 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<String>,
pub web_push_vapid_public_key: Option<String>,
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<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceAccessTokenPayload {
#[serde(default, alias = "accessToken")]
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceVerifyPayload {
#[serde(default)]
pub code: Option<String>,
}
#[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<String>,
}
#[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>,
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> {
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 maintenance_mode_enabled(model: &Model) -> bool {
model.maintenance_mode_enabled.unwrap_or(false)
}
fn maintenance_access_code(model: &Model) -> Option<String> {
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::<String>()
}
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<String> {
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<String>) -> Option<String> {
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<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())
}
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(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(
"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()),
},
]),
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<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 {
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<AppContext>) -> Result<Response> {
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::<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?;
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<_>>();
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<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?))
}
#[debug_handler]
pub async fn maintenance_status(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceAccessTokenPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
let access_granted = if enabled {
validate_maintenance_access_token(&current, 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<AppContext>,
Json(params): Json<MaintenanceVerifyPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
if !enabled {
return format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: false,
access_granted: true,
access_token: None,
});
}
let access_token = verify_maintenance_access_code(&current, 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<AppContext>,
Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
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))
}