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
491 lines
21 KiB
Rust
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(())
|
|
}
|
|
}
|