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:
303
docs/design/v1.2.0/developer-qa.md
Normal file
303
docs/design/v1.2.0/developer-qa.md
Normal 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.
|
||||
872
docs/design/v1.2.0/feature-specification.md
Normal file
872
docs/design/v1.2.0/feature-specification.md
Normal 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">
|
||||
<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>
|
||||
</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
|
||||
269
docs/design/v1.2.0/media-implementation-guide.md
Normal file
269
docs/design/v1.2.0/media-implementation-guide.md
Normal 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)
|
||||
143
docs/design/v1.2.0/media-upload-final-design.md
Normal file
143
docs/design/v1.2.0/media-upload-final-design.md
Normal 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
|
||||
Reference in New Issue
Block a user