Fix admin login and add subscription popup settings
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 6s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 6s
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 6s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 6s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM rust:1.88-bookworm AS builder
|
||||
FROM rust:1.91.1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
@@ -36,6 +36,7 @@ mod m20260331_000025_create_post_revisions;
|
||||
mod m20260331_000026_create_subscriptions;
|
||||
mod m20260331_000027_create_notification_deliveries;
|
||||
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
||||
mod m20260331_000029_add_subscription_popup_settings_to_site_settings;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -76,6 +77,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260331_000026_create_subscriptions::Migration),
|
||||
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
||||
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
||||
Box::new(m20260331_000029_add_subscription_popup_settings_to_site_settings::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("site_settings");
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "subscription_popup_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("subscription_popup_enabled"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(true),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "subscription_popup_title")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("subscription_popup_title"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "subscription_popup_description")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("subscription_popup_description"))
|
||||
.text()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "subscription_popup_delay_seconds")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table)
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("subscription_popup_delay_seconds"))
|
||||
.integer()
|
||||
.null()
|
||||
.default(18),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("site_settings");
|
||||
|
||||
for column in [
|
||||
"subscription_popup_delay_seconds",
|
||||
"subscription_popup_description",
|
||||
"subscription_popup_title",
|
||||
"subscription_popup_enabled",
|
||||
] {
|
||||
if manager.has_column("site_settings", column).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.drop_column(Alias::new(column))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{http::Method, Router as AxumRouter};
|
||||
use axum::{
|
||||
http::{header, HeaderName, Method},
|
||||
Router as AxumRouter,
|
||||
};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Hooks, Initializer},
|
||||
bgworker::{BackgroundWorker, Queue},
|
||||
@@ -15,8 +18,8 @@ use migration::Migrator;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use std::path::Path;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use std::{collections::BTreeSet, path::Path};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
@@ -29,6 +32,48 @@ use crate::{
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
|
||||
fn normalized_origin(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim().trim_end_matches('/').to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_cors_origins() -> Vec<String> {
|
||||
let mut origins = BTreeSet::new();
|
||||
|
||||
for origin in [
|
||||
"http://127.0.0.1:4321",
|
||||
"http://127.0.0.1:4322",
|
||||
"http://localhost:4321",
|
||||
"http://localhost:4322",
|
||||
] {
|
||||
origins.insert(origin.to_string());
|
||||
}
|
||||
|
||||
for key in [
|
||||
"APP_BASE_URL",
|
||||
"ADMIN_API_BASE_URL",
|
||||
"ADMIN_FRONTEND_BASE_URL",
|
||||
"PUBLIC_API_BASE_URL",
|
||||
"PUBLIC_FRONTEND_BASE_URL",
|
||||
"TERMI_CORS_ALLOWED_ORIGINS",
|
||||
] {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
for origin in value.split([',', ';', ' ']) {
|
||||
if let Some(origin) = normalized_origin(origin) {
|
||||
origins.insert(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
origins.into_iter().collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hooks for App {
|
||||
fn app_name() -> &'static str {
|
||||
@@ -76,8 +121,22 @@ impl Hooks for App {
|
||||
.add_route(controllers::subscription::routes())
|
||||
}
|
||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let allowed_origins = collect_cors_origins()
|
||||
.into_iter()
|
||||
.filter_map(|origin| origin.parse().ok())
|
||||
.collect::<Vec<_>>();
|
||||
let allowed_headers = [
|
||||
header::ACCEPT,
|
||||
header::ACCEPT_LANGUAGE,
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_LANGUAGE,
|
||||
header::CONTENT_TYPE,
|
||||
header::COOKIE,
|
||||
header::ORIGIN,
|
||||
HeaderName::from_static("x-requested-with"),
|
||||
];
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_origin(allowed_origins)
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
@@ -85,7 +144,8 @@ impl Hooks for App {
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
])
|
||||
.allow_headers(Any);
|
||||
.allow_headers(allowed_headers)
|
||||
.allow_credentials(true);
|
||||
|
||||
Ok(router.layer(cors))
|
||||
}
|
||||
|
||||
@@ -44,8 +44,13 @@ pub struct AdminSessionResponse {
|
||||
pub can_logout: bool,
|
||||
}
|
||||
|
||||
fn build_session_response(identity: Option<crate::controllers::admin::AdminIdentity>) -> AdminSessionResponse {
|
||||
let can_logout = matches!(identity.as_ref().map(|item| item.source.as_str()), Some("local"));
|
||||
fn build_session_response(
|
||||
identity: Option<crate::controllers::admin::AdminIdentity>,
|
||||
) -> AdminSessionResponse {
|
||||
let can_logout = matches!(
|
||||
identity.as_ref().map(|item| item.source.as_str()),
|
||||
Some("local")
|
||||
);
|
||||
|
||||
AdminSessionResponse {
|
||||
authenticated: identity.is_some(),
|
||||
@@ -193,6 +198,10 @@ pub struct AdminSiteSettingsResponse {
|
||||
pub notification_webhook_url: Option<String>,
|
||||
pub notification_comment_enabled: bool,
|
||||
pub notification_friend_link_enabled: bool,
|
||||
pub subscription_popup_enabled: bool,
|
||||
pub subscription_popup_title: String,
|
||||
pub subscription_popup_description: String,
|
||||
pub subscription_popup_delay_seconds: i32,
|
||||
pub search_synonyms: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -706,6 +715,18 @@ fn build_settings_response(
|
||||
notification_webhook_url: item.notification_webhook_url,
|
||||
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
||||
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
||||
subscription_popup_enabled: item
|
||||
.subscription_popup_enabled
|
||||
.unwrap_or_else(site_settings::default_subscription_popup_enabled),
|
||||
subscription_popup_title: item
|
||||
.subscription_popup_title
|
||||
.unwrap_or_else(site_settings::default_subscription_popup_title),
|
||||
subscription_popup_description: item
|
||||
.subscription_popup_description
|
||||
.unwrap_or_else(site_settings::default_subscription_popup_description),
|
||||
subscription_popup_delay_seconds: item
|
||||
.subscription_popup_delay_seconds
|
||||
.unwrap_or_else(site_settings::default_subscription_popup_delay_seconds),
|
||||
search_synonyms: tech_stack_values(&item.search_synonyms),
|
||||
}
|
||||
}
|
||||
@@ -753,7 +774,10 @@ pub async fn session_login(
|
||||
#[debug_handler]
|
||||
pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let before = resolve_admin_identity(&headers);
|
||||
if matches!(before.as_ref().map(|item| item.source.as_str()), Some("local")) {
|
||||
if matches!(
|
||||
before.as_ref().map(|item| item.source.as_str()),
|
||||
Some("local")
|
||||
) {
|
||||
clear_local_session(&headers);
|
||||
}
|
||||
|
||||
@@ -764,7 +788,10 @@ pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -
|
||||
"admin.logout",
|
||||
"admin_session",
|
||||
None,
|
||||
identity.email.clone().or_else(|| Some(identity.username.clone())),
|
||||
identity
|
||||
.email
|
||||
.clone()
|
||||
.or_else(|| Some(identity.username.clone())),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
@@ -843,10 +870,7 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
let mut recent_posts = all_posts
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let mut recent_posts = all_posts.clone().into_iter().collect::<Vec<_>>();
|
||||
recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
let recent_posts = recent_posts
|
||||
.into_iter()
|
||||
@@ -959,13 +983,19 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Res
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn analytics_overview(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn analytics_overview(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(analytics::build_admin_analytics(&ctx).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_site_settings(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn get_site_settings(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let current = site_settings::load_current(&ctx).await?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
@@ -1061,7 +1091,10 @@ pub async fn test_ai_image_provider(
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn test_r2_storage(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn test_r2_storage(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let settings = storage::require_r2_settings(&ctx).await?;
|
||||
@@ -1278,7 +1311,10 @@ pub async fn replace_media_object(
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_comment_blacklist(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn list_comment_blacklist(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let items = comment_blacklist::Entity::find()
|
||||
|
||||
@@ -143,6 +143,14 @@ pub struct SiteSettingsPayload {
|
||||
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>>,
|
||||
}
|
||||
@@ -169,6 +177,10 @@ pub struct PublicSiteSettingsResponse {
|
||||
pub music_playlist: Option<serde_json::Value>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
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>,
|
||||
}
|
||||
@@ -208,6 +220,22 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
||||
value.map(|item| item.clamp(min, max))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
@@ -569,6 +597,21 @@ impl SiteSettingsPayload {
|
||||
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));
|
||||
@@ -684,6 +727,10 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
notification_webhook_url: None,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -734,6 +781,18 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
music_playlist: model.music_playlist,
|
||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||||
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,
|
||||
}
|
||||
@@ -784,7 +843,8 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
.collect::<Vec<_>>();
|
||||
let content_highlights =
|
||||
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
|
||||
let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
|
||||
let content_ranges =
|
||||
crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
|
||||
|
||||
format::json(HomePageResponse {
|
||||
site_settings,
|
||||
|
||||
@@ -65,6 +65,11 @@ pub struct Model {
|
||||
pub notification_webhook_url: Option<String>,
|
||||
pub notification_comment_enabled: Option<bool>,
|
||||
pub notification_friend_link_enabled: Option<bool>,
|
||||
pub subscription_popup_enabled: Option<bool>,
|
||||
pub subscription_popup_title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub subscription_popup_description: Option<String>,
|
||||
pub subscription_popup_delay_seconds: Option<i32>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub search_synonyms: Option<Json>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user