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:
@@ -62,7 +62,11 @@ def load_config(app, config_override=None):
|
|||||||
app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "")
|
app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "")
|
||||||
|
|
||||||
# Application version
|
# 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
|
# Apply overrides if provided
|
||||||
if config_override:
|
if config_override:
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ Handles public-facing pages including homepage and note permalinks.
|
|||||||
No authentication required for these routes.
|
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.notes import list_notes, get_note
|
||||||
|
from starpunk.feed import generate_feed
|
||||||
|
|
||||||
# Create blueprint
|
# Create blueprint
|
||||||
bp = Blueprint("public", __name__)
|
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("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
@@ -55,3 +63,85 @@ def note(slug: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
return render_template("note.html", note=note_obj)
|
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