synops/maskinrommet/src/rss.rs
vegard c5239d2923 Feed-redirect: 301 for podcast som flyttes til ny host (oppgave 30.8)
Når redirect_feed er satt i podcast-trait, returnerer maskinrommet
HTTP 301 Moved Permanently med Location-header i stedet for å serve
feeden. iTunes new-feed-url-taggen bevares også i RSS-en for klienter
som ikke følger 301.

Admin-UI: erstatter det enkle tekstfeltet med tre tilstander:
- Inaktiv: knapp "Flytt podcast til annen plattform..."
- Bekreftelse: advarsel + URL-felt + rød "Aktiver redirect"-knapp
- Aktiv: gul statusindikator med deaktiver-knapp

Backend: sjekker redirect_feed tidlig i generate_feed() og returnerer
301 før noe annet arbeid gjøres (DB-oppslag for episodes osv).
2026-03-19 00:31:39 +00:00

723 lines
24 KiB
Rust

//! RSS/Atom-feed: GET /pub/{slug}/feed.xml
//!
//! Genererer RSS 2.0 eller Atom 1.0 feed for samlinger med `rss`-trait.
//! Feeden er offentlig — ingen autentisering kreves.
//! Podcast-samlinger (med `podcast`-trait) inkluderer <enclosure>-tags,
//! iTunes-tags og Podcasting 2.0-tags (transcript, chapters).
//!
//! Ref: docs/concepts/publisering.md (RSS/Atom-seksjonen)
//! docs/primitiver/traits.md (rss-trait)
//! docs/features/podcast_hosting.md (iTunes/Podcasting 2.0)
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::Response,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::AppState;
// =============================================================================
// Konfigurasjon fra trait-metadata
// =============================================================================
#[derive(Deserialize, Default)]
struct RssTraitConfig {
format: Option<String>, // "rss" (default) eller "atom"
title: Option<String>,
description: Option<String>,
max_items: Option<i64>,
language: Option<String>,
}
#[derive(Deserialize, Default)]
struct PodcastTraitConfig {
itunes_author: Option<String>,
itunes_category: Option<String>,
itunes_subcategory: Option<String>,
explicit: Option<bool>,
language: Option<String>,
#[serde(default)]
redirect_feed: Option<String>,
}
#[derive(Deserialize, Default)]
struct PublishingTraitConfig {
#[allow(dead_code)]
slug: Option<String>,
custom_domain: Option<String>,
}
// =============================================================================
// Database-modeller
// =============================================================================
struct CollectionInfo {
id: Uuid,
title: Option<String>,
rss_config: RssTraitConfig,
publishing_config: PublishingTraitConfig,
is_podcast: bool,
podcast_config: PodcastTraitConfig,
artwork_cas_hash: Option<String>,
}
struct FeedItem {
id: Uuid,
title: Option<String>,
content: Option<String>,
created_at: DateTime<Utc>,
publish_at: Option<DateTime<Utc>>,
// Podcast-felt
enclosure_url: Option<String>,
enclosure_mime: Option<String>,
enclosure_size: Option<i64>,
has_transcript: bool,
chapters: Vec<Chapter>,
duration_secs: Option<i64>,
episode_image_cas: Option<String>,
}
#[allow(dead_code)]
struct Chapter {
at: String,
title: Option<String>,
}
// =============================================================================
// Handler
// =============================================================================
/// GET /pub/{slug}/feed.xml — offentlig RSS/Atom-feed.
pub async fn generate_feed(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
let collection = find_collection_by_slug(&state.db, &slug)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved oppslag av samling");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
// 301 Moved Permanently når redirect_feed er satt (podcast flyttet til ny host)
if let Some(ref redirect_url) = collection.podcast_config.redirect_feed {
if !redirect_url.is_empty() {
tracing::info!(slug = %slug, redirect = %redirect_url, "Feed-redirect aktiv");
return Ok(Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, redirect_url.as_str())
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body("Moved Permanently".into())
.unwrap());
}
}
let max_items = collection.rss_config.max_items.unwrap_or(50);
let items = fetch_feed_items(&state.db, collection.id, max_items, collection.is_podcast)
.await
.map_err(|e| {
tracing::error!(slug = %slug, error = %e, "Feil ved henting av feed-elementer");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let base_url = collection
.publishing_config
.custom_domain
.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("https://synops.no/pub/{slug}"));
let format = collection
.rss_config
.format
.as_deref()
.unwrap_or("rss");
let xml = match format {
"atom" => build_atom_feed(&collection, &items, &base_url),
_ => build_rss_feed(&collection, &items, &base_url),
};
let content_type = match format {
"atom" => "application/atom+xml; charset=utf-8",
_ => "application/rss+xml; charset=utf-8",
};
Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=300")
.body(xml.into())
.unwrap())
}
// =============================================================================
// Database-spørringer
// =============================================================================
/// Finn samling med rss-trait basert på publishing-slug.
async fn find_collection_by_slug(
db: &PgPool,
slug: &str,
) -> Result<Option<CollectionInfo>, 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'->>'slug' = $1
AND metadata->'traits' ? 'rss'
LIMIT 1
"#,
)
.bind(slug)
.fetch_optional(db)
.await?;
let Some((id, title, metadata)) = row else {
return Ok(None);
};
let traits = metadata
.get("traits")
.cloned()
.unwrap_or(serde_json::Value::Null);
let rss_config: RssTraitConfig = traits
.get("rss")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
let publishing_config: PublishingTraitConfig = traits
.get("publishing")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
let is_podcast = traits.get("podcast").is_some();
let podcast_config: PodcastTraitConfig = traits
.get("podcast")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
// Hent samlingens artwork via og_image-edge
let artwork_cas_hash: Option<String> = sqlx::query_scalar(
r#"
SELECT m.metadata->>'cas_hash'
FROM edges e
JOIN nodes m ON m.id = e.target_id
WHERE e.source_id = $1
AND e.edge_type = 'og_image'
LIMIT 1
"#,
)
.bind(id)
.fetch_optional(db)
.await?;
Ok(Some(CollectionInfo {
id,
title,
rss_config,
publishing_config,
is_podcast,
podcast_config,
artwork_cas_hash,
}))
}
/// Hent publiserte elementer (belongs_to-edges til samlingen).
/// For podcast-samlinger: inkluder enclosure-data, transkripsjoner, kapitler og varighet.
async fn fetch_feed_items(
db: &PgPool,
collection_id: Uuid,
max_items: i64,
is_podcast: bool,
) -> Result<Vec<FeedItem>, sqlx::Error> {
if is_podcast {
let rows: Vec<(
Uuid,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<serde_json::Value>,
Option<String>,
Option<String>,
Option<i64>,
Option<bool>,
Option<i64>,
Option<String>,
)> = sqlx::query_as(
r#"
SELECT
n.id,
n.title,
n.content,
n.created_at,
e.metadata,
m.metadata->>'cas_hash' AS cas_hash,
m.metadata->>'mime' AS mime,
COALESCE((m.metadata->>'size_bytes')::bigint, (m.metadata->>'size')::bigint) AS size,
EXISTS(
SELECT 1 FROM transcription_segments ts WHERE ts.node_id = n.id LIMIT 1
) AS has_transcript,
(m.metadata->>'duration_secs')::bigint AS duration_secs,
(SELECT img.metadata->>'cas_hash'
FROM edges ie
JOIN nodes img ON img.id = ie.target_id
WHERE ie.source_id = n.id AND ie.edge_type = 'og_image'
LIMIT 1
) AS episode_image_cas
FROM edges e
JOIN nodes n ON n.id = e.source_id
LEFT JOIN edges me ON me.source_id = n.id AND me.edge_type = 'has_media'
LEFT JOIN nodes m ON m.id = me.target_id AND m.node_kind = 'media'
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $2
"#,
)
.bind(collection_id)
.bind(max_items)
.fetch_all(db)
.await?;
// Samle node-IDer for å hente kapitler i én spørring
let node_ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();
let chapter_rows: Vec<(Uuid, String, Option<String>)> = if !node_ids.is_empty() {
sqlx::query_as(
r#"
SELECT
e.source_id,
COALESCE(e.metadata->>'at', '00:00:00') AS at,
e.metadata->>'title' AS title
FROM edges e
WHERE e.source_id = ANY($1)
AND e.edge_type = 'chapter'
ORDER BY e.source_id, e.metadata->>'at'
"#,
)
.bind(&node_ids)
.fetch_all(db)
.await?
} else {
vec![]
};
// Grupper kapitler per node
let mut chapters_map: std::collections::HashMap<Uuid, Vec<Chapter>> =
std::collections::HashMap::new();
for (node_id, at, title) in chapter_rows {
chapters_map
.entry(node_id)
.or_default()
.push(Chapter { at, title });
}
Ok(rows
.into_iter()
.map(
|(id, title, content, created_at, edge_meta, cas_hash, mime, size, has_transcript, duration_secs, episode_image_cas)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
FeedItem {
id,
title,
content,
created_at,
publish_at,
enclosure_url: cas_hash.map(|h| format!("/cas/{h}")),
enclosure_mime: mime,
enclosure_size: size,
has_transcript: has_transcript.unwrap_or(false),
chapters: chapters_map.remove(&id).unwrap_or_default(),
duration_secs,
episode_image_cas,
}
},
)
.collect())
} else {
// Vanlig feed: kun noder, ingen enclosures
let rows: Vec<(Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>)> = sqlx::query_as(
r#"
SELECT
n.id,
n.title,
n.content,
n.created_at,
e.metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $2
"#,
)
.bind(collection_id)
.bind(max_items)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|(id, title, content, created_at, edge_meta)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
FeedItem {
id,
title,
content,
created_at,
publish_at,
enclosure_url: None,
enclosure_mime: None,
enclosure_size: None,
has_transcript: false,
chapters: vec![],
duration_secs: None,
episode_image_cas: None,
}
})
.collect())
}
}
// =============================================================================
// XML-generering
// =============================================================================
/// Bygg RSS 2.0 XML-streng.
fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &str) -> String {
let channel_title = xml_escape(
collection
.rss_config
.title
.as_deref()
.or(collection.title.as_deref())
.unwrap_or("Untitled Feed"),
);
let channel_desc = xml_escape(
collection
.rss_config
.description
.as_deref()
.unwrap_or(""),
);
// Podcast-trait language overstyrer rss-trait language
let language = collection
.podcast_config
.language
.as_deref()
.or(collection.rss_config.language.as_deref())
.unwrap_or("no");
let feed_url = format!("{base_url}/feed.xml");
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
if collection.is_podcast {
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:podcast=\"https://podcastindex.org/namespace/1.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
} else {
xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
}
xml.push_str("<channel>\n");
xml.push_str(&format!(" <title>{channel_title}</title>\n"));
xml.push_str(&format!(" <link>{base_url}</link>\n"));
xml.push_str(&format!(" <description>{channel_desc}</description>\n"));
xml.push_str(&format!(" <language>{language}</language>\n"));
xml.push_str(&format!(
" <atom:link href=\"{feed_url}\" rel=\"self\" type=\"application/rss+xml\"/>\n"
));
if let Some(item) = items.first() {
let date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(" <lastBuildDate>{}</lastBuildDate>\n", date.to_rfc2822()));
}
// iTunes og Podcasting 2.0 channel-level tags
if collection.is_podcast {
let pc = &collection.podcast_config;
if let Some(ref author) = pc.itunes_author {
xml.push_str(&format!(
" <itunes:author>{}</itunes:author>\n",
xml_escape(author)
));
}
if let Some(ref category) = pc.itunes_category {
if let Some(ref subcategory) = pc.itunes_subcategory {
xml.push_str(&format!(
" <itunes:category text=\"{}\">\n <itunes:category text=\"{}\"/>\n </itunes:category>\n",
xml_escape(category),
xml_escape(subcategory)
));
} else {
xml.push_str(&format!(
" <itunes:category text=\"{}\"/>\n",
xml_escape(category)
));
}
}
let explicit = pc.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
if let Some(ref cas_hash) = collection.artwork_cas_hash {
let artwork_url = format!("{base_url}/cas/{cas_hash}");
xml.push_str(&format!(
" <itunes:image href=\"{artwork_url}\"/>\n"
));
}
xml.push_str(" <itunes:type>episodic</itunes:type>\n");
// itunes:new-feed-url for feed migration
if let Some(ref redirect_url) = pc.redirect_feed {
if !redirect_url.is_empty() {
xml.push_str(&format!(
" <itunes:new-feed-url>{}</itunes:new-feed-url>\n",
xml_escape(redirect_url)
));
}
}
// Podcasting 2.0: locked
xml.push_str(" <podcast:locked>no</podcast:locked>\n");
}
for item in items {
xml.push_str(" <item>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
xml.push_str(&format!(" <title>{title}</title>\n"));
let item_url = format!("{base_url}/{}", short_id(item.id));
xml.push_str(&format!(" <link>{item_url}</link>\n"));
// GUID er stabil — basert på node-ID
xml.push_str(&format!(
" <guid isPermaLink=\"false\">{}</guid>\n",
item.id
));
let pub_date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(" <pubDate>{}</pubDate>\n", pub_date.to_rfc2822()));
if let Some(ref content) = item.content {
let desc = xml_escape(&truncate_description(content, 500));
xml.push_str(&format!(" <description>{desc}</description>\n"));
}
// Podcast enclosure
if let Some(ref enc_path) = item.enclosure_url {
let enc_url = format!("{base_url}{enc_path}");
let mime = item.enclosure_mime.as_deref().unwrap_or("audio/mpeg");
let size = item.enclosure_size.unwrap_or(0);
xml.push_str(&format!(
" <enclosure url=\"{enc_url}\" length=\"{size}\" type=\"{mime}\"/>\n"
));
}
// iTunes og Podcasting 2.0 item-level tags (kun for podcast)
if collection.is_podcast {
xml.push_str(&format!(" <itunes:title>{title}</itunes:title>\n"));
if let Some(secs) = item.duration_secs {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
xml.push_str(&format!(
" <itunes:duration>{h:02}:{m:02}:{s:02}</itunes:duration>\n"
));
} else {
xml.push_str(&format!(
" <itunes:duration>{m:02}:{s:02}</itunes:duration>\n"
));
}
}
let explicit = collection.podcast_config.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
if let Some(ref cas) = item.episode_image_cas {
let img_url = format!("{base_url}/cas/{cas}");
xml.push_str(&format!(
" <itunes:image href=\"{img_url}\"/>\n"
));
}
// podcast:transcript — SRT fra transkripsjons-segmenter
if item.has_transcript {
let transcript_url =
format!("{base_url}/{}/transcript.srt", short_id(item.id));
xml.push_str(&format!(
" <podcast:transcript url=\"{transcript_url}\" type=\"application/srt\"/>\n"
));
}
// podcast:chapters — JSON fra chapter-edges
if !item.chapters.is_empty() {
let chapters_url =
format!("{base_url}/{}/chapters.json", short_id(item.id));
xml.push_str(&format!(
" <podcast:chapters url=\"{chapters_url}\" type=\"application/json+chapters\"/>\n"
));
}
}
xml.push_str(" </item>\n");
}
xml.push_str("</channel>\n");
xml.push_str("</rss>\n");
xml
}
/// Bygg Atom 1.0 XML-streng.
fn build_atom_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &str) -> String {
let feed_title = xml_escape(
collection
.rss_config
.title
.as_deref()
.or(collection.title.as_deref())
.unwrap_or("Untitled Feed"),
);
let feed_desc = xml_escape(
collection
.rss_config
.description
.as_deref()
.unwrap_or(""),
);
let feed_url = format!("{base_url}/feed.xml");
let updated = items
.first()
.map(|i| i.publish_at.unwrap_or(i.created_at))
.unwrap_or_else(Utc::now);
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");
xml.push_str(&format!(" <title>{feed_title}</title>\n"));
xml.push_str(&format!(" <subtitle>{feed_desc}</subtitle>\n"));
xml.push_str(&format!(
" <link href=\"{feed_url}\" rel=\"self\" type=\"application/atom+xml\"/>\n"
));
xml.push_str(&format!(" <link href=\"{base_url}\" rel=\"alternate\"/>\n"));
xml.push_str(&format!(" <id>{base_url}</id>\n"));
xml.push_str(&format!(
" <updated>{}</updated>\n",
updated.to_rfc3339()
));
for item in items {
xml.push_str(" <entry>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
xml.push_str(&format!(" <title>{title}</title>\n"));
let item_url = format!("{base_url}/{}", short_id(item.id));
xml.push_str(&format!(
" <link href=\"{item_url}\" rel=\"alternate\"/>\n"
));
xml.push_str(&format!(" <id>urn:uuid:{}</id>\n", item.id));
let pub_date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(
" <updated>{}</updated>\n",
pub_date.to_rfc3339()
));
xml.push_str(&format!(
" <published>{}</published>\n",
pub_date.to_rfc3339()
));
if let Some(ref content) = item.content {
let summary = xml_escape(&truncate_description(content, 500));
xml.push_str(&format!(" <summary>{summary}</summary>\n"));
}
// Podcast enclosure som link med rel="enclosure"
if let Some(ref enc_path) = item.enclosure_url {
let enc_url = format!("{base_url}{enc_path}");
let mime = item.enclosure_mime.as_deref().unwrap_or("audio/mpeg");
let size = item.enclosure_size.unwrap_or(0);
xml.push_str(&format!(
" <link rel=\"enclosure\" href=\"{enc_url}\" type=\"{mime}\" length=\"{size}\"/>\n"
));
}
xml.push_str(" </entry>\n");
}
xml.push_str("</feed>\n");
xml
}
// =============================================================================
// Hjelpefunksjoner
// =============================================================================
/// XML-escape for tekst i elementer.
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Kort ID fra UUID (første 8 tegn) — for URL-er.
fn short_id(id: Uuid) -> String {
id.to_string()[..8].to_string()
}
/// Trunkér beskrivelse til maks antall tegn (chars, ikke bytes), på ordgrense.
fn truncate_description(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
return s.to_string();
}
// Finn byte-posisjon for max_chars tegn
let byte_end = s.char_indices().nth(max_chars).map(|(i, _)| i).unwrap_or(s.len());
match s[..byte_end].rfind(' ') {
Some(pos) => format!("{}", &s[..pos]),
None => format!("{}", &s[..byte_end]),
}
}