feat: update tag and timeline share panel copy for clarity and conciseness
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
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
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
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@@ -89,6 +90,12 @@ pub struct SiteSettingsPayload {
|
||||
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")]
|
||||
@@ -199,6 +206,7 @@ pub struct PublicSiteSettingsResponse {
|
||||
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,
|
||||
@@ -217,6 +225,31 @@ pub struct PublicSiteSettingsResponse {
|
||||
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,
|
||||
@@ -252,6 +285,51 @@ 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();
|
||||
@@ -272,7 +350,7 @@ pub(crate) fn default_subscription_popup_title() -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn default_subscription_popup_description() -> String {
|
||||
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。".to_string()
|
||||
"有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份。".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
|
||||
@@ -555,6 +633,15 @@ impl SiteSettingsPayload {
|
||||
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);
|
||||
}
|
||||
@@ -752,10 +839,10 @@ fn default_payload() -> 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()),
|
||||
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(
|
||||
@@ -813,6 +900,9 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
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(
|
||||
@@ -923,6 +1013,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
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(),
|
||||
@@ -1019,6 +1110,50 @@ 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(¤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<AppContext>,
|
||||
Json(params): Json<MaintenanceVerifyPayload>,
|
||||
) -> Result<Response> {
|
||||
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,
|
||||
@@ -1039,6 +1174,8 @@ 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))
|
||||
|
||||
Reference in New Issue
Block a user