synops/maskinrommet/src/custom_domain.rs
vegard 26c6a3b8d9 Dynamiske sider (oppgave 14.15): kategori, arkiv, søk, om-side
Implementerer fire nye dynamiske sidetyper for publiseringssamlinger:

- Kategori-sider: filtrert på tag-edges, paginert med cache
- Arkiv: kronologisk med månedsgruppering, paginert med cache
- Søk: PG fulltekst med tsvector/ts_rank, paginert med cache
- Om-side: statisk CAS-node (page_role: "about"), immutable cache

Teknisk:
- Ny migrasjon (011): tsvector-kolonne + GIN-indeks + trigger for søk
- Nye Tera-templates: category.html, archive.html, search.html, about.html
- DynamicPageCache for in-memory caching av dynamiske sider
- Ruter for /pub/{slug}/kategori/{tag}, arkiv, sok, om
- Custom domain-varianter for alle nye sidetyper

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

444 lines
14 KiB
Rust

//! 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<AppState>,
Query(query): Query<VerifyDomainQuery>,
) -> 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<String> = 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<String>,
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<Option<DomainCollection>, sqlx::Error> {
let row: Option<(Uuid, Option<String>, 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<String, StatusCode> {
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<AppState>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
axum::extract::Path(article_id): axum::extract::Path<String>,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
axum::extract::Path(tag): axum::extract::Path<String>,
query: Query<publishing::PageQuery>,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
query: Query<publishing::PageQuery>,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
query: Query<publishing::SearchQuery>,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<AppState>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<usize, sqlx::Error> {
// 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());
}
}