pub mod agent; pub mod ai_admin; pub mod ai_edges; pub mod ai_process; pub mod audio; pub mod bandwidth; mod auth; pub mod cas; mod custom_domain; mod intentions; pub mod jobs; pub mod livekit; pub mod maintenance; pub mod pruning; mod queries; pub mod publishing; pub mod health; pub mod resource_usage; pub mod resources; mod rss; mod serving; mod stdb; pub mod summarize; pub mod tiptap; pub mod transcribe; pub mod tts; pub mod usage_overview; pub mod user_usage; mod warmup; use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; use serde::Serialize; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use auth::{AuthUser, JwksKeys}; use cas::CasStore; use stdb::StdbClient; #[derive(Clone)] pub struct AppState { pub db: PgPool, pub jwks: JwksKeys, pub stdb: StdbClient, pub cas: CasStore, pub index_cache: publishing::IndexCache, pub dynamic_page_cache: publishing::DynamicPageCache, pub maintenance: maintenance::MaintenanceState, pub priority_rules: resources::PriorityRules, } #[derive(Serialize)] struct HealthResponse { status: &'static str, version: &'static str, db: &'static str, stdb: &'static str, } #[derive(Serialize)] struct MeResponse { node_id: uuid::Uuid, authentik_sub: String, } #[tokio::main] async fn main() { tracing_subscriber::registry() .with( EnvFilter::try_from_default_env() .unwrap_or_else(|_| "maskinrommet=debug,tower_http=debug".parse().unwrap()), ) .with(tracing_subscriber::fmt::layer()) .init(); // Database let database_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://sidelinja:sidelinja@localhost:5432/synops".to_string()); let db = PgPoolOptions::new() .max_connections(10) .connect(&database_url) .await .expect("Kunne ikke koble til PostgreSQL"); tracing::info!("Koblet til PostgreSQL"); // JWKS — hent nøkler fra Authentik ved oppstart let issuer = std::env::var("AUTHENTIK_ISSUER") .unwrap_or_else(|_| "https://auth.sidelinja.org/application/o/sidelinja/".to_string()); let client_id = std::env::var("AUTHENTIK_CLIENT_ID") .expect("AUTHENTIK_CLIENT_ID må være satt"); let jwks = JwksKeys::fetch(&issuer, &client_id) .await .expect("Kunne ikke hente JWKS fra Authentik"); // SpacetimeDB-klient let stdb_url = std::env::var("SPACETIMEDB_URL") .unwrap_or_else(|_| "http://spacetimedb:3000".to_string()); let stdb_database = std::env::var("SPACETIMEDB_DATABASE") .unwrap_or_else(|_| "synops".to_string()); // Hent token fra miljøvariabel, eller opprett ny identitet let stdb_token = match std::env::var("SPACETIMEDB_TOKEN") { Ok(token) if !token.is_empty() => { tracing::info!("Bruker konfigurert STDB-token"); token } _ => { tracing::info!("Ingen STDB-token konfigurert, oppretter ny identitet"); let (identity, token) = StdbClient::create_identity(&stdb_url) .await .expect("Kunne ikke opprette STDB-identitet"); tracing::info!("Opprettet STDB-identitet: {identity}"); token } }; let stdb = StdbClient::new(&stdb_url, &stdb_database, &stdb_token); // Warmup: last hele grafen fra PG til SpacetimeDB match warmup::run(&db, &stdb).await { Ok(stats) => { tracing::info!( "Warmup fullført: {} noder, {} edges, {} access", stats.nodes, stats.edges, stats.access ); } Err(e) => { tracing::error!("Warmup feilet: {e}"); // Fortsett likevel — STDB kan være midlertidig utilgjengelig } } // CAS — content-addressable store for binærfiler let cas_root = std::env::var("CAS_ROOT") .unwrap_or_else(|_| "/srv/synops/media/cas".to_string()); let cas = CasStore::new(&cas_root) .await .expect("Kunne ikke opprette CAS-katalog"); tracing::info!(root = %cas_root, "CAS initialisert"); // Vedlikeholdstilstand (oppgave 15.2) let maintenance = maintenance::MaintenanceState::new(); // Last prioritetsregler fra PG (oppgave 15.5) let priority_rules = resources::PriorityRules::load(&db) .await .expect("Kunne ikke laste prioritetsregler fra PG"); // Start jobbkø-worker i bakgrunnen (med ressursstyring, oppgave 15.5) jobs::start_worker(db.clone(), stdb.clone(), cas.clone(), maintenance.clone(), priority_rules.clone()); // Start periodisk CAS-pruning i bakgrunnen pruning::start_pruning_loop(db.clone(), cas.clone()); // Start disk-overvåking i bakgrunnen (oppgave 15.5) resources::start_disk_monitor(db.clone()); // Start planlagt publisering-scheduler i bakgrunnen publishing::start_publish_scheduler(db.clone()); // Start A/B-evaluator i bakgrunnen (oppgave 14.17) publishing::start_ab_evaluator(db.clone()); // Start nattlig bandwidth-parsing (oppgave 15.7) bandwidth::start_bandwidth_parser(db.clone()); // Start periodisk CAS tmp-opprydding (oppgave 17.6) cas::start_tmp_cleanup_loop(cas.clone()); let index_cache = publishing::new_index_cache(); let dynamic_page_cache = publishing::new_dynamic_page_cache(); let state = AppState { db, jwks, stdb, cas, index_cache, dynamic_page_cache, maintenance, priority_rules }; // Ruter: /health er offentlig, /me krever gyldig JWT let app = Router::new() .route("/health", get(health)) .route("/me", get(me)) .route("/intentions/create_node", post(intentions::create_node)) .route("/intentions/create_edge", post(intentions::create_edge)) .route("/intentions/update_node", post(intentions::update_node)) .route("/intentions/delete_node", post(intentions::delete_node)) .route("/intentions/update_edge", post(intentions::update_edge)) .route("/intentions/delete_edge", post(intentions::delete_edge)) .route("/intentions/set_slot", post(intentions::set_slot)) .route("/intentions/create_communication", post(intentions::create_communication)) .route("/intentions/upload_media", post(intentions::upload_media)) .route("/cas/{hash}", get(serving::get_cas_file)) .route("/query/nodes", get(queries::query_nodes)) .route("/query/board", get(queries::query_board)) .route("/query/editorial_board", get(queries::query_editorial_board)) .route("/query/segments", get(queries::query_segments)) .route("/query/segments/srt", get(queries::export_srt)) .route("/intentions/create_alias", post(intentions::create_alias)) .route("/intentions/update_segment", post(intentions::update_segment)) .route("/intentions/retranscribe", post(intentions::retranscribe)) .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) .route("/intentions/summarize", post(intentions::summarize)) .route("/intentions/ai_process", post(intentions::ai_process)) .route("/intentions/create_ai_preset", post(intentions::create_ai_preset)) .route("/intentions/generate_tts", post(intentions::generate_tts)) .route("/intentions/join_communication", post(intentions::join_communication)) .route("/intentions/leave_communication", post(intentions::leave_communication)) .route("/intentions/close_communication", post(intentions::close_communication)) .route("/query/aliases", get(queries::query_aliases)) .route("/query/graph", get(queries::query_graph)) .route("/query/presentation_elements", get(queries::query_presentation_elements)) .route("/query/transcription_versions", get(queries::query_transcription_versions)) .route("/query/segments_version", get(queries::query_segments_version)) .route("/intentions/audio_analyze", post(intentions::audio_analyze)) .route("/intentions/audio_process", post(intentions::audio_process)) .route("/intentions/ab_override", post(publishing::ab_override)) // Systemvarsler (oppgave 15.1) .route("/intentions/create_announcement", post(intentions::create_announcement)) .route("/intentions/expire_announcement", post(intentions::expire_announcement)) // Vedlikeholdsmodus (oppgave 15.2) .route("/intentions/initiate_maintenance", post(intentions::initiate_maintenance)) .route("/intentions/cancel_maintenance", post(intentions::cancel_maintenance)) .route("/admin/maintenance_status", get(intentions::maintenance_status)) // Jobbkø-oversikt (oppgave 15.3) .route("/admin/jobs", get(intentions::list_jobs)) .route("/intentions/retry_job", post(intentions::retry_job)) .route("/intentions/cancel_job", post(intentions::cancel_job)) // Ressursstyring (oppgave 15.5) .route("/admin/resources", get(intentions::resource_status)) .route("/admin/resources/disk", get(intentions::resource_disk)) .route("/admin/resources/update_rule", post(intentions::update_priority_rule)) // AI Gateway-konfigurasjon (oppgave 15.4) .route("/admin/ai", get(ai_admin::ai_overview)) .route("/admin/ai/usage", get(ai_admin::ai_usage)) .route("/admin/ai/update_alias", post(ai_admin::update_alias)) .route("/admin/ai/create_alias", post(ai_admin::create_alias)) .route("/admin/ai/update_provider", post(ai_admin::update_provider)) .route("/admin/ai/create_provider", post(ai_admin::create_provider)) .route("/admin/ai/delete_provider", post(ai_admin::delete_provider)) .route("/admin/ai/update_routing", post(ai_admin::update_routing)) .route("/admin/ai/delete_routing", post(ai_admin::delete_routing)) // Forbruksoversikt (oppgave 15.8) .route("/admin/usage", get(usage_overview::usage_overview)) // Brukersynlig forbruk (oppgave 15.9) .route("/my/usage", get(user_usage::my_usage)) .route("/query/node_usage", get(user_usage::node_usage)) // Serverhelse-dashboard (oppgave 15.6) .route("/admin/health", get(health::health_dashboard)) .route("/admin/health/logs", get(health::health_logs)) .route("/query/audio_info", get(intentions::audio_info)) .route("/query/job_status", get(queries::query_job_status)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}", get(publishing::serve_index)) // A/B-testing: klikk-sporing (oppgave 14.17) .route("/pub/{slug}/t/{article_id}", get(publishing::track_click)) // Dynamiske sider: kategori, arkiv, søk, om (oppgave 14.15) .route("/pub/{slug}/kategori/{tag}", get(publishing::serve_category)) .route("/pub/{slug}/arkiv", get(publishing::serve_archive)) .route("/pub/{slug}/sok", get(publishing::serve_search)) .route("/pub/{slug}/om", get(publishing::serve_about)) .route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme)) // NB: {article_id} catch-all må komme etter de spesifikke rutene .route("/pub/{slug}/{article_id}", get(publishing::serve_article)) // Custom domains: Caddy on-demand TLS callback .route("/internal/verify-domain", get(custom_domain::verify_domain)) // Custom domains: domene-basert serving (Caddy proxyer hit) .route("/custom-domain/index", get(custom_domain::serve_custom_domain_index)) .route("/custom-domain/feed.xml", get(custom_domain::serve_custom_domain_feed)) // Dynamiske sider for custom domains .route("/custom-domain/kategori/{tag}", get(custom_domain::serve_custom_domain_category)) .route("/custom-domain/arkiv", get(custom_domain::serve_custom_domain_archive)) .route("/custom-domain/sok", get(custom_domain::serve_custom_domain_search)) .route("/custom-domain/om", get(custom_domain::serve_custom_domain_about)) .route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article)) .layer(TraceLayer::new_for_http()) .with_state(state); let bind = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3100".to_string()); let listener = tokio::net::TcpListener::bind(&bind).await.unwrap(); tracing::info!("Maskinrommet lytter på {bind}"); axum::serve(listener, app).await.unwrap(); } /// Offentlig helsesjekk — verifiserer PG- og STDB-tilkobling. async fn health(State(state): State) -> Result, StatusCode> { sqlx::query("SELECT 1") .execute(&state.db) .await .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; // STDB helsesjekk: prøv å slette en ikke-eksisterende node. // Kallet når STDB og returnerer ok (noop), men feiler ved nettverksfeil. let stdb_status = match state.stdb.delete_node("__healthcheck_nonexistent__").await { Ok(()) => "connected", Err(_) => "unavailable", }; Ok(Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), db: "connected", stdb: stdb_status, })) } /// Beskyttet endepunkt — returnerer autentisert brukers node_id. /// Brukes for å verifisere at auth-middleware fungerer. async fn me(user: AuthUser) -> Json { Json(MeResponse { node_id: user.node_id, authentik_sub: user.authentik_sub, }) }