synops/maskinrommet/src/main.rs
vegard 63630eb55a Fullfører oppgave 14.16: Presentasjonselementer som noder
Publisert tittel, ingress, OG-bilde og undertittel er nå egne noder
koblet til artikler via title/subtitle/summary/og_image-edges.
Rendering bruker presentasjonselementer med fallback til artikkelfelt.

Backend:
- Ny query: GET /query/presentation_elements?article_id=...
- render_article_to_cas henter presentasjonselementer via edges
- fetch_article + fetch_index_articles bruker pres.elementer
- Batch-henting for forsideartikler (én SQL-spørring)
- ArticleData utvides med subtitle + og_image
- Alle fire temaer viser subtitle og OG-bilde
- SEO og_image-tag fylles fra presentasjonselement

Frontend:
- PresentationEditor.svelte: opprett/rediger tittel, undertittel,
  ingress, OG-bilde med variantvelger (editorial/ai/social/rss)
- Integrert i PublishDialog via <details>-seksjon
- API-klient: fetchPresentationElements(), deleteNode()

Grunnlag for A/B-testing (oppgave 14.17): edge-metadata støtter
ab_status/impressions/clicks/ctr, best_of() prioriterer winner > testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:55:23 +00:00

246 lines
9.8 KiB
Rust

pub mod agent;
pub mod ai_edges;
pub mod audio;
mod auth;
pub mod cas;
mod custom_domain;
mod intentions;
pub mod jobs;
pub mod livekit;
pub mod pruning;
mod queries;
pub mod publishing;
mod rss;
mod serving;
mod stdb;
pub mod summarize;
pub mod tiptap;
pub mod transcribe;
pub mod tts;
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,
}
#[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");
// Start jobbkø-worker i bakgrunnen
jobs::start_worker(db.clone(), stdb.clone(), cas.clone());
// Start periodisk CAS-pruning i bakgrunnen
pruning::start_pruning_loop(db.clone(), cas.clone());
// Start planlagt publisering-scheduler i bakgrunnen
publishing::start_publish_scheduler(db.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 };
// 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/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("/query/audio_info", get(intentions::audio_info))
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
.route("/pub/{slug}", get(publishing::serve_index))
// 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<AppState>) -> Result<Json<HealthResponse>, 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<MeResponse> {
Json(MeResponse {
node_id: user.node_id,
authentik_sub: user.authentik_sub,
})
}