""" 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, request from starpunk.notes import list_notes, get_note from starpunk.feed import generate_feed_streaming # Legacy RSS from starpunk.feeds import ( generate_rss_streaming, generate_atom_streaming, generate_json_feed_streaming, negotiate_feed_format, get_mime_type, ) # Create blueprint bp = Blueprint("public", __name__) # Simple in-memory cache for feed note list # Caches the database query results to avoid repeated DB hits # Feed content (XML/JSON) is streamed, not cached (memory optimization) # Structure: {'notes': list[Note], 'timestamp': datetime} _feed_cache = {"notes": None, "timestamp": None} def _get_cached_notes(): """ Get cached note list or fetch fresh notes Returns cached notes if still valid, otherwise fetches fresh notes from database and updates cache. Returns: List of published notes for feed generation """ # 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 note list cache is valid if _feed_cache["notes"] and _feed_cache["timestamp"]: cache_age = now - _feed_cache["timestamp"] if cache_age < cache_duration: # Use cached note list return _feed_cache["notes"] # Cache expired or empty, fetch fresh notes max_items = current_app.config.get("FEED_MAX_ITEMS", 50) notes = list_notes(published_only=True, limit=max_items) _feed_cache["notes"] = notes _feed_cache["timestamp"] = now return notes @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") def feed(): """ Content negotiation endpoint for feeds Serves feed in format based on HTTP Accept header: - application/rss+xml → RSS 2.0 - application/atom+xml → ATOM 1.0 - application/feed+json or application/json → JSON Feed 1.1 - */* → RSS 2.0 (default) If no acceptable format is available, returns 406 Not Acceptable with X-Available-Formats header listing supported formats. Returns: Streaming feed response in negotiated format, or 406 error Headers: Content-Type: Varies by format Cache-Control: public, max-age={FEED_CACHE_SECONDS} X-Available-Formats: List of supported formats (on 406 error only) Examples: >>> # Request with Accept: application/atom+xml >>> response = client.get('/feed', headers={'Accept': 'application/atom+xml'}) >>> response.headers['Content-Type'] 'application/atom+xml; charset=utf-8' >>> # Request with no Accept header (defaults to RSS) >>> response = client.get('/feed') >>> response.headers['Content-Type'] 'application/rss+xml; charset=utf-8' """ # Get Accept header accept = request.headers.get('Accept', '*/*') # Negotiate format available_formats = ['rss', 'atom', 'json'] try: format_name = negotiate_feed_format(accept, available_formats) except ValueError: # No acceptable format - return 406 return ( "Not Acceptable. Supported formats: application/rss+xml, application/atom+xml, application/feed+json", 406, { 'Content-Type': 'text/plain; charset=utf-8', 'X-Available-Formats': 'application/rss+xml, application/atom+xml, application/feed+json', } ) # Route to appropriate generator if format_name == 'rss': return feed_rss() elif format_name == 'atom': return feed_atom() elif format_name == 'json': return feed_json() else: # Shouldn't reach here, but be defensive return feed_rss() @bp.route("/feed.rss") def feed_rss(): """ Explicit RSS 2.0 feed endpoint Generates standards-compliant RSS 2.0 feed using memory-efficient streaming. Instead of building the entire feed in memory, yields XML chunks directly to the client for optimal memory usage with large feeds. Cache duration is configurable via FEED_CACHE_SECONDS (default: 300 seconds = 5 minutes). Cache stores note list to avoid repeated database queries, but streaming prevents holding full XML in memory. Returns: Streaming RSS 2.0 feed response Headers: Content-Type: application/rss+xml; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} Streaming Strategy: - Database query cached (avoid repeated DB hits) - XML generation streamed (avoid full XML in memory) - Client-side: Cache-Control header with max-age Performance: - Memory usage: O(1) instead of O(n) for feed size - Latency: Lower time-to-first-byte (TTFB) - Recommended for feeds with 100+ items Examples: >>> response = client.get('/feed.rss') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/rss+xml; charset=utf-8' """ # Get cached notes notes = _get_cached_notes() # Get cache duration for response header cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) # Generate streaming RSS feed max_items = current_app.config.get("FEED_MAX_ITEMS", 50) generator = generate_rss_streaming( 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, ) # Return streaming response with appropriate headers response = Response(generator, mimetype="application/rss+xml; charset=utf-8") response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response @bp.route("/feed.atom") def feed_atom(): """ Explicit ATOM 1.0 feed endpoint Generates standards-compliant ATOM 1.0 feed using memory-efficient streaming. Follows RFC 4287 specification for ATOM syndication format. Returns: Streaming ATOM 1.0 feed response Headers: Content-Type: application/atom+xml; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} Examples: >>> response = client.get('/feed.atom') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/atom+xml; charset=utf-8' """ # Get cached notes notes = _get_cached_notes() # Get cache duration for response header cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) # Generate streaming ATOM feed max_items = current_app.config.get("FEED_MAX_ITEMS", 50) generator = generate_atom_streaming( 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, ) # Return streaming response with appropriate headers response = Response(generator, mimetype="application/atom+xml; charset=utf-8") response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response @bp.route("/feed.json") def feed_json(): """ Explicit JSON Feed 1.1 endpoint Generates standards-compliant JSON Feed 1.1 feed using memory-efficient streaming. Follows JSON Feed specification (https://jsonfeed.org/version/1.1). Returns: Streaming JSON Feed 1.1 response Headers: Content-Type: application/feed+json; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} Examples: >>> response = client.get('/feed.json') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/feed+json; charset=utf-8' """ # Get cached notes notes = _get_cached_notes() # Get cache duration for response header cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) # Generate streaming JSON Feed max_items = current_app.config.get("FEED_MAX_ITEMS", 50) generator = generate_json_feed_streaming( 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, ) # Return streaming response with appropriate headers response = Response(generator, mimetype="application/feed+json; charset=utf-8") response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response @bp.route("/feed.xml") def feed_xml_legacy(): """ Legacy RSS 2.0 feed endpoint (backward compatibility) Maintains backward compatibility for /feed.xml endpoint. New code should use /feed.rss or /feed with content negotiation. Returns: Streaming RSS 2.0 feed response See feed_rss() for full documentation. """ # Use the new RSS endpoint return feed_rss()