# v1.2.0 Feature Specification ## Overview Version 1.2.0 focuses on three essential improvements to the StarPunk web interface: 1. Custom slug support in the web UI 2. Media upload capability (web UI only, not Micropub) 3. Complete Microformats2 implementation ## Feature 1: Custom Slugs in Web UI ### Current State - Slugs are auto-generated from the first line of content - Custom slugs only possible via Micropub API (mp-slug property) - Web UI has no option to specify custom slugs ### Requirements - Add optional "Slug" field to note creation form - Validate slug format (URL-safe, unique) - If empty, fall back to auto-generation - Support custom slugs in edit form as well ### Design Specification #### Form Updates Location: `templates/admin/new.html` and `templates/admin/edit.html` Add new form field: ```html
URL-safe characters only (lowercase letters, numbers, hyphens) {% if editing %} Slugs cannot be changed after creation to preserve permalinks {% endif %}
``` #### Backend Changes Location: `starpunk/routes/admin.py` Modify `create_note_submit()`: - Extract slug from form data - Pass to `create_note()` as `custom_slug` parameter - Handle validation errors Modify `edit_note_submit()`: - Display current slug as read-only - Do NOT allow slug updates (prevent broken permalinks) #### Validation Rules - Must be URL-safe: `^[a-z0-9-]+$` - Maximum length: 200 characters - Must be unique (database constraint) - Empty string = auto-generate - **Read-only after creation** (no editing allowed) ### Acceptance Criteria - [ ] Slug field appears in create note form - [ ] Slug field appears in edit note form - [ ] Custom slugs are validated for format - [ ] Custom slugs are validated for uniqueness - [ ] Empty field triggers auto-generation - [ ] Error messages are user-friendly --- ## Feature 2: Media Upload (Web UI Only) ### Current State - No media upload capability - Notes are text/markdown only - No file storage infrastructure ### Requirements - Upload images when creating/editing notes - Store uploaded files locally - Display media at top of note (social media style) - Support multiple media per note - Basic file validation - NOT implementing Micropub media endpoint (future version) ### Design Specification #### Conceptual Model Media attachments work like social media posts (Twitter, Mastodon, etc.): - Media is displayed at the TOP of the note when published - Text content appears BELOW the media - Multiple images can be attached to a single note (maximum 4) - Media is stored as attachments, not inline markdown - Display order is upload order (no reordering interface) - Each image can have an optional caption for accessibility #### Storage Architecture ``` data/ media/ 2025/ 01/ image-slug-12345.jpg another-image-67890.png ``` URL Structure: `/media/2025/01/filename.jpg` (date-organized paths) #### Database Schema **Option A: Junction Table (RECOMMENDED)** ```sql -- Media files table CREATE TABLE media ( id INTEGER PRIMARY KEY, filename TEXT NOT NULL, original_name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, size INTEGER NOT NULL, width INTEGER, -- Image dimensions for responsive display height INTEGER, uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Note-media relationship table CREATE TABLE note_media ( id INTEGER PRIMARY KEY, note_id INTEGER NOT NULL, media_id INTEGER NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, caption TEXT, -- Optional alt text/caption created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE, UNIQUE(note_id, media_id) ); CREATE INDEX idx_note_media_note ON note_media(note_id); CREATE INDEX idx_note_media_order ON note_media(note_id, display_order); ``` **Rationale**: Junction table provides flexibility for: - Multiple media per note with ordering - Reusing media across notes (future) - Per-attachment metadata (captions) - Efficient queries for syndication feeds #### Display Strategy **Note Rendering**: ```html
{% if note.media %}
{% if note.media|length == 1 %} {{ media.caption or '' }} {% elif note.media|length == 2 %}
{% for media in note.media %} {{ media.caption or '' }} {% endfor %}
{% else %}
{% for media in note.media[:4] %} {{ media.caption or '' }} {% endfor %}
{% endif %}
{% endif %}
{{ note.html|safe }}
``` #### Upload Flow 1. User selects multiple files via HTML file input 2. Files validated (type, size) 3. Files saved to `data/media/YYYY/MM/` with generated names 4. Database records created in `media` table 5. Associations created in `note_media` table 6. Media displayed as thumbnails below textarea 7. User can remove or reorder attachments #### Form Updates Location: `templates/admin/new.html` and `templates/admin/edit.html` ```html
Accepted formats: JPG, PNG, GIF, WebP (max 10MB each, max 4 images)
``` #### Backend Implementation Location: New module `starpunk/media.py` Key functions: - `validate_media_file(file)` - Check type, size (max 10MB), dimensions (max 4096x4096) - `optimize_image(file)` - Resize if >2048px, correct EXIF orientation (using Pillow) - `save_media_file(file)` - Store optimized version to disk with date-based path - `generate_media_url(filename)` - Create public URL - `track_media_upload(metadata)` - Save to database - `attach_media_to_note(note_id, media_ids, captions)` - Create note-media associations with captions - `get_media_by_note(note_id)` - List media for a note ordered by display_order - `extract_image_dimensions(file)` - Get width/height for storage Image Processing with Pillow: ```python from PIL import Image, ImageOps def optimize_image(file_obj): """Optimize image for web display.""" img = Image.open(file_obj) # Correct EXIF orientation img = ImageOps.exif_transpose(img) # Check dimensions if max(img.size) > 4096: raise ValueError("Image dimensions exceed 4096x4096") # Resize if needed (preserve aspect ratio) if max(img.size) > 2048: img.thumbnail((2048, 2048), Image.Resampling.LANCZOS) return img ``` #### Routes Location: `starpunk/routes/public.py` Add route to serve media: ```python @bp.route('/media///') def serve_media(year, month, filename): # Serve file from data/media/YYYY/MM/ # Set appropriate cache headers ``` Location: `starpunk/routes/admin.py` Add upload endpoint: ```python @bp.route('/admin/upload', methods=['POST']) @require_auth def upload_media(): # Handle AJAX upload, return JSON with URL and media_id # Store in media table, return metadata ``` #### Syndication Feed Support **RSS 2.0 Strategy**: ```xml Note Title

Note text content here...

]]>
...
``` Rationale: RSS `` only supports single items and is meant for podcasts/downloads. HTML in description is standard for blog posts with images. **ATOM 1.0 Strategy**: ```xml Note Title <div class="media"> <img src="https://site.com/media/2025/01/image1.jpg" /> <img src="https://site.com/media/2025/01/image2.jpg" /> </div> <div>Note text content...</div> ``` Rationale: ATOM supports multiple `` elements. We include both enclosures (for feed readers that understand them) AND HTML content (for universal display). **JSON Feed 1.1 Strategy**: ```json { "id": "...", "title": "Note Title", "content_html": "
...
Note text...
", "attachments": [ { "url": "https://site.com/media/2025/01/image1.jpg", "mime_type": "image/jpeg", "size_in_bytes": 123456 }, { "url": "https://site.com/media/2025/01/image2.jpg", "mime_type": "image/jpeg", "size_in_bytes": 234567 } ] } ``` Rationale: JSON Feed has native support for multiple attachments! This is the cleanest implementation. **Feed Generation Updates**: - Modify `generate_rss()` to prepend media HTML to content - Modify `generate_atom()` to add `` elements - Modify `generate_json_feed()` to populate `attachments` array - Query `note_media` JOIN `media` when generating feeds #### Security Considerations - Validate MIME types server-side (JPEG, PNG, GIF, WebP only) - Reject files over 10MB (before processing) - Limit total uploads (4 images max per note) - Sanitize filenames (remove special characters, use slugify) - Prevent directory traversal attacks - Add rate limiting to upload endpoint - Validate image dimensions (max 4096x4096, reject if larger) - Use Pillow to verify file integrity (corrupted files will fail to open) - Resize images over 2048px to prevent memory issues - Strip potentially harmful EXIF data during optimization ### Acceptance Criteria - [ ] Multiple file upload field in create/edit forms - [ ] Images saved to data/media/ directory after optimization - [ ] Media-note associations tracked in database with captions - [ ] Media displayed at TOP of notes - [ ] Text content displayed BELOW media - [ ] Media served at /media/YYYY/MM/filename - [ ] File type validation (JPEG, PNG, GIF, WebP only) - [ ] File size validation (10MB max, checked before processing) - [ ] Image dimension validation (4096x4096 max) - [ ] Automatic resize for images over 2048px - [ ] EXIF orientation correction during processing - [ ] Max 4 images per note enforced - [ ] Caption field for each uploaded image - [ ] Captions used as alt text in HTML - [ ] Media appears in RSS feeds (HTML in description) - [ ] Media appears in ATOM feeds (enclosures + HTML) - [ ] Media appears in JSON feeds (attachments array) - [ ] User can remove attached images - [ ] Display order matches upload order (no reordering UI) - [ ] Error handling for invalid/oversized/corrupted files --- ## Feature 3: Complete Microformats2 Support ### Current State - Basic h-entry on note pages - Basic h-feed on index - Missing h-card (author info) - Missing many microformats properties - No rel=me links ### Requirements Full compliance with Microformats2 specification: - Complete h-entry implementation - Author h-card on all pages - Proper h-feed structure - rel=me for identity verification - All relevant properties marked up ### Design Specification #### Author Discovery System When a user authenticates via IndieAuth, we discover their author information from their profile URL: 1. **Discovery Process** (runs during login): - User logs in with IndieAuth using their domain (e.g., https://user.example.com) - System fetches the user's profile page - Parses h-card microformats from the profile - Extracts: name, photo, bio/note, rel-me links - Caches author info in database (new `author_profile` table) 2. **Database Schema** for Author Profile: ```sql CREATE TABLE author_profile ( id INTEGER PRIMARY KEY, me_url TEXT NOT NULL UNIQUE, -- The IndieAuth 'me' URL name TEXT, -- From h-card p-name photo TEXT, -- From h-card u-photo bio TEXT, -- From h-card p-note rel_me_links TEXT, -- JSON array of rel-me URLs discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` 3. **Caching Strategy**: - Cache on first login - Refresh on each login (but use cache if discovery fails) - Manual refresh button in admin settings - Cache expires after 7 days (configurable) 4. **Fallback Behavior**: - If discovery fails, use cached data if available - If no cache and discovery fails, use minimal defaults: - Name: Domain name (e.g., "user.example.com") - Photo: None (gracefully degrade) - Bio: None - Log discovery failures for debugging #### h-card (Author Information) Location: `templates/partials/author.html` (new) Required properties from discovered profile: - p-name (author name from discovery) - u-url (author URL from ADMIN_ME) - u-photo (avatar from discovery, optional) ```html
{{ author.name or author.me_url }} {% if author.photo %} {{ author.name }} {% endif %} {% if author.bio %}

{{ author.bio }}

{% endif %}
``` #### Enhanced h-entry Location: `templates/note.html` Complete properties with discovered author and media support: - p-name (note title, if exists) - e-content (note content) - dt-published (creation date) - dt-updated (modification date) - u-url (permalink) - p-author (nested h-card with discovered info) - u-uid (unique identifier) - u-photo (multiple for multi-photo posts) - p-category (tags, future) ```html
{% if note.media %} {% for media in note.media %} {{ media.caption or '' }} {% endfor %} {% endif %}
{{ note.html|safe }}
{% if note.has_explicit_title %}

{{ note.title }}

{% endif %}
``` **Multi-photo Implementation Notes**: - Multiple `u-photo` elements indicate a multi-photo post (like Instagram, Twitter) - Photos are considered primary content when present - Consuming applications (like Bridgy) will respect platform limits (e.g., Twitter's 4-photo max) - Photos appear BEFORE text content, matching social media conventions #### Enhanced h-feed Location: `templates/index.html` Required structure: - h-feed container - p-name (feed title) - p-author (feed author) - Multiple h-entry children #### rel=me Links Location: `templates/base.html` Add to using discovered rel-me links: ```html {% if author.rel_me_links %} {% for profile in author.rel_me_links %} {% endfor %} {% endif %} ``` #### Discovery Module Location: New module `starpunk/author_discovery.py` Key functions: - `discover_author_info(me_url)` - Fetch and parse h-card from profile - `parse_hcard(html, url)` - Extract h-card properties - `parse_rel_me(html, url)` - Extract rel-me links - `cache_author_profile(profile_data)` - Store in database - `get_cached_author(me_url)` - Retrieve from cache - `refresh_author_profile(me_url)` - Force refresh Integration points: - Called during IndieAuth login success in `auth_external.py` - Admin settings page for manual refresh (`/admin/settings`) - Template context processor to inject author data globally #### Microformats Parsing Use existing library for parsing: - Option 1: `mf2py` - Python microformats2 parser - Option 2: Custom minimal parser (lighter weight) Parse these specific properties: - h-card properties: name, photo, url, note, email - rel-me links for identity verification - Store as JSON in database for flexibility ### Testing & Validation Use these tools to validate: 1. https://indiewebify.me/ - Complete IndieWeb validation 2. https://microformats.io/ - Microformats parser 3. https://search.google.com/test/rich-results - Google's structured data test ### Acceptance Criteria - [ ] Author info discovered from IndieAuth profile URL - [ ] h-card present within h-entries only (not standalone) - [ ] h-entry has all required properties - [ ] h-feed properly structures the homepage - [ ] rel=me links in HTML head (from discovery) - [ ] Passes indiewebify.me Level 2 tests - [ ] Parsed correctly by microformats.io - [ ] Graceful fallback when discovery fails - [ ] Author profile cached in database - [ ] Manual refresh option in admin --- ## Implementation Order Recommended implementation sequence: 1. **Custom Slugs** (simplest, least dependencies) - Modify forms - Update backend - Test uniqueness 2. **Microformats2** (template-only changes) - Add h-card partial - Enhance h-entry - Add rel=me links - Validate with tools 3. **Media Upload** (most complex) - Create media module - Add upload forms - Implement storage - Add serving route --- ## Out of Scope The following are explicitly NOT included in v1.2.0: - Micropub media endpoint - Video upload support - Thumbnail generation (separate from main image) - CDN integration - Media gallery interface - Webmention support - Multi-user support - Self-hosted IndieAuth (see ADR-056) --- ## Database Schema Changes Required schema changes for v1.2.0: ### 1. Media Tables ```sql -- Media files table CREATE TABLE media ( id INTEGER PRIMARY KEY, filename TEXT NOT NULL, original_name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, size INTEGER NOT NULL, width INTEGER, -- Image dimensions height INTEGER, uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Note-media relationship table CREATE TABLE note_media ( id INTEGER PRIMARY KEY, note_id INTEGER NOT NULL, media_id INTEGER NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, caption TEXT, -- Optional alt text/caption created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE, UNIQUE(note_id, media_id) ); CREATE INDEX idx_note_media_note ON note_media(note_id); CREATE INDEX idx_note_media_order ON note_media(note_id, display_order); ``` ### 2. Author Profile Table ```sql CREATE TABLE author_profile ( id INTEGER PRIMARY KEY, me_url TEXT NOT NULL UNIQUE, name TEXT, photo TEXT, bio TEXT, rel_me_links TEXT, -- JSON array discovered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` ### 3. No Changes Required For: - Custom slugs: Already supported via existing `slug` column --- ## Configuration Changes New configuration variables: ``` # Media settings MAX_UPLOAD_SIZE=10485760 # 10MB in bytes ALLOWED_MEDIA_TYPES=image/jpeg,image/png,image/gif,image/webp MEDIA_PATH=data/media # Storage location # Author discovery settings AUTHOR_CACHE_TTL=604800 # 7 days in seconds AUTHOR_DISCOVERY_TIMEOUT=5.0 # HTTP timeout for profile fetch ``` Note: Author information is NOT configured via environment variables. It is discovered from the authenticated user's IndieAuth profile URL. --- ## Security Considerations 1. **File Upload Security** - Validate MIME types - Check file extensions - Limit file sizes - Sanitize filenames - Store outside web root if possible 2. **Slug Validation** - Prevent directory traversal - Enforce URL-safe characters - Check uniqueness 3. **Microformats** - No security implications - Ensure proper HTML escaping continues --- ## Testing Requirements ### Unit Tests - Slug validation logic - Media file validation - Unique filename generation ### Integration Tests - Custom slug creation flow - Media upload and serving - Microformats parsing ### Manual Testing - Upload various image formats - Try invalid slugs - Validate microformats output - Test with screen readers --- ## Additional Design Considerations ### Media Upload Details 1. **Social Media Model**: Media works like Twitter/Mastodon posts - Media displays at TOP of note - Text appears BELOW media - Multiple images supported (max 4) - No inline markdown images (attachments only) - Display order is upload order (no reordering) 2. **File Type Restrictions**: - Accept: image/jpeg, image/png, image/gif, image/webp - Reject: SVG (security), video formats (v1.2.0 scope) - Validate MIME type server-side, not just extension 3. **Image Processing** (using Pillow): - Automatic resize if >2048px (longest edge) - EXIF orientation correction - File integrity validation - Preserve aspect ratio - Quality setting: 95 (high quality) - No separate thumbnail generation 4. **Display Layout**: - 1 image: Full width - 2 images: Side by side (50% each) - 3 images: Grid (1 large + 2 small, or equal grid) - 4 images: 2x2 grid 5. **Image Limits** (per ADR-058): - Max file size: 10MB per image - Max dimensions: 4096x4096 pixels - Auto-resize threshold: 2048 pixels (longest edge) - Max images per note: 4 6. **Accessibility Features**: - Optional caption field for each image - Captions stored in `note_media.caption` - Used as alt text in HTML output - Included in syndication feeds 7. **Database Design Rationale**: - Junction table allows flexible ordering - Supports future media reuse across notes - Per-attachment captions for accessibility - Efficient queries for feed generation 8. **Feed Syndication Strategy**: - RSS: HTML with images in description (universal support) - ATOM: Both enclosures AND HTML content (best compatibility) - JSON Feed: Native attachments array (cleanest implementation) ### Slug Handling 1. **Absolute No-Edit Policy**: Once created, slugs are immutable - No admin override - No database updates allowed - Prevents broken permalinks completely 2. **Validation Pattern**: `^[a-z0-9-]+$` - Lowercase only for consistency - No underscores (hyphens preferred) - No special characters ### Author Discovery Edge Cases 1. **Multiple h-cards on Profile**: - Use first representative h-card (class="h-card" on body or first found) - Log if multiple found for debugging 2. **Missing Properties**: - Name: Falls back to domain - Photo: Omit if not found - Bio: Omit if not found - All properties are optional except URL 3. **Network Failures**: - Use cached data even if expired - Log failure for monitoring - Never block login due to discovery failure 4. **Invalid Markup**: - Best-effort parsing - Log parsing errors - Use whatever can be extracted ## Success Metrics v1.2.0 is successful when: 1. Users can specify custom slugs via web UI (immutable after creation) 2. Users can upload images via web UI with auto-insertion 3. Author info discovered from IndieAuth profile 4. Site passes IndieWebify.me Level 2 5. All existing tests continue to pass 6. No regression in existing functionality 7. Media tracked in database with metadata 8. Graceful handling of discovery failures