//! RSS feed endpoints. No CSRF, no sessions. use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Response}, routing::get, Router, }; use serde::Deserialize; use crate::{ 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("/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()) } /// Query parameters for the personal feed. #[derive(Debug, Deserialize)] struct FeedQuery { sig: String, } /// 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 if !helpers::verify_feed_signature(user_id, &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)?; // 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()) }