""" 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, generate_rss_streaming, generate_atom, generate_atom_streaming, generate_json_feed, generate_json_feed_streaming, negotiate_feed_format, get_mime_type, get_cache, generate_opml, ) # 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 is now cached via FeedCache (Phase 3) # 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 def _generate_feed_with_cache(format_name: str, non_streaming_generator): """ Generate feed with caching and ETag support. Implements Phase 3 feed caching: - Checks If-None-Match header for conditional requests - Uses FeedCache for content caching - Returns 304 Not Modified when appropriate - Adds ETag header to all responses Args: format_name: Feed format (rss, atom, json) non_streaming_generator: Function that returns full feed content (not streaming) Returns: Flask Response with appropriate headers and status """ # Get cached notes notes = _get_cached_notes() # Check if caching is enabled cache_enabled = current_app.config.get("FEED_CACHE_ENABLED", True) if not cache_enabled: # Caching disabled, generate fresh feed max_items = current_app.config.get("FEED_MAX_ITEMS", 50) cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) # Generate feed content (non-streaming) content = non_streaming_generator( 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, ) response = Response(content, mimetype=get_mime_type(format_name)) response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response # Caching enabled - use FeedCache feed_cache = get_cache() notes_checksum = feed_cache.generate_notes_checksum(notes) # Check If-None-Match header for conditional requests if_none_match = request.headers.get('If-None-Match') # Try to get cached feed cached_result = feed_cache.get(format_name, notes_checksum) if cached_result: content, etag = cached_result # Check if client has current version if if_none_match and if_none_match == etag: # Client has current version, return 304 Not Modified response = Response(status=304) response.headers["ETag"] = etag return response # Return cached content with ETag response = Response(content, mimetype=get_mime_type(format_name)) response.headers["ETag"] = etag cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response # Cache miss - generate fresh feed max_items = current_app.config.get("FEED_MAX_ITEMS", 50) # Generate feed content (non-streaming) content = non_streaming_generator( 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, ) # Store in cache and get ETag etag = feed_cache.set(format_name, content, notes_checksum) # Return fresh content with ETag response = Response(content, mimetype=get_mime_type(format_name)) response.headers["ETag"] = etag cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response @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 (with caching) Generates standards-compliant RSS 2.0 feed with Phase 3 caching: - LRU cache with TTL (default 5 minutes) - ETag support for conditional requests - 304 Not Modified responses - SHA-256 checksums Returns: Cached or fresh RSS 2.0 feed response Headers: Content-Type: application/rss+xml; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} ETag: W/"sha256_hash" Caching Strategy: - Database query cached (note list) - Feed content cached (full XML) - Conditional requests (If-None-Match) - Cache invalidation on content changes Examples: >>> response = client.get('/feed.rss') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/rss+xml; charset=utf-8' >>> response.headers['ETag'] 'W/"abc123..."' >>> # Conditional request >>> response = client.get('/feed.rss', headers={'If-None-Match': 'W/"abc123..."'}) >>> response.status_code 304 """ return _generate_feed_with_cache('rss', generate_rss) @bp.route("/feed.atom") def feed_atom(): """ Explicit ATOM 1.0 feed endpoint (with caching) Generates standards-compliant ATOM 1.0 feed with Phase 3 caching. Follows RFC 4287 specification for ATOM syndication format. Returns: Cached or fresh ATOM 1.0 feed response Headers: Content-Type: application/atom+xml; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} ETag: W/"sha256_hash" Examples: >>> response = client.get('/feed.atom') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/atom+xml; charset=utf-8' >>> response.headers['ETag'] 'W/"abc123..."' """ return _generate_feed_with_cache('atom', generate_atom) @bp.route("/feed.json") def feed_json(): """ Explicit JSON Feed 1.1 endpoint (with caching) Generates standards-compliant JSON Feed 1.1 feed with Phase 3 caching. Follows JSON Feed specification (https://jsonfeed.org/version/1.1). Returns: Cached or fresh JSON Feed 1.1 response Headers: Content-Type: application/feed+json; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} ETag: W/"sha256_hash" Examples: >>> response = client.get('/feed.json') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/feed+json; charset=utf-8' >>> response.headers['ETag'] 'W/"abc123..."' """ return _generate_feed_with_cache('json', generate_json_feed) @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() @bp.route("/opml.xml") def opml(): """ OPML 2.0 feed subscription list endpoint (Phase 3) Generates OPML 2.0 document listing all available feed formats. Feed readers can import this file to subscribe to all feeds at once. Per v1.1.2 Phase 3: - OPML 2.0 compliant - Lists RSS, ATOM, and JSON Feed formats - Public access (no authentication required per CQ8) - Enables easy multi-feed subscription Returns: OPML 2.0 XML document Headers: Content-Type: application/xml; charset=utf-8 Cache-Control: public, max-age={FEED_CACHE_SECONDS} Examples: >>> response = client.get('/opml.xml') >>> response.status_code 200 >>> response.headers['Content-Type'] 'application/xml; charset=utf-8' >>> b'' in response.data True Standards: - OPML 2.0: http://opml.org/spec2.opml """ # Generate OPML content opml_content = generate_opml( site_url=current_app.config["SITE_URL"], site_name=current_app.config["SITE_NAME"], ) # Create response response = Response(opml_content, mimetype="application/xml") # Add cache headers (same as feed cache duration) cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300) response.headers["Cache-Control"] = f"public, max-age={cache_seconds}" return response