feat: v1.2.0-rc.1 - IndieWeb Features Release Candidate

Complete implementation of v1.2.0 "IndieWeb Features" release.

## Phase 1: Custom Slugs
- Optional custom slug field in note creation form
- Auto-sanitization (lowercase, hyphens only)
- Uniqueness validation with auto-numbering
- Read-only after creation to preserve permalinks
- Matches Micropub mp-slug behavior

## Phase 2: Author Discovery + Microformats2
- Automatic h-card discovery from IndieAuth identity URL
- 24-hour caching with graceful fallback
- Never blocks login (per ADR-061)
- Complete h-entry, h-card, h-feed markup
- All required Microformats2 properties
- rel-me links for identity verification
- Passes IndieWeb validation

## Phase 3: Media Upload
- Upload up to 4 images per note (JPEG, PNG, GIF, WebP)
- Automatic optimization with Pillow
  - Auto-resize to 2048px
  - EXIF orientation correction
  - 95% quality compression
- Social media-style layout (media top, text below)
- Optional captions for accessibility
- Integration with all feed formats (RSS, ATOM, JSON Feed)
- Date-organized storage with UUID filenames
- Immutable caching (1 year)

## Database Changes
- migrations/006_add_author_profile.sql - Author discovery cache
- migrations/007_add_media_support.sql - Media storage

## New Modules
- starpunk/author_discovery.py - h-card discovery and caching
- starpunk/media.py - Image upload, validation, optimization

## Documentation
- 4 new ADRs (056, 057, 058, 061)
- Complete design specifications
- Developer Q&A with 40+ questions answered
- 3 implementation reports
- 3 architect reviews (all approved)

## Testing
- 56 new tests for v1.2.0 features
- 842 total tests in suite
- All v1.2.0 feature tests passing

## Dependencies
- Added: mf2py (Microformats2 parser)
- Added: Pillow (image processing)

Version: 1.2.0-rc.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-28 15:02:20 -07:00
parent 83739ec2c6
commit dd822a35b5
40 changed files with 6929 additions and 15 deletions

View File

@@ -75,23 +75,72 @@ def create_note_submit():
Form data:
content: Markdown content (required)
published: Checkbox for published status (optional)
custom_slug: Optional custom slug (v1.2.0 Phase 1)
media_files: Multiple file upload (v1.2.0 Phase 3)
captions[]: Captions for each media file (v1.2.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
from starpunk.media import save_media, attach_media_to_note
content = request.form.get("content", "").strip()
published = "published" in request.form
custom_slug = request.form.get("custom_slug", "").strip()
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.new_note_form"))
try:
note = create_note(content, published=published)
# Create note first (per Q4)
note = create_note(
content,
published=published,
custom_slug=custom_slug if custom_slug else None
)
# Handle media uploads (v1.2.0 Phase 3)
media_files = request.files.getlist('media_files')
captions = request.form.getlist('captions[]')
if media_files and any(f.filename for f in media_files):
# Per Q35: Accept valid, reject invalid (not atomic)
media_ids = []
errors = []
for i, file in enumerate(media_files):
if not file.filename:
continue
try:
# Read file data
file_data = file.read()
# Save and optimize media
media_info = save_media(file_data, file.filename)
media_ids.append(media_info['id'])
except ValueError as e:
errors.append(f"{file.filename}: {str(e)}")
except Exception as e:
errors.append(f"{file.filename}: Upload failed")
if media_ids:
# Ensure captions list matches media_ids length
while len(captions) < len(media_ids):
captions.append('')
# Attach media to note
attach_media_to_note(note.id, media_ids, captions[:len(media_ids)])
if errors:
flash(f"Note created, but some images failed: {'; '.join(errors)}", "warning")
flash(f"Note created: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
flash(f"Error creating note: {e}", "error")
return redirect(url_for("admin.new_note_form"))

View File

@@ -8,7 +8,7 @@ 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 flask import Blueprint, abort, render_template, Response, current_app, request, send_from_directory
from starpunk.notes import list_notes, get_note
from starpunk.feed import generate_feed_streaming # Legacy RSS
@@ -40,11 +40,13 @@ 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.
from database and updates cache. Includes media for each note.
Returns:
List of published notes for feed generation
List of published notes for feed generation (with media attached)
"""
from starpunk.media import get_note_media
# Get cache duration from config (in seconds)
cache_seconds = current_app.config.get("FEED_CACHE_SECONDS", 300)
cache_duration = timedelta(seconds=cache_seconds)
@@ -60,6 +62,12 @@ def _get_cached_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)
# Attach media to each note (v1.2.0 Phase 3)
for note in notes:
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
_feed_cache["notes"] = notes
_feed_cache["timestamp"] = now
@@ -158,6 +166,56 @@ def _generate_feed_with_cache(format_name: str, non_streaming_generator):
return response
@bp.route('/media/<path:path>')
def media_file(path):
"""
Serve media files
Per Q10: Set cache headers for media
Per Q26: Absolute URLs in feeds constructed from this route
Args:
path: Relative path to media file (YYYY/MM/filename.ext)
Returns:
File response with caching headers
Raises:
404: If file not found
Headers:
Cache-Control: public, max-age=31536000, immutable
Examples:
>>> response = client.get('/media/2025/01/uuid.jpg')
>>> response.status_code
200
>>> response.headers['Cache-Control']
'public, max-age=31536000, immutable'
"""
from pathlib import Path
media_dir = Path(current_app.config.get('DATA_PATH', 'data')) / 'media'
# Validate path is safe (prevent directory traversal)
try:
# Resolve path and ensure it's under media_dir
requested_path = (media_dir / path).resolve()
if not str(requested_path).startswith(str(media_dir.resolve())):
abort(404)
except (ValueError, OSError):
abort(404)
# Serve file with cache headers
response = send_from_directory(media_dir, path)
# Cache for 1 year (immutable content)
# Media files are UUID-named, so changing content = new URL
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
@bp.route("/")
def index():
"""
@@ -192,6 +250,8 @@ def note(slug: str):
Template: templates/note.html
Microformats: h-entry
"""
from starpunk.media import get_note_media
# Get note by slug
note_obj = get_note(slug=slug)
@@ -199,6 +259,13 @@ def note(slug: str):
if not note_obj or not note_obj.published:
abort(404)
# Get media for note (v1.2.0 Phase 3)
media = get_note_media(note_obj.id)
# Attach media to note object for template
# Use object.__setattr__ since Note is frozen dataclass
object.__setattr__(note_obj, 'media', media)
return render_template("note.html", note=note_obj)