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; pub mod cli_dispatch; pub mod clip; mod custom_domain; mod intentions; pub mod jobs; pub mod livekit; pub mod maintenance; pub mod metrics; pub mod pruning; mod queries; pub mod pg_writes; pub mod publishing; pub mod health; pub mod resource_usage; pub mod resources; mod rss; mod serving; pub mod summarize; pub mod ws; pub mod mixer; pub mod orchestration_trigger; pub mod script_compiler; pub mod script_executor; pub mod tiptap; pub mod transcribe; pub mod tts; pub mod usage_overview; pub mod user_usage; mod workspace; use axum::{extract::State, http::StatusCode, middleware, routing::{get, post}, Json, Router}; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use auth::{AuthUser, JwksKeys}; use cas::CasStore; use ws::WsBroadcast; #[derive(Clone)] pub struct AppState { pub db: PgPool, pub jwks: JwksKeys, pub cas: CasStore, pub index_cache: publishing::IndexCache, pub dynamic_page_cache: publishing::DynamicPageCache, pub maintenance: maintenance::MaintenanceState, pub priority_rules: resources::PriorityRules, pub metrics: metrics::MetricsCollector, pub ws_broadcast: WsBroadcast, } #[derive(Serialize)] struct HealthResponse { status: &'static str, version: &'static str, db: &'static str, } #[derive(Serialize)] struct MeResponse { node_id: uuid::Uuid, authentik_sub: String, } #[derive(Deserialize)] struct AuthSyncRequest { username: String, } #[derive(Serialize)] struct AuthSyncResponse { ok: bool, } #[tokio::main] async fn main() { // Strukturert logging: LOG_FORMAT=json for maskinlesbart, ellers human-readable. let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| "maskinrommet=info,tower_http=info".parse().unwrap()); let log_format = std::env::var("LOG_FORMAT").unwrap_or_default(); if log_format == "json" { tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer().json()) .init(); } else { tracing_subscriber::registry() .with(env_filter) .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"); // 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"); let index_cache = publishing::new_index_cache(); // Start jobbkø-worker i bakgrunnen (med ressursstyring, oppgave 15.5) jobs::start_worker(db.clone(), cas.clone(), index_cache.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 dynamic_page_cache = publishing::new_dynamic_page_cache(); let metrics = metrics::MetricsCollector::new(); // WebSocket broadcast-kanal og PG LISTEN/NOTIFY-lytter (oppgave 22.1) let ws_broadcast = WsBroadcast::new(); ws::start_pg_listener(db.clone(), ws_broadcast.clone()); let state = AppState { db, jwks, cas, index_cache, dynamic_page_cache, maintenance, priority_rules, metrics, ws_broadcast }; // Ruter: /health er offentlig, /me krever gyldig JWT let app = Router::new() .route("/health", get(health)) .route("/me", get(me)) .route("/auth/sync", post(auth_sync)) // WebSocket-endepunkt for sanntid via PG LISTEN/NOTIFY (oppgave 22.1) .route("/ws", get(ws::ws_handler)) .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("/my/workspace", get(workspace::my_workspace)) .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)) // Orkestrering UI (oppgave 24.6) + AI-assistert (oppgave 24.7) .route("/intentions/clip_url", post(intentions::clip_url)) .route("/intentions/compile_script", post(intentions::compile_script)) .route("/intentions/test_orchestration", post(intentions::test_orchestration)) .route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script)) .route("/query/orchestration_log", get(intentions::orchestration_log)) // Mixer-kanaler .route("/intentions/create_mixer_channel", post(mixer::create_mixer_channel)) .route("/intentions/set_gain", post(mixer::set_gain)) .route("/intentions/set_mute", post(mixer::set_mute)) .route("/intentions/toggle_effect", post(mixer::toggle_effect)) .route("/intentions/set_mixer_role", post(mixer::set_mixer_role)) // Observerbarhet (oppgave 12.1) .route("/metrics", get(metrics::metrics_endpoint)) .layer(middleware::from_fn_with_state(state.clone(), metrics::latency_middleware)) .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-tilkobling. async fn health(State(state): State) -> Result, StatusCode> { sqlx::query("SELECT 1") .execute(&state.db) .await .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; Ok(Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), db: "connected", })) } /// 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, }) } /// POST /auth/sync — synkroniser brukerprofil fra Authentik ved login. /// Kalles av SvelteKit auth-callback etter vellykket innlogging. /// Oppdaterer username i auth_identities fra Authentik preferred_username. async fn auth_sync( user: AuthUser, State(state): State, Json(body): Json, ) -> Result, StatusCode> { let username = body.username.trim().to_lowercase(); if username.is_empty() { tracing::warn!("auth_sync: tomt brukernavn for node_id={}", user.node_id); return Err(StatusCode::BAD_REQUEST); } sqlx::query( "UPDATE auth_identities SET username = $1 WHERE node_id = $2", ) .bind(&username) .bind(user.node_id) .execute(&state.db) .await .map_err(|e| { tracing::error!("auth_sync: kunne ikke oppdatere username: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; tracing::info!( "auth_sync: username='{}' for node_id={}", username, user.node_id ); Ok(Json(AuthSyncResponse { ok: true })) }