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>
444 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|