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>
872 lines
26 KiB
Markdown
872 lines
26 KiB
Markdown
# 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 |