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 { let trimmed = value.trim().trim_end_matches('/').to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } } fn collect_cors_origins() -> Vec { 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 { create_app::(mode, environment, config).await } async fn initializers(_ctx: &AppContext) -> Result>> { 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 { let allowed_origins = collect_cors_origins() .into_iter() .filter_map(|origin| origin.parse().ok()) .collect::>(); 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::(&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_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_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::>() }) .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_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_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_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::>() }) .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::>() }) .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_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::>() }) .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(()) } }