Files
StarPunk/docs/design/v1.2.0/media-implementation-guide.md
Phil Skentelbery dd822a35b5 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>
2025-11-28 15:02:20 -07:00

7.8 KiB

Media Upload Implementation Guide

Overview

This guide provides implementation details for the v1.2.0 media upload feature based on the finalized design.

Key Design Decisions

Image Limits (per ADR-058)

  • Max file size: 10MB per image (reject before processing)
  • Max dimensions: 4096x4096 pixels (reject if larger)
  • Auto-resize threshold: 2048 pixels on longest edge
  • Max images per note: 4
  • Accepted formats: JPEG, PNG, GIF, WebP only

Features

  • Caption support: Each image has optional caption field
  • No reordering: Display order matches upload order
  • Auto-optimization: Images >2048px automatically resized
  • EXIF correction: Orientation fixed during processing

Implementation Approach

1. Dependencies

Add to pyproject.toml:

dependencies = [
    # ... existing dependencies
    "Pillow>=10.0.0",  # Image processing
]

2. Image Processing Module Structure

Create starpunk/media.py:

from PIL import Image, ImageOps
import hashlib
import os
from pathlib import Path
from datetime import datetime

class MediaProcessor:
    MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
    MAX_DIMENSIONS = 4096
    RESIZE_THRESHOLD = 2048
    ALLOWED_MIMES = {
        'image/jpeg': '.jpg',
        'image/png': '.png',
        'image/gif': '.gif',
        'image/webp': '.webp'
    }

    def validate_file_size(self, file_obj):
        """Check file size before processing."""
        file_obj.seek(0, os.SEEK_END)
        size = file_obj.tell()
        file_obj.seek(0)

        if size > self.MAX_FILE_SIZE:
            raise ValueError(f"File too large: {size} bytes (max {self.MAX_FILE_SIZE})")

        return size

    def optimize_image(self, file_obj):
        """Optimize image for web display."""
        # Open and validate
        try:
            img = Image.open(file_obj)
        except Exception as e:
            raise ValueError(f"Invalid or corrupted image: {e}")

        # Correct EXIF orientation
        img = ImageOps.exif_transpose(img)

        # Check dimensions
        width, height = img.size
        if max(width, height) > self.MAX_DIMENSIONS:
            raise ValueError(f"Image too large: {width}x{height} (max {self.MAX_DIMENSIONS})")

        # Resize if needed
        if max(width, height) > self.RESIZE_THRESHOLD:
            img.thumbnail((self.RESIZE_THRESHOLD, self.RESIZE_THRESHOLD),
                         Image.Resampling.LANCZOS)

        return img

    def generate_filename(self, original_name, content):
        """Generate unique filename with date path."""
        # Create hash for uniqueness
        hash_obj = hashlib.sha256(content)
        hash_hex = hash_obj.hexdigest()[:8]

        # Get extension
        _, ext = os.path.splitext(original_name)

        # Generate date-based path
        now = datetime.now()
        year = now.strftime('%Y')
        month = now.strftime('%m')

        # Create filename
        filename = f"{now.strftime('%Y%m%d')}-{hash_hex}{ext}"

        return f"{year}/{month}/{filename}"

3. Database Migration

Create migration for media tables:

-- Create media table
CREATE TABLE IF NOT EXISTS media (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    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,
    height INTEGER,
    uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Create note_media junction table with caption support
CREATE TABLE IF NOT EXISTS note_media (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    note_id INTEGER NOT NULL,
    media_id INTEGER NOT NULL,
    display_order INTEGER NOT NULL DEFAULT 0,
    caption TEXT,  -- Optional caption for accessibility
    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 indexes
CREATE INDEX idx_note_media_note ON note_media(note_id);
CREATE INDEX idx_note_media_order ON note_media(note_id, display_order);

4. Upload Endpoint

In starpunk/routes/admin.py:

@bp.route('/admin/upload', methods=['POST'])
@require_auth
def upload_media():
    """Handle AJAX media upload."""
    if 'file' not in request.files:
        return jsonify({'error': 'No file provided'}), 400

    file = request.files['file']

    try:
        # Process with MediaProcessor
        processor = MediaProcessor()

        # Validate size first (before loading image)
        size = processor.validate_file_size(file.file)

        # Optimize image
        optimized = processor.optimize_image(file.file)

        # Generate path
        path = processor.generate_filename(file.filename, file.read())

        # Save to disk
        save_path = Path(app.config['MEDIA_PATH']) / path
        save_path.parent.mkdir(parents=True, exist_ok=True)
        optimized.save(save_path, quality=95, optimize=True)

        # Save to database
        media_id = save_media_metadata(
            filename=path.name,
            original_name=file.filename,
            path=path,
            mime_type=file.content_type,
            size=save_path.stat().st_size,
            width=optimized.width,
            height=optimized.height
        )

        # Return success
        return jsonify({
            'success': True,
            'media_id': media_id,
            'url': f'/media/{path}'
        })

    except ValueError as e:
        return jsonify({'error': str(e)}), 400
    except Exception as e:
        app.logger.error(f"Upload failed: {e}")
        return jsonify({'error': 'Upload failed'}), 500

5. Template Updates

Update note creation/edit forms to include:

  • Multiple file input with accept attribute
  • Caption fields for each uploaded image
  • Client-side preview with caption inputs
  • Remove button for each image
  • Hidden fields to track attached media IDs

6. Display Implementation

When rendering notes:

  1. Query note_media JOIN media ordered by display_order
  2. Display images at top of note
  3. Use captions as alt text
  4. Apply responsive grid layout CSS

Testing Checklist

Unit Tests

  • File size validation (reject >10MB)
  • Dimension validation (reject >4096px)
  • MIME type validation (accept only JPEG/PNG/GIF/WebP)
  • Image resize logic (>2048px gets resized)
  • Filename generation (unique, date-based)
  • EXIF orientation correction

Integration Tests

  • Upload single image
  • Upload multiple images (up to 4)
  • Reject 5th image
  • Upload with captions
  • Delete uploaded image
  • Edit note with existing media
  • Corrupted file handling
  • Oversized file handling

Manual Testing

  • Upload from phone camera
  • Upload screenshots
  • Test all supported formats
  • Verify captions appear as alt text
  • Check responsive layouts (1-4 images)
  • Verify images in RSS/ATOM/JSON feeds

Error Messages

Provide clear, actionable error messages:

  • "File too large. Maximum size is 10MB"
  • "Image dimensions too large. Maximum is 4096x4096 pixels"
  • "Invalid image format. Accepted: JPEG, PNG, GIF, WebP"
  • "Maximum 4 images per note"
  • "Image appears to be corrupted"

Performance Considerations

  • Process images synchronously (single-user CMS)
  • Use quality=95 for good balance of size/quality
  • Consider lazy loading for feed pages
  • Cache resized images (future enhancement)

Security Notes

  • Always validate MIME type server-side
  • Use Pillow to verify file integrity
  • Sanitize filenames before saving
  • Prevent directory traversal in media paths
  • Strip EXIF data that might contain GPS/personal info

Future Enhancements (NOT in v1.2.0)

  • Micropub media endpoint support
  • Video upload support
  • Separate thumbnail generation
  • CDN integration
  • Bulk upload interface
  • Image editing tools (crop, rotate)