""" 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/") 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