feat: v1.2.0-rc.2 - Media display fixes and feed enhancements

## Added
- Feed Media Enhancement with Media RSS namespace support
  - RSS enclosure, media:content, media:thumbnail elements
  - JSON Feed image field for first image
- ADR-059: Full feed media standardization roadmap

## Fixed
- Media display on homepage (was only showing on note pages)
- Responsive image sizing with CSS constraints
- Caption display (now alt text only, not visible)
- Logging correlation ID crash in non-request contexts

## Documentation
- Feed media design documents and implementation reports
- Media display fixes design and validation reports
- Updated ROADMAP with v1.3.0/v1.4.0 media plans

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-09 14:58:37 -07:00
parent 10d85bb78b
commit 27501f6381
21 changed files with 3360 additions and 44 deletions

View File

@@ -157,7 +157,7 @@ class TestImageOptimization:
class TestMediaSave:
"""Test save_media function"""
def test_save_valid_image(self, app, db):
def test_save_valid_image(self, app):
"""Test saving valid image"""
image_data = create_test_image(800, 600, 'PNG')
@@ -175,7 +175,7 @@ class TestMediaSave:
media_path = Path(app.config['DATA_PATH']) / 'media' / media_info['path']
assert media_path.exists()
def test_uuid_filename(self, app, db):
def test_uuid_filename(self, app):
"""Test UUID-based filename generation (per Q5)"""
image_data = create_test_image(800, 600, 'PNG')
@@ -192,7 +192,7 @@ class TestMediaSave:
assert len(parts[0]) == 4 # Year
assert len(parts[1]) == 2 # Month
def test_auto_resize_on_save(self, app, db):
def test_auto_resize_on_save(self, app):
"""Test image >2048px is automatically resized"""
large_image = create_test_image(3000, 2000, 'PNG')
@@ -207,7 +207,7 @@ class TestMediaSave:
class TestMediaAttachment:
"""Test attach_media_to_note function"""
def test_attach_single_image(self, app, db, sample_note):
def test_attach_single_image(self, app, sample_note):
"""Test attaching single image to note"""
image_data = create_test_image(800, 600, 'PNG')
@@ -225,7 +225,7 @@ class TestMediaAttachment:
assert media_list[0]['caption'] == 'Test caption'
assert media_list[0]['display_order'] == 0
def test_attach_multiple_images(self, app, db, sample_note):
def test_attach_multiple_images(self, app, sample_note):
"""Test attaching multiple images (up to 4)"""
with app.app_context():
media_ids = []
@@ -247,7 +247,7 @@ class TestMediaAttachment:
assert media_item['display_order'] == i
assert media_item['caption'] == f'Caption {i}'
def test_reject_more_than_4_images(self, app, db, sample_note):
def test_reject_more_than_4_images(self, app, sample_note):
"""Test rejection of 5th image (per Q6)"""
with app.app_context():
media_ids = []
@@ -264,7 +264,7 @@ class TestMediaAttachment:
assert "Maximum 4 images" in str(exc_info.value)
def test_optional_captions(self, app, db, sample_note):
def test_optional_captions(self, app, sample_note):
"""Test captions are optional (per Q7)"""
image_data = create_test_image(800, 600, 'PNG')
@@ -281,7 +281,7 @@ class TestMediaAttachment:
class TestMediaDeletion:
"""Test delete_media function"""
def test_delete_media_file(self, app, db):
def test_delete_media_file(self, app):
"""Test deletion of media file and record"""
image_data = create_test_image(800, 600, 'PNG')
@@ -299,7 +299,7 @@ class TestMediaDeletion:
# Verify file deleted
assert not media_path.exists()
def test_delete_orphaned_associations(self, app, db, sample_note):
def test_delete_orphaned_associations(self, app, sample_note):
"""Test cascade deletion of note_media associations"""
image_data = create_test_image(800, 600, 'PNG')
@@ -315,8 +315,118 @@ class TestMediaDeletion:
assert len(media_list) == 0
class TestMediaSecurityEscaping:
"""Test HTML/JavaScript escaping in media display (per media-display-fixes.md)"""
def test_caption_html_escaped_in_alt_attribute(self, app, sample_note):
"""
Test that captions containing HTML are properly escaped in alt attributes
Per media-display-fixes.md Security Considerations:
"Alt text must be HTML-escaped in templates"
This prevents XSS attacks via malicious caption content.
"""
from starpunk.media import attach_media_to_note, save_media
image_data = create_test_image(800, 600, 'PNG')
# Create caption with HTML tags that should be escaped
malicious_caption = '<script>alert("XSS")</script><img src=x onerror=alert(1)>'
with app.app_context():
# Save media with malicious caption
media_info = save_media(image_data, 'test.png')
attach_media_to_note(sample_note.id, [media_info['id']], [malicious_caption])
# Get the rendered note page
client = app.test_client()
response = client.get(f'/note/{sample_note.slug}')
assert response.status_code == 200
# Verify the HTML is escaped in the alt attribute
# The caption should appear as escaped HTML entities, not raw HTML
html = response.data.decode('utf-8')
# Should NOT contain unescaped HTML tags
assert '<script>alert("XSS")</script>' not in html
assert '<img src=x onerror=alert(1)>' not in html
# Should NOT have onerror as an actual HTML attribute (i.e., outside quotes)
# Pattern: onerror= followed by something that isn't part of an alt value
assert 'onerror=' not in html or 'alt=' in html.split('onerror=')[0]
# Should contain escaped versions (Jinja2 auto-escapes by default)
# The HTML tags should be escaped
assert '&lt;script&gt;' in html
assert '&lt;img' in html
def test_caption_quotes_escaped_in_alt_attribute(self, app, sample_note):
"""
Test that captions containing quotes are properly escaped in alt attributes
This prevents breaking out of the alt attribute with malicious quotes.
"""
from starpunk.media import attach_media_to_note, save_media
image_data = create_test_image(800, 600, 'PNG')
# Create caption with quotes that could break alt attribute
caption_with_quotes = 'Image" onload="alert(\'XSS\')'
with app.app_context():
# Save media with caption containing quotes
media_info = save_media(image_data, 'test.png')
attach_media_to_note(sample_note.id, [media_info['id']], [caption_with_quotes])
# Get the rendered note page
client = app.test_client()
response = client.get(f'/note/{sample_note.slug}')
assert response.status_code == 200
html = response.data.decode('utf-8')
# Should NOT contain unescaped onload event
assert 'onload="alert' not in html
# The quote should be properly escaped
# Jinja2 should escape quotes in attributes
assert '&#34;' in html or '&quot;' in html or '&#39;' in html
def test_caption_displayed_on_homepage(self, app, sample_note):
"""
Test that media with captions are properly escaped on homepage too
Per media-display-fixes.md, homepage also displays media using the same macro.
"""
from starpunk.media import attach_media_to_note, save_media
image_data = create_test_image(800, 600, 'PNG')
malicious_caption = '<img src=x onerror=alert(1)>'
with app.app_context():
media_info = save_media(image_data, 'test.png')
attach_media_to_note(sample_note.id, [media_info['id']], [malicious_caption])
# Get the homepage
client = app.test_client()
response = client.get('/')
assert response.status_code == 200
html = response.data.decode('utf-8')
# Should NOT contain unescaped HTML tag
assert '<img src=x onerror=alert(1)>' not in html
# Should contain escaped version
assert '&lt;img' in html
@pytest.fixture
def sample_note(app, db):
def sample_note(app):
"""Create a sample note for testing"""
from starpunk.notes import create_note