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
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user