//! Custom domain-håndtering for publiseringssamlinger. //! //! Tre ansvarsområder: //! 1. Verify-domain callback for Caddy on-demand TLS (intern, uautentisert) //! 2. DNS-validering ved registrering av custom domain //! 3. Ruting: domene → samling → serve artikkel/forside/feed //! //! Ref: docs/concepts/publisering.md § "Custom domain-mekanisme" use axum::{ extract::{Query, State}, http::{HeaderMap, StatusCode}, response::Response, }; use serde::Deserialize; use sqlx::PgPool; use std::net::ToSocketAddrs; use uuid::Uuid; use crate::publishing::{self, PublishingConfig}; use crate::rss; use crate::AppState; // Re-export for route handler parameter types // (PageQuery og SearchQuery er definert i publishing-modulen) // ============================================================================= // Verify-domain (Caddy on-demand TLS callback) // ============================================================================= #[derive(Deserialize)] pub struct VerifyDomainQuery { domain: String, } /// GET /internal/verify-domain?domain=mittmagasin.no /// /// Caddy kaller dette endepunktet før den henter TLS-sertifikat. /// Returnerer 200 hvis domenet tilhører en samling med publishing-trait, /// 404 ellers. Uautentisert — kun tilgjengelig internt. pub async fn verify_domain( State(state): State, Query(query): Query, ) -> StatusCode { let domain = query.domain.to_lowercase().trim().to_string(); if domain.is_empty() { return StatusCode::BAD_REQUEST; } // Sjekk at domenet er registrert i en publishing-trait match find_collection_by_domain(&state.db, &domain).await { Ok(Some(_)) => { tracing::info!(domain = %domain, "Domene verifisert for on-demand TLS"); StatusCode::OK } Ok(None) => { tracing::debug!(domain = %domain, "Ukjent domene avvist"); StatusCode::NOT_FOUND } Err(e) => { tracing::error!(domain = %domain, error = %e, "DB-feil ved domeneverifisering"); StatusCode::INTERNAL_SERVER_ERROR } } } // ============================================================================= // DNS-validering // ============================================================================= /// Serverens forventede IP (fra miljøvariabel eller default). fn expected_server_ip() -> String { std::env::var("SERVER_IP").unwrap_or_else(|_| "157.180.81.26".to_string()) } /// Validerer at domenet har DNS-oppføring som peker til serveren. /// /// Sjekker A-record via system DNS resolver. Returnerer Ok(()) hvis /// minst én A-record peker til serverens IP, Err med forklaring ellers. pub fn validate_dns(domain: &str) -> Result<(), String> { let expected_ip = expected_server_ip(); // Bruk system DNS resolver let lookup = format!("{domain}:443") .to_socket_addrs() .map_err(|e| format!("DNS-oppslag feilet for {domain}: {e}"))?; let resolved_ips: Vec = lookup.map(|addr| addr.ip().to_string()).collect(); if resolved_ips.is_empty() { return Err(format!( "Ingen DNS-oppføringer funnet for {domain}. \ Opprett en A-record som peker til {expected_ip}." )); } if resolved_ips.iter().any(|ip| ip == &expected_ip) { Ok(()) } else { Err(format!( "DNS for {domain} peker til {:?}, men forventet {expected_ip}. \ Opprett en A-record som peker til {expected_ip}.", resolved_ips )) } } // ============================================================================= // Domene → samling-oppslag // ============================================================================= #[allow(dead_code)] struct DomainCollection { id: Uuid, title: Option, publishing_config: PublishingConfig, has_rss: bool, slug: String, } /// Finn samling basert på custom_domain i publishing-trait. async fn find_collection_by_domain( db: &PgPool, domain: &str, ) -> Result, sqlx::Error> { let row: Option<(Uuid, Option, serde_json::Value)> = sqlx::query_as( r#" SELECT id, title, metadata FROM nodes WHERE node_kind = 'collection' AND metadata->'traits'->'publishing'->>'custom_domain' = $1 LIMIT 1 "#, ) .bind(domain) .fetch_optional(db) .await?; let Some((id, title, metadata)) = row else { return Ok(None); }; let traits = metadata.get("traits"); let publishing_config: PublishingConfig = traits .and_then(|t| t.get("publishing")) .cloned() .map(|v| serde_json::from_value(v).unwrap_or_default()) .unwrap_or_default(); let slug = publishing_config .slug .clone() .unwrap_or_else(|| "unknown".to_string()); let has_rss = traits.and_then(|t| t.get("rss")).is_some(); Ok(Some(DomainCollection { id, title, publishing_config, has_rss, slug, })) } // ============================================================================= // Custom domain serving: forside, artikkel, feed // ============================================================================= /// Ekstraher domene fra Host-header. fn extract_domain(headers: &HeaderMap) -> Result { let host = headers .get("host") .and_then(|v| v.to_str().ok()) .ok_or(StatusCode::BAD_REQUEST)?; Ok(normalize_host(host)) } /// GET /custom-domain/index — forside for custom domain. /// /// Caddy rewriter forespørsler fra custom domains hit. /// Vi bruker Host-headeren til å finne samlingen. pub async fn serve_custom_domain_index( State(state): State, headers: HeaderMap, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_index( State(state), axum::extract::Path(slug), ) .await } /// GET /custom-domain/{article_id} — enkeltartikkel for custom domain. pub async fn serve_custom_domain_article( State(state): State, headers: HeaderMap, axum::extract::Path(article_id): axum::extract::Path, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_article( State(state), axum::extract::Path((slug, article_id)), ) .await } /// GET /custom-domain/kategori/{tag} — kategori for custom domain. pub async fn serve_custom_domain_category( State(state): State, headers: HeaderMap, axum::extract::Path(tag): axum::extract::Path, query: Query, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_category( State(state), axum::extract::Path((slug, tag)), query, ) .await } /// GET /custom-domain/arkiv — arkiv for custom domain. pub async fn serve_custom_domain_archive( State(state): State, headers: HeaderMap, query: Query, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_archive( State(state), axum::extract::Path(slug), query, ) .await } /// GET /custom-domain/sok — søk for custom domain. pub async fn serve_custom_domain_search( State(state): State, headers: HeaderMap, query: Query, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_search( State(state), axum::extract::Path(slug), query, ) .await } /// GET /custom-domain/om — om-side for custom domain. pub async fn serve_custom_domain_about( State(state): State, headers: HeaderMap, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; publishing::serve_about( State(state), axum::extract::Path(slug), ) .await } /// GET /custom-domain/feed.xml — RSS/Atom for custom domain. pub async fn serve_custom_domain_feed( State(state): State, headers: HeaderMap, ) -> Result { let domain = extract_domain(&headers)?; let collection = find_collection_by_domain(&state.db, &domain) .await .map_err(|e| { tracing::error!(domain = %domain, error = %e, "DB-feil ved domene-oppslag"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let slug = collection.slug; rss::generate_feed( State(state), axum::extract::Path(slug), ) .await } /// Normaliser Host-header: fjern port og lowercase. fn normalize_host(host: &str) -> String { host.split(':').next().unwrap_or(host).to_lowercase() } // ============================================================================= // Re-rendering ved domeneendring // ============================================================================= /// Enqueue re-rendering av alle artikler + forside for en samling. /// /// Kalles når custom_domain endres — canonical URL i rendret HTML /// må oppdateres. Artiklene re-rendres via jobbkøen (ikke-blokkerende). pub async fn rerender_collection_articles( db: &PgPool, collection_id: Uuid, ) -> Result { // Finn alle artikler som tilhører samlingen let article_ids: Vec<(Uuid,)> = sqlx::query_as( r#" SELECT e.source_id FROM edges e WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' "#, ) .bind(collection_id) .fetch_all(db) .await?; let count = article_ids.len(); // Enqueue render-jobb for hver artikkel for (article_id,) in &article_ids { let payload = serde_json::json!({ "node_id": article_id.to_string(), "collection_id": collection_id.to_string(), }); if let Err(e) = crate::jobs::enqueue(db, "render_article", payload, Some(collection_id), 3).await { tracing::error!( article_id = %article_id, collection_id = %collection_id, error = %e, "Kunne ikke enqueue render_article ved domeneendring" ); } } // Enqueue render av forsiden let index_payload = serde_json::json!({ "collection_id": collection_id.to_string(), }); if let Err(e) = crate::jobs::enqueue(db, "render_index", index_payload, Some(collection_id), 4).await { tracing::error!( collection_id = %collection_id, error = %e, "Kunne ikke enqueue render_index ved domeneendring" ); } tracing::info!( collection_id = %collection_id, article_count = count, "Re-rendering enqueued for domeneendring" ); Ok(count) } // ============================================================================= // Tester // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn normalize_host_strips_port() { assert_eq!(normalize_host("mittmagasin.no:443"), "mittmagasin.no"); assert_eq!(normalize_host("mittmagasin.no"), "mittmagasin.no"); assert_eq!(normalize_host("MITTMAGASIN.NO:8080"), "mittmagasin.no"); } #[test] fn dns_validation_rejects_empty() { let result = validate_dns(""); assert!(result.is_err()); } #[test] fn dns_validation_rejects_nonexistent() { let result = validate_dns("this-domain-does-not-exist-synops-test.invalid"); assert!(result.is_err()); } }