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
148 lines
4.3 KiB
Python
148 lines
4.3 KiB
Python
"""
|
|
Public routes for StarPunk
|
|
|
|
Handles public-facing pages including homepage and note permalinks.
|
|
No authentication required for these routes.
|
|
"""
|
|
|
|
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():
|
|
"""
|
|
Homepage displaying recent published notes
|
|
|
|
Returns:
|
|
Rendered homepage template with note list
|
|
|
|
Template: templates/index.html
|
|
Microformats: h-feed containing h-entry items
|
|
"""
|
|
# Get recent published notes (limit 20)
|
|
notes = list_notes(published_only=True, limit=20)
|
|
|
|
return render_template("index.html", notes=notes)
|
|
|
|
|
|
@bp.route("/note/<slug>")
|
|
def note(slug: str):
|
|
"""
|
|
Individual note permalink page
|
|
|
|
Args:
|
|
slug: URL-safe note identifier
|
|
|
|
Returns:
|
|
Rendered note template with full content
|
|
|
|
Raises:
|
|
404: If note not found or not published
|
|
|
|
Template: templates/note.html
|
|
Microformats: h-entry
|
|
"""
|
|
# Get note by slug
|
|
note_obj = get_note(slug=slug)
|
|
|
|
# Return 404 if note doesn't exist or isn't published
|
|
if not note_obj or not note_obj.published:
|
|
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
|