Files
termi-blog/backend/src/app.rs
limitcool 660b255700
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
Fix admin login and add subscription popup settings
2026-04-01 00:05:16 +08:00

491 lines
21 KiB
Rust

use async_trait::async_trait;
use axum::{
http::{header, HeaderName, Method},
Router as AxumRouter,
};
use loco_rs::{
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode},
config::Config,
controller::AppRoutes,
db::{self, truncate_table},
environment::Environment,
task::Tasks,
Result,
};
use migration::Migrator;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
};
use std::{collections::BTreeSet, path::Path};
use tower_http::cors::CorsLayer;
#[allow(unused_imports)]
use crate::{
controllers, initializers,
models::_entities::{
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
},
tasks,
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
};
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 {
env!("CARGO_CRATE_NAME")
}
fn app_version() -> String {
format!(
"{} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("BUILD_SHA")
.or(option_env!("GITHUB_SHA"))
.unwrap_or("dev")
)
}
async fn boot(
mode: StartMode,
environment: &Environment,
config: Config,
) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment, config).await
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::health::routes())
.add_route(controllers::admin_api::routes())
.add_route(controllers::admin_ops::routes())
.add_route(controllers::review::routes())
.add_route(controllers::category::routes())
.add_route(controllers::friend_link::routes())
.add_route(controllers::tag::routes())
.add_route(controllers::comment::routes())
.add_route(controllers::post::routes())
.add_route(controllers::search::routes())
.add_route(controllers::content_analytics::routes())
.add_route(controllers::site_settings::routes())
.add_route(controllers::ai::routes())
.add_route(controllers::auth::routes())
.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(allowed_origins)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
])
.allow_headers(allowed_headers)
.allow_credentials(true);
Ok(router.layer(cors))
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
queue.register(NotificationDeliveryWorker::build(ctx)).await?;
Ok(())
}
#[allow(unused_variables)]
fn register_tasks(tasks: &mut Tasks) {
tasks.register(tasks::retry_deliveries::RetryDeliveries);
tasks.register(tasks::send_weekly_digest::SendWeeklyDigest);
tasks.register(tasks::send_monthly_digest::SendMonthlyDigest);
// tasks-inject (do not remove)
}
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
// Seed users - use loco's default seed which handles duplicates
let users_file = base.join("users.yaml");
if users_file.exists() {
if let Err(e) =
db::seed::<users::ActiveModel>(&ctx.db, &users_file.display().to_string()).await
{
tracing::warn!("Users seed skipped or failed: {}", e);
}
}
// Seed tags first (no foreign key dependencies) - use Unchanged to ignore conflicts
if let Ok(seed_data) = std::fs::read_to_string(base.join("tags.yaml")) {
let tags_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for tag in tags_data {
let id = tag["id"].as_i64().unwrap_or(0) as i32;
let name = tag["name"].as_str().unwrap_or("").to_string();
let slug = tag["slug"].as_str().unwrap_or("").to_string();
let existing = tags::Entity::find_by_id(id).one(&ctx.db).await?;
if existing.is_none() {
let new_tag = tags::ActiveModel {
id: Set(id),
name: Set(Some(name)),
slug: Set(slug),
..Default::default()
};
let _ = new_tag.insert(&ctx.db).await;
}
}
}
// Seed posts
if let Ok(seed_data) = std::fs::read_to_string(base.join("posts.yaml")) {
let posts_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for post in posts_data {
let pid = post["pid"].as_i64().unwrap_or(0) as i32;
let title = post["title"].as_str().unwrap_or("").to_string();
let slug = post["slug"].as_str().unwrap_or("").to_string();
let content = post["content"].as_str().unwrap_or("").to_string();
let excerpt = post["excerpt"].as_str().unwrap_or("").to_string();
let category = post["category"].as_str().unwrap_or("").to_string();
let pinned = post["pinned"].as_bool().unwrap_or(false);
let post_type = post["post_type"].as_str().unwrap_or("article").to_string();
let tags_vec = post["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let tags_json = if tags_vec.is_empty() {
None
} else {
Some(serde_json::json!(tags_vec))
};
let existing = posts::Entity::find_by_id(pid).one(&ctx.db).await?;
let has_existing = existing.is_some();
let mut post_model = existing
.map(|model| model.into_active_model())
.unwrap_or_else(|| posts::ActiveModel {
id: Set(pid),
..Default::default()
});
post_model.title = Set(Some(title));
post_model.slug = Set(slug);
post_model.content = Set(Some(content));
post_model.description = Set(Some(excerpt));
post_model.category = Set(Some(category));
post_model.tags = Set(tags_json);
post_model.pinned = Set(Some(pinned));
post_model.post_type = Set(Some(post_type));
if has_existing {
let _ = post_model.update(&ctx.db).await;
} else {
let _ = post_model.insert(&ctx.db).await;
}
}
}
// Seed comments
if let Ok(seed_data) = std::fs::read_to_string(base.join("comments.yaml")) {
let comments_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for comment in comments_data {
let id = comment["id"].as_i64().unwrap_or(0) as i32;
let pid = comment["pid"].as_i64().unwrap_or(0) as i32;
let author = comment["author"].as_str().unwrap_or("").to_string();
let email = comment["email"].as_str().unwrap_or("").to_string();
let content_text = comment["content"].as_str().unwrap_or("").to_string();
let approved = comment["approved"].as_bool().unwrap_or(false);
let post_slug = posts::Entity::find_by_id(pid)
.one(&ctx.db)
.await?
.map(|post| post.slug);
let existing = comments::Entity::find_by_id(id).one(&ctx.db).await?;
let has_existing = existing.is_some();
let mut comment_model = existing
.map(|model| model.into_active_model())
.unwrap_or_else(|| comments::ActiveModel {
id: Set(id),
..Default::default()
});
comment_model.author = Set(Some(author));
comment_model.email = Set(Some(email));
comment_model.content = Set(Some(content_text));
comment_model.approved = Set(Some(approved));
comment_model.post_slug = Set(post_slug);
if has_existing {
let _ = comment_model.update(&ctx.db).await;
} else {
let _ = comment_model.insert(&ctx.db).await;
}
}
}
// Seed friend links
if let Ok(seed_data) = std::fs::read_to_string(base.join("friend_links.yaml")) {
let links_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for link in links_data {
let site_name = link["site_name"].as_str().unwrap_or("").to_string();
let site_url = link["site_url"].as_str().unwrap_or("").to_string();
let avatar_url = link["avatar_url"].as_str().map(|s: &str| s.to_string());
let description = link["description"].as_str().unwrap_or("").to_string();
let category = link["category"].as_str().unwrap_or("").to_string();
let status = link["status"].as_str().unwrap_or("pending").to_string();
let existing = friend_links::Entity::find()
.filter(friend_links::Column::SiteUrl.eq(&site_url))
.one(&ctx.db)
.await?;
if existing.is_none() {
let new_link = friend_links::ActiveModel {
site_name: Set(Some(site_name)),
site_url: Set(site_url),
avatar_url: Set(avatar_url),
description: Set(Some(description)),
category: Set(Some(category)),
status: Set(Some(status)),
..Default::default()
};
let _ = new_link.insert(&ctx.db).await;
}
}
}
// Seed site settings
if let Ok(seed_data) = std::fs::read_to_string(base.join("site_settings.yaml")) {
let settings_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
if let Some(settings) = settings_data.first() {
let existing = site_settings::Entity::find()
.order_by_asc(site_settings::Column::Id)
.one(&ctx.db)
.await?;
if existing.is_none() {
let tech_stack = settings["tech_stack"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(|items| serde_json::json!(items));
let music_playlist = settings["music_playlist"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| {
let title = item["title"].as_str()?.trim();
let url = item["url"].as_str()?.trim();
if title.is_empty() || url.is_empty() {
None
} else {
Some(serde_json::json!({
"title": title,
"url": url,
}))
}
})
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(serde_json::Value::Array);
let item = site_settings::ActiveModel {
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
site_short_name: Set(settings["site_short_name"]
.as_str()
.map(ToString::to_string)),
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
site_description: Set(settings["site_description"]
.as_str()
.map(ToString::to_string)),
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
hero_subtitle: Set(settings["hero_subtitle"]
.as_str()
.map(ToString::to_string)),
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
owner_title: Set(settings["owner_title"].as_str().map(ToString::to_string)),
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
owner_avatar_url: Set(settings["owner_avatar_url"].as_str().and_then(
|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
},
)),
social_github: Set(settings["social_github"]
.as_str()
.map(ToString::to_string)),
social_twitter: Set(settings["social_twitter"]
.as_str()
.map(ToString::to_string)),
social_email: Set(settings["social_email"]
.as_str()
.map(ToString::to_string)),
location: Set(settings["location"].as_str().map(ToString::to_string)),
tech_stack: Set(tech_stack),
music_playlist: Set(music_playlist),
ai_enabled: Set(settings["ai_enabled"].as_bool()),
paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"]
.as_bool()
.or(Some(true))),
ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)),
ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)),
ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)),
ai_chat_model: Set(settings["ai_chat_model"]
.as_str()
.map(ToString::to_string)),
ai_embedding_model: Set(settings["ai_embedding_model"]
.as_str()
.map(ToString::to_string)),
ai_system_prompt: Set(settings["ai_system_prompt"]
.as_str()
.map(ToString::to_string)),
ai_top_k: Set(settings["ai_top_k"].as_i64().map(|value| value as i32)),
ai_chunk_size: Set(settings["ai_chunk_size"]
.as_i64()
.map(|value| value as i32)),
..Default::default()
};
let _ = item.insert(&ctx.db).await;
}
}
}
// Seed reviews
if let Ok(seed_data) = std::fs::read_to_string(base.join("reviews.yaml")) {
let reviews_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for review in reviews_data {
let title = review["title"].as_str().unwrap_or("").to_string();
let review_type = review["review_type"].as_str().unwrap_or("").to_string();
let rating = review["rating"].as_i64().unwrap_or(0) as i32;
let review_date = review["review_date"].as_str().unwrap_or("").to_string();
let status = review["status"].as_str().unwrap_or("completed").to_string();
let description = review["description"].as_str().unwrap_or("").to_string();
let cover = review["cover"].as_str().unwrap_or("📝").to_string();
let link_url = review["link_url"]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let tags_vec = review["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let existing = reviews::Entity::find()
.filter(reviews::Column::Title.eq(&title))
.one(&ctx.db)
.await?;
if existing.is_none() {
let new_review = reviews::ActiveModel {
title: Set(Some(title)),
review_type: Set(Some(review_type)),
rating: Set(Some(rating)),
review_date: Set(Some(review_date)),
status: Set(Some(status)),
description: Set(Some(description)),
cover: Set(Some(cover)),
link_url: Set(link_url),
tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())),
..Default::default()
};
let _ = new_review.insert(&ctx.db).await;
}
}
}
Ok(())
}
async fn truncate(ctx: &AppContext) -> Result<()> {
truncate_table(&ctx.db, ai_chunks::Entity).await?;
truncate_table(&ctx.db, users::Entity).await?;
Ok(())
}
}