//! RSS feed endpoints. No CSRF, no sessions. use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Response}, routing::get, Router, }; use serde::Deserialize; use crate::{ constants, db::{self, Slug, UserId, Username}, error::{AppError, Result}, helpers, rss::{self, FeedItem}, AppState, }; /// Register RSS feed routes. pub fn feed_routes() -> Router { Router::new() .route("/u/{username}/rss", get(user_rss_feed)) .route("/p/{slug}/rss", get(project_rss_feed)) .route("/p/{slug}/blog/feed.xml", get(project_blog_rss)) .route("/changelog/feed.xml", get(changelog_rss)) .route("/feed/{user_id}", get(personal_feed)) } /// Render an RSS 2.0 feed for a creator's public items. /// /// GET /u/{username}/rss #[tracing::instrument(skip_all, name = "feeds::user_rss_feed")] async fn user_rss_feed( State(state): State, Path(username): Path, ) -> Result { let username = Username::new(&username).map_err(|_| AppError::NotFound)?; let db_user = db::users::get_user_by_username(&state.db, &username) .await? .ok_or(AppError::NotFound)?; if db_user.is_sandbox { return Err(AppError::NotFound); } // Single joined query instead of O(projects) loop let db_items = db::items::get_public_items_by_user(&state.db, db_user.id).await?; let feed_items: Vec = db_items .into_iter() .map(|item| FeedItem { title: item.title, link: format!("{}/i/{}", state.config.host_url, item.id), description: item.description.unwrap_or_default(), pub_date: item.created_at, guid: item.id.to_string(), }) .collect(); let display_name = db_user .display_name .as_deref() .unwrap_or(&db_user.username); let bio = db_user.bio.as_deref().unwrap_or(""); let xml = rss::render_creator_feed( display_name, &db_user.username, bio, &feed_items, &state.config.host_url, ); Ok(( [( axum::http::header::CONTENT_TYPE, "application/rss+xml; charset=utf-8", )], xml, ) .into_response()) } /// Render an RSS 2.0 feed for a project's public items. /// /// GET /p/{slug}/rss #[tracing::instrument(skip_all, name = "feeds::project_rss_feed")] async fn project_rss_feed( State(state): State, Path(slug): Path, ) -> Result { let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; if db_user.is_sandbox { return Err(AppError::NotFound); } let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?; let feed_items: Vec = db_items .into_iter() .map(|item| FeedItem { title: item.title, link: format!("{}/i/{}", state.config.host_url, item.id), description: item.description.unwrap_or_default(), pub_date: item.created_at, guid: item.id.to_string(), }) .collect(); let xml = rss::render_project_feed( &db_project.title, &db_project.slug, db_project.description.as_deref().unwrap_or(""), &db_user.username, &feed_items, &state.config.host_url, ); Ok(( [( axum::http::header::CONTENT_TYPE, "application/rss+xml; charset=utf-8", )], xml, ) .into_response()) } /// RSS feed for a project's blog posts. #[tracing::instrument(skip_all, name = "feeds::project_blog_rss")] async fn project_blog_rss( State(state): State, Path(slug): Path, ) -> Result { let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; if db_user.is_sandbox { return Err(AppError::NotFound); } let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?; let feed_items: Vec = db_posts .into_iter() .map(|post| FeedItem { title: post.title, link: format!("{}/p/{}/blog/{}", state.config.host_url, db_project.slug, post.slug), description: post.body_markdown.chars().take(300).collect::(), pub_date: post.published_at.unwrap_or(post.created_at), guid: post.id.to_string(), }) .collect(); let xml = rss::render_blog_feed( &db_project.title, &db_project.slug, db_project.description.as_deref().unwrap_or(""), &db_user.username, &feed_items, &state.config.host_url, ); Ok(( [( axum::http::header::CONTENT_TYPE, "application/rss+xml; charset=utf-8", )], xml, ) .into_response()) } /// Platform changelog RSS feed (alias for the "changelog" project blog feed). /// /// Mirrors the `/changelog` page alias in `blog.rs`: same project lookup, same /// rendering, so subscribers can follow the canonical `/changelog/feed.xml` /// path instead of discovering the underlying `/p/changelog/blog/feed.xml`. #[tracing::instrument(skip_all, name = "feeds::changelog_rss")] async fn changelog_rss(State(state): State) -> Result { // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a // compile-time constant (`&'static str`), not user input. let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned()); let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; if db_user.is_sandbox { return Err(AppError::NotFound); } let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?; let feed_items: Vec = db_posts .into_iter() .map(|post| FeedItem { title: post.title, link: format!("{}/changelog/{}", state.config.host_url, post.slug), description: post.body_markdown.chars().take(300).collect::(), pub_date: post.published_at.unwrap_or(post.created_at), guid: post.id.to_string(), }) .collect(); let xml = rss::render_blog_feed( &db_project.title, &db_project.slug, db_project.description.as_deref().unwrap_or(""), &db_user.username, &feed_items, &state.config.host_url, ); Ok(( [( axum::http::header::CONTENT_TYPE, "application/rss+xml; charset=utf-8", )], xml, ) .into_response()) } /// Query parameters for the personal feed. #[derive(Debug, Deserialize)] struct FeedQuery { sig: String, /// Feed key version the URL was signed for. Defaults to 0 for URLs minted /// before per-user versioning existed; the handler still rejects it unless /// it matches the user's current `feed_key_version`. #[serde(default)] v: i32, } /// Personalized RSS feed of items from followed users and projects. /// /// GET /feed/{user_id}?sig={hmac_signature} /// /// Auth is via HMAC signature in the query string, so RSS readers /// can fetch the feed without cookies or headers. #[tracing::instrument(skip_all, name = "feeds::personal_feed")] async fn personal_feed( State(state): State, Path(user_id): Path, Query(query): Query, ) -> Result { // Verify HMAC signature (cheap, no DB) before touching the database. if !helpers::verify_feed_signature(user_id, query.v, &query.sig, &state.config.signing_secret) { return Err(AppError::Forbidden); } // Verify the user exists let db_user = db::users::get_user_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; // Enforce key rotation: a signature valid for a stale version is a revoked // URL (the user hit "Regenerate feed URL"). The HMAC above proves the `v` // wasn't forged; this proves it's still current. if query.v != db_user.feed_key_version { return Err(AppError::Forbidden); } // Get items from followed users and projects let db_items = db::follows::get_followed_items(&state.db, user_id).await?; let feed_items: Vec = db_items .into_iter() .map(|item| FeedItem { title: item.title, link: format!("{}/i/{}", state.config.host_url, item.id), description: item.description.unwrap_or_default(), pub_date: item.created_at, guid: item.id.to_string(), }) .collect(); let display_name = db_user .display_name .as_deref() .unwrap_or(&db_user.username); let title = format!("{}'s Feed", display_name); let xml = rss::render_feed_custom( &title, &format!("{}/feed/{}", state.config.host_url, user_id), "New content from creators and projects you follow", &feed_items, ); Ok(( [( axum::http::header::CONTENT_TYPE, "application/rss+xml; charset=utf-8", )], xml, ) .into_response()) }