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

@@ -0,0 +1,303 @@
# v1.2.0 Developer Q&A
**Date**: 2025-11-28
**Architect**: StarPunk Architect Subagent
**Purpose**: Answer critical implementation questions for v1.2.0
## Custom Slugs Answers
**Q1: Validation pattern conflict - should we apply new lowercase validation to existing slugs?**
- **Answer:** Validate only new custom slugs, don't migrate existing slugs
- **Rationale:** Existing slugs work, no need to change them retroactively
- **Implementation:** In `validate_and_sanitize_custom_slug()`, apply lowercase enforcement only to new/edited slugs
**Q2: Form field readonly behavior - how should the slug field behave on edit forms?**
- **Answer:** Display as readonly input field with current value visible
- **Rationale:** Users need to see the current slug but understand it cannot be changed
- **Implementation:** Use `readonly` attribute, not `disabled` (disabled fields don't submit with form)
**Q3: Slug uniqueness validation - where should this happen?**
- **Answer:** Both client-side (for UX) and server-side (for security)
- **Rationale:** Client-side prevents unnecessary submissions, server-side is authoritative
- **Implementation:** Database unique constraint + Python validation in `validate_and_sanitize_custom_slug()`
## Media Upload Answers
**Q4: Media upload flow - how should upload and note association work?**
- **Answer:** Upload during note creation, associate via note_id after creation
- **Rationale:** Simpler than pre-upload with temporary IDs
- **Implementation:** Upload files in `create_note_submit()` after note is created, store associations in media table
**Q5: Storage directory structure - exact path format?**
- **Answer:** `data/media/YYYY/MM/filename-uuid.ext`
- **Rationale:** Date organization helps with backups and management
- **Implementation:** Use `os.makedirs(path, exist_ok=True)` to create directories as needed
**Q6: File naming convention - how to ensure uniqueness?**
- **Answer:** `{original_name_slug}-{uuid4()[:8]}.{extension}`
- **Rationale:** Preserves original name for SEO while ensuring uniqueness
- **Implementation:** Slugify original filename, append 8-char UUID, preserve extension
**Q7: MIME type validation - which types exactly?**
- **Answer:** Allow: image/jpeg, image/png, image/gif, image/webp. Reject all others
- **Rationale:** Common web formats only, no SVG (XSS risk)
- **Implementation:** Use python-magic for reliable MIME detection, not just file extension
**Q8: Upload size limits - what's reasonable?**
- **Answer:** 10MB per file, 40MB total per note (4 files × 10MB)
- **Rationale:** Sufficient for high-quality images without overwhelming storage
- **Implementation:** Check in both client-side JavaScript and server-side validation
**Q9: Database schema for media table - exact columns?**
- **Answer:** id, note_id, filename, mime_type, size_bytes, width, height, uploaded_at
- **Rationale:** Minimal but sufficient metadata for display and management
- **Implementation:** Use Pillow to extract image dimensions on upload
**Q10: Orphaned file cleanup - how to handle?**
- **Answer:** Keep orphaned files, add admin cleanup tool in future version
- **Rationale:** Data preservation is priority, cleanup can be manual for v1.2.0
- **Implementation:** Log orphaned files but don't auto-delete
**Q11: Upload progress indication - required for v1.2.0?**
- **Answer:** No, simple form submission is sufficient for v1.2.0
- **Rationale:** Keep it simple, can enhance in future version
- **Implementation:** Standard HTML form with enctype="multipart/form-data"
**Q12: Image display order - how to maintain?**
- **Answer:** Use upload sequence, store display_order in media table
- **Rationale:** Predictable and simple
- **Implementation:** Auto-increment display_order starting at 0
**Q13: Thumbnail generation - needed for v1.2.0?**
- **Answer:** No, use CSS for responsive sizing
- **Rationale:** Simplicity over optimization for v1
- **Implementation:** Use `max-width: 100%` and lazy loading
**Q14: Edit form media handling - can users remove media?**
- **Answer:** Yes, checkbox to mark for deletion
- **Rationale:** Essential editing capability
- **Implementation:** "Remove" checkboxes next to each image in edit form
**Q15: Media URL structure - exact format?**
- **Answer:** `/media/YYYY/MM/filename.ext` (matches storage path)
- **Rationale:** Clean URLs, date organization visible
- **Implementation:** Route in `starpunk/routes/public.py` using send_from_directory
## Author Discovery Answers
**Q16: Discovery failure handling - what if profile URL is unreachable?**
- **Answer:** Use defaults: name from IndieAuth me URL domain, no photo
- **Rationale:** Always provide something, never break
- **Implementation:** Try discovery, catch all exceptions, use defaults
**Q17: h-card parsing library - which one?**
- **Answer:** Use mf2py (already in requirements for Micropub)
- **Rationale:** Already a dependency, well-maintained
- **Implementation:** `import mf2py; result = mf2py.parse(url=profile_url)`
**Q18: Multiple h-cards on profile - which to use?**
- **Answer:** First h-card with url property matching the profile URL
- **Rationale:** Most specific match per IndieWeb convention
- **Implementation:** Loop through h-cards, check url property
**Q19: Discovery caching duration - how long?**
- **Answer:** 24 hours, with manual refresh button in admin
- **Rationale:** Balance between freshness and performance
- **Implementation:** Store discovered_at timestamp, check age
**Q20: Profile update mechanism - when to refresh?**
- **Answer:** On login + manual refresh button + 24hr expiry
- **Rationale:** Login is natural refresh point
- **Implementation:** Call discovery in auth callback
**Q21: Missing properties handling - what if no name/photo?**
- **Answer:** name = domain from URL, photo = None (no image)
- **Rationale:** Graceful degradation
- **Implementation:** Use get() with defaults on parsed properties
**Q22: Database schema for author_profile - exact columns?**
- **Answer:** me_url (PK), name, photo, url, discovered_at, raw_data (JSON)
- **Rationale:** Cache parsed data + raw for debugging
- **Implementation:** Single row table, upsert on discovery
## Microformats2 Answers
**Q23: h-card placement - where exactly in templates?**
- **Answer:** Only within h-entry author property (p-author h-card)
- **Rationale:** Correct semantic placement per spec
- **Implementation:** In note partial template, not standalone
**Q24: h-feed container - which pages need it?**
- **Answer:** Homepage (/) and any paginated list pages
- **Rationale:** Feed pages only, not single note pages
- **Implementation:** Wrap note list in div.h-feed with h1.p-name
**Q25: Optional properties - which to include?**
- **Answer:** Only what we have: author, name, url, published, content
- **Rationale:** Don't add empty properties
- **Implementation:** Use conditional template blocks
**Q26: Micropub compatibility - any changes needed?**
- **Answer:** No, Micropub already handles microformats correctly
- **Rationale:** Micropub creates data, templates display it
- **Implementation:** Ensure templates match Micropub's data model
## Feed Integration Answers
**Q27: RSS/Atom changes for media - how to include images?**
- **Answer:** Add as enclosures (RSS) and link rel="enclosure" (Atom)
- **Rationale:** Standard podcast/media pattern
- **Implementation:** Loop through note.media, add enclosure elements
**Q28: JSON Feed media handling - which property?**
- **Answer:** Use "attachments" array per JSON Feed 1.1 spec
- **Rationale:** Designed for exactly this use case
- **Implementation:** Create attachment objects with url, mime_type
**Q29: Feed caching - any changes needed?**
- **Answer:** No, existing cache logic is sufficient
- **Rationale:** Media URLs are stable once uploaded
- **Implementation:** No changes required
**Q30: Author in feeds - use discovered data?**
- **Answer:** Yes, use discovered name and photo in feed metadata
- **Rationale:** Consistency across all outputs
- **Implementation:** Pass author_profile to feed templates
## Database Migration Answers
**Q31: Migration naming convention - what number?**
- **Answer:** Use next sequential: 005_add_media_support.sql
- **Rationale:** Continue existing pattern
- **Implementation:** Check latest migration, increment
**Q32: Migration rollback - needed?**
- **Answer:** No, forward-only migrations per project convention
- **Rationale:** Simplicity, follows existing pattern
- **Implementation:** CREATE IF NOT EXISTS, never DROP
**Q33: Migration testing - how to verify?**
- **Answer:** Test on copy of production database
- **Rationale:** Real-world data is best test
- **Implementation:** Copy data/starpunk.db, run migration, verify
## Testing Strategy Answers
**Q34: Test data for media - what to use?**
- **Answer:** Generate 1x1 pixel PNG in tests, don't use real files
- **Rationale:** Minimal, fast, no binary files in repo
- **Implementation:** Use Pillow to generate test images in memory
**Q35: Author discovery mocking - how to test?**
- **Answer:** Mock HTTP responses with test h-card HTML
- **Rationale:** Deterministic, no external dependencies
- **Implementation:** Use responses library or unittest.mock
**Q36: Integration test priority - which are critical?**
- **Answer:** Upload → Display → Edit → Delete flow
- **Rationale:** Core user journey must work
- **Implementation:** Single test that exercises full lifecycle
## Error Handling Answers
**Q37: Upload failure recovery - how to handle?**
- **Answer:** Show error, preserve form data, allow retry
- **Rationale:** Don't lose user's work
- **Implementation:** Flash error, return to form with content preserved
**Q38: Discovery network timeout - how long to wait?**
- **Answer:** 5 second timeout for profile fetch
- **Rationale:** Balance between patience and responsiveness
- **Implementation:** Use requests timeout parameter
## Deployment Answers
**Q39: Media directory permissions - what's needed?**
- **Answer:** data/media/ needs write permission for app user
- **Rationale:** Same as existing data/ directory
- **Implementation:** Document in deployment guide, create in setup
**Q40: Upgrade path from v1.1.2 - any special steps?**
- **Answer:** Run migration, create media directory, restart app
- **Rationale:** Minimal disruption
- **Implementation:** Add to CHANGELOG upgrade notes
**Q41: Configuration changes - any new env vars?**
- **Answer:** No, all settings have sensible defaults
- **Rationale:** Maintain zero-config philosophy
- **Implementation:** Hardcode limits in code with constants
## Critical Path Decisions Summary
These are the key decisions to unblock implementation:
1. **Media upload flow**: Upload after note creation, associate via note_id
2. **Author discovery**: Use mf2py, cache for 24hrs, graceful fallbacks
3. **h-card parsing**: First h-card with matching URL property
4. **h-card placement**: Only within h-entry as p-author
5. **Migration strategy**: Sequential numbering (005), forward-only
## Implementation Order
Based on dependencies and complexity:
### Phase 1: Custom Slugs (2 hours)
- Simplest feature
- No database changes
- Template and validation only
### Phase 2: Author Discovery (4 hours)
- Build discovery module
- Add author_profile table
- Integrate with auth flow
- Update templates
### Phase 3: Media Upload (6 hours)
- Most complex feature
- Media table and migration
- Upload handling
- Template updates
- Storage management
## File Structure
Key files to create/modify:
### New Files
- `starpunk/discovery.py` - Author discovery module
- `starpunk/media.py` - Media handling module
- `migrations/005_add_media_support.sql` - Database changes
- `static/js/media-upload.js` - Optional enhancement
### Modified Files
- `templates/admin/new.html` - Add slug and media fields
- `templates/admin/edit.html` - Add slug (readonly) and media
- `templates/partials/note.html` - Add microformats markup
- `templates/public/index.html` - Add h-feed container
- `starpunk/routes/admin.py` - Handle slugs and uploads
- `starpunk/routes/auth.py` - Trigger discovery on login
- `starpunk/models/note.py` - Add media relationship
## Success Metrics
Implementation is complete when:
1. ✅ Custom slug can be specified on creation
2. ✅ Images can be uploaded and displayed
3. ✅ Author info is discovered from IndieAuth profile
4. ✅ IndieWebify.me validates h-feed and h-entry
5. ✅ All tests pass
6. ✅ No regressions in existing functionality
7. ✅ Media files are tracked in database
8. ✅ Errors are handled gracefully
## Final Notes
- Keep it simple - this is v1.2.0, not v2.0.0
- Data preservation over premature optimization
- When uncertain, choose the more explicit option
- Document any deviations from this guidance
---
This Q&A document serves as the authoritative implementation guide for v1.2.0. Any questions not covered here should follow the principle of maximum simplicity.

View File

@@ -0,0 +1,872 @@
# 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
<div class="form-group">
<label for="slug">Custom Slug (Optional)</label>
<input
type="text"
id="slug"
name="slug"
pattern="[a-z0-9-]+"
maxlength="200"
placeholder="leave-blank-for-auto-generation"
{% if editing %}readonly{% endif %}
>
<small>URL-safe characters only (lowercase letters, numbers, hyphens)</small>
{% if editing %}
<small class="text-warning">Slugs cannot be changed after creation to preserve permalinks</small>
{% endif %}
</div>
```
#### 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
<article class="note">
<!-- Media displayed first -->
{% if note.media %}
<div class="media-attachments">
{% if note.media|length == 1 %}
<!-- Single image: full width -->
<img src="{{ media.url }}" alt="{{ media.caption or '' }}" class="single-image">
{% elif note.media|length == 2 %}
<!-- Two images: side by side -->
<div class="media-grid media-grid-2">
{% for media in note.media %}
<img src="{{ media.url }}" alt="{{ media.caption or '' }}">
{% endfor %}
</div>
{% else %}
<!-- 3-4 images: grid layout -->
<div class="media-grid media-grid-{{ note.media|length }}">
{% for media in note.media[:4] %}
<img src="{{ media.url }}" alt="{{ media.caption or '' }}">
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Text content displayed below media -->
<div class="content">
{{ note.html|safe }}
</div>
</article>
```
#### 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
<div class="form-group">
<label for="media">Attach Images</label>
<input
type="file"
id="media"
name="media"
accept="image/*"
multiple
class="media-upload"
>
<small>Accepted formats: JPG, PNG, GIF, WebP (max 10MB each, max 4 images)</small>
<!-- Preview attached media with captions -->
<div id="media-preview" class="media-preview">
<!-- Thumbnails appear here after upload with caption fields -->
</div>
</div>
<script>
// Handle media as attachments, not inline insertion
document.getElementById('media').addEventListener('change', async (e) => {
const preview = document.getElementById('media-preview');
const files = Array.from(e.target.files).slice(0, 4); // Max 4
for (const file of files) {
// Upload and show thumbnail
const url = await uploadMedia(file);
addMediaThumbnail(preview, url, file.name);
}
});
function addMediaThumbnail(container, url, filename) {
const thumb = document.createElement('div');
thumb.className = 'media-thumb';
thumb.innerHTML = `
<img src="${url}" alt="${filename}">
<input type="text" name="caption[]" placeholder="Caption (optional)" class="media-caption">
<button type="button" class="remove-media" data-url="${url}">×</button>
<input type="hidden" name="attached_media[]" value="${url}">
`;
container.appendChild(thumb);
}
</script>
```
#### 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/<year>/<month>/<filename>')
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
<!-- Embed media as HTML in description with CDATA -->
<item>
<title>Note Title</title>
<description><![CDATA[
<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 class="content">
<p>Note text content here...</p>
</div>
]]></description>
<pubDate>...</pubDate>
</item>
```
Rationale: RSS `<enclosure>` 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
<!-- Multiple link elements with rel="enclosure" for each media item -->
<entry>
<title>Note Title</title>
<link rel="enclosure"
type="image/jpeg"
href="https://site.com/media/2025/01/image1.jpg"
length="123456" />
<link rel="enclosure"
type="image/jpeg"
href="https://site.com/media/2025/01/image2.jpg"
length="234567" />
<content type="html">
&lt;div class="media"&gt;
&lt;img src="https://site.com/media/2025/01/image1.jpg" /&gt;
&lt;img src="https://site.com/media/2025/01/image2.jpg" /&gt;
&lt;/div&gt;
&lt;div&gt;Note text content...&lt;/div&gt;
</content>
</entry>
```
Rationale: ATOM supports multiple `<link rel="enclosure">` 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": "<div class='media'>...</div><div>Note text...</div>",
"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 `<link rel="enclosure">` 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
<div class="h-card">
<a class="p-name u-url" href="{{ author.me_url }}">
{{ author.name or author.me_url }}
</a>
{% if author.photo %}
<img class="u-photo" src="{{ author.photo }}" alt="{{ author.name }}">
{% endif %}
{% if author.bio %}
<p class="p-note">{{ author.bio }}</p>
{% endif %}
</div>
```
#### 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
<article class="h-entry">
<!-- Multiple u-photo for multi-photo posts (social media style) -->
{% if note.media %}
{% for media in note.media %}
<img class="u-photo" src="{{ media.url }}" alt="{{ media.caption or '' }}">
{% endfor %}
{% endif %}
<!-- Text content -->
<div class="e-content">
{{ note.html|safe }}
</div>
<!-- Title only if exists (most notes won't have titles) -->
{% if note.has_explicit_title %}
<h1 class="p-name">{{ note.title }}</h1>
{% endif %}
<footer>
<a class="u-url u-uid" href="{{ url }}">
<time class="dt-published" datetime="{{ iso_date }}">
{{ formatted_date }}
</time>
</a>
{% if note.updated_at %}
<time class="dt-updated" datetime="{{ updated_iso }}">
Updated: {{ updated_formatted }}
</time>
{% endif %}
<!-- Author h-card only within h-entry -->
<div class="p-author h-card">
<a class="p-name u-url" href="{{ author.me_url }}">
{{ author.name or author.me_url }}
</a>
{% if author.photo %}
<img class="u-photo" src="{{ author.photo }}" alt="{{ author.name }}">
{% endif %}
</div>
</footer>
</article>
```
**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 <head> using discovered rel-me links:
```html
{% if author.rel_me_links %}
{% for profile in author.rel_me_links %}
<link rel="me" href="{{ profile }}">
{% 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

View File

@@ -0,0 +1,269 @@
# 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`:
```toml
dependencies = [
# ... existing dependencies
"Pillow>=10.0.0", # Image processing
]
```
### 2. Image Processing Module Structure
Create `starpunk/media.py`:
```python
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:
```sql
-- 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`:
```python
@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)

View File

@@ -0,0 +1,143 @@
# V1.2.0 Media Upload - Final Design Summary
## Design Status: COMPLETE ✓
This document summarizes the finalized design for v1.2.0 media upload feature based on user requirements and architectural decisions.
## User Requirements (Confirmed)
1. **Image limit**: 4 images per note
2. **Reordering**: Not needed (display order = upload order)
3. **Image optimization**: Yes, automatic resize for large images
4. **Captions**: Yes, optional caption field for each image
## Architectural Decisions
### ADR-057: Media Attachment Model
- Social media style attachments (not inline markdown)
- Media displays at TOP of notes
- Text content appears BELOW media
- Junction table for flexible associations
### ADR-058: Image Optimization Strategy
- **Max file size**: 10MB per image
- **Max dimensions**: 4096x4096 pixels
- **Auto-resize**: Images >2048px resized automatically
- **Processing library**: Pillow
- **Formats**: JPEG, PNG, GIF, WebP only
## Technical Specifications
### Image Processing
- **Validation**: Size, dimensions, format, integrity
- **Optimization**: Resize to 2048px max, EXIF correction
- **Quality**: 95% JPEG quality (high quality)
- **Storage**: data/media/YYYY/MM/ structure
### Database Schema
```sql
-- Media table with dimensions
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,
height INTEGER,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Junction table with captions
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, -- 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)
);
```
### User Interface
- Multiple file input (accept images only)
- Caption field for each uploaded image
- Preview thumbnails during upload
- Remove button per image
- No drag-and-drop reordering
- Maximum 4 images enforced
### Display Layout
- 1 image: Full width
- 2 images: Side by side (50% each)
- 3 images: Grid layout
- 4 images: 2x2 grid
### Syndication Support
- **RSS**: HTML with images in description
- **ATOM**: Both enclosures and HTML content
- **JSON Feed**: Native attachments array
- **Microformats2**: Multiple u-photo properties
## Implementation Guidance
### Dependencies
- **Pillow**: For image processing and optimization
### Processing Pipeline
1. Check file size (<10MB)
2. Validate MIME type
3. Load with Pillow (validates integrity)
4. Check dimensions (<4096px)
5. Correct EXIF orientation
6. Resize if needed (>2048px)
7. Save optimized version
8. Store metadata in database
### Error Handling
Clear user-facing messages for:
- File too large
- Invalid format
- Dimensions too large
- Corrupted file
- Maximum images reached
## Acceptance Criteria
- ✓ 4 image maximum per note
- ✓ No reordering interface
- ✓ Automatic optimization for large images
- ✓ Caption support for accessibility
- ✓ JPEG, PNG, GIF, WebP support
- ✓ 10MB file size limit
- ✓ 4096x4096 dimension limit
- ✓ Auto-resize at 2048px
- ✓ EXIF orientation correction
- ✓ Display order = upload order
## Related Documents
- `/docs/decisions/ADR-057-media-attachment-model.md`
- `/docs/decisions/ADR-058-image-optimization-strategy.md`
- `/docs/design/v1.2.0/feature-specification.md`
- `/docs/design/v1.2.0/media-implementation-guide.md`
## Design Sign-off
The v1.2.0 media upload feature design is now complete and ready for implementation. All user requirements have been addressed, technical decisions documented, and implementation guidance provided.
### Key Highlights
- **Simple and elegant**: Automatic optimization, no complex UI
- **Accessible**: Caption support for all images
- **Standards-compliant**: Full syndication feed support
- **Performant**: Optimized images, reasonable limits
- **Secure**: Multiple validation layers, Pillow verification
## Next Steps
1. Implement database migrations
2. Create MediaProcessor class with Pillow
3. Add upload endpoint to admin routes
4. Update note creation/edit forms
5. Implement media display in templates
6. Update feed generators for media
7. Write comprehensive tests