From d420269bc0b4ae96cd99667316ccdf6e901469b3 Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Wed, 19 Nov 2025 08:42:32 -0700 Subject: [PATCH] feat: add RSS feed endpoint and configuration Implements /feed.xml route with caching and ETag support. Features: - GET /feed.xml returns RSS 2.0 feed of published notes - Server-side caching (5 minutes default, configurable) - ETag generation for conditional requests - Cache-Control headers for client-side caching - Configurable feed item limit (50 default) Configuration: - FEED_MAX_ITEMS: Maximum items in feed (default: 50) - FEED_CACHE_SECONDS: Cache duration in seconds (default: 300) Related: docs/decisions/ADR-014-rss-feed-implementation.md --- starpunk/config.py | 6 ++- starpunk/routes/public.py | 92 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/starpunk/config.py b/starpunk/config.py index b8269de..063f1c8 100644 --- a/starpunk/config.py +++ b/starpunk/config.py @@ -62,7 +62,11 @@ def load_config(app, config_override=None): app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "") # Application version - app.config["VERSION"] = os.getenv("VERSION", "0.5.0") + app.config["VERSION"] = os.getenv("VERSION", "0.6.0") + + # RSS feed configuration + app.config["FEED_MAX_ITEMS"] = int(os.getenv("FEED_MAX_ITEMS", "50")) + app.config["FEED_CACHE_SECONDS"] = int(os.getenv("FEED_CACHE_SECONDS", "300")) # Apply overrides if provided if config_override: diff --git a/starpunk/routes/public.py b/starpunk/routes/public.py index 45b001c..d178d71 100644 --- a/starpunk/routes/public.py +++ b/starpunk/routes/public.py @@ -5,13 +5,21 @@ Handles public-facing pages including homepage and note permalinks. No authentication required for these routes. """ -from flask import Blueprint, abort, render_template +import hashlib +from datetime import datetime, timedelta + +from flask import Blueprint, abort, render_template, Response, current_app from starpunk.notes import list_notes, get_note +from starpunk.feed import generate_feed # Create blueprint bp = Blueprint("public", __name__) +# Simple in-memory cache for RSS feed +# Structure: {'xml': str, 'timestamp': datetime, 'etag': str} +_feed_cache = {"xml": None, "timestamp": None, "etag": None} + @bp.route("/") def index(): @@ -55,3 +63,85 @@ def note(slug: str): abort(404) return render_template("note.html", note=note_obj) + + +@bp.route("/feed.xml") +def feed(): + """ + RSS 2.0 feed of published notes + + Generates standards-compliant RSS 2.0 feed with server-side caching + and ETag support for conditional requests. Cache duration is + configurable via FEED_CACHE_SECONDS (default: 300 seconds = 5 minutes). + + Returns: + XML response with RSS feed + + Headers: + Content-Type: application/rss+xml; charset=utf-8 + Cache-Control: public, max-age={FEED_CACHE_SECONDS} + ETag: MD5 hash of feed content + + Caching Strategy: + - Server-side: In-memory cache for configured duration + - Client-side: Cache-Control header with max-age + - Conditional: ETag support for efficient updates + + Examples: + >>> # First request: generates and caches feed + >>> response = client.get('/feed.xml') + >>> response.status_code + 200 + >>> response.headers['Content-Type'] + 'application/rss+xml; charset=utf-8' + + >>> # Subsequent requests within cache window: returns cached feed + >>> response = client.get('/feed.xml') + >>> response.headers['ETag'] + 'abc123...' + """ + # Get cache duration from config (in seconds) + cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) + cache_duration = timedelta(seconds=cache_seconds) + now = datetime.utcnow() + + # Check if cache is valid + if _feed_cache["xml"] and _feed_cache["timestamp"]: + cache_age = now - _feed_cache["timestamp"] + if cache_age < cache_duration: + # Cache is still valid, return cached feed + response = Response( + _feed_cache["xml"], mimetype="application/rss+xml; charset=utf-8" + ) + response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" + response.headers["ETag"] = _feed_cache["etag"] + return response + + # Cache expired or empty, generate fresh feed + # Get published notes (limit from config) + max_items = current_app.config.get("FEED_MAX_ITEMS", 50) + notes = list_notes(published_only=True, limit=max_items) + + # Generate RSS feed + feed_xml = generate_feed( + site_url=current_app.config["SITE_URL"], + site_name=current_app.config["SITE_NAME"], + site_description=current_app.config.get("SITE_DESCRIPTION", ""), + notes=notes, + limit=max_items, + ) + + # Calculate ETag (MD5 hash of feed content) + etag = hashlib.md5(feed_xml.encode("utf-8")).hexdigest() + + # Update cache + _feed_cache["xml"] = feed_xml + _feed_cache["timestamp"] = now + _feed_cache["etag"] = etag + + # Return response with appropriate headers + response = Response(feed_xml, mimetype="application/rss+xml; charset=utf-8") + response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" + response.headers["ETag"] = etag + + return response