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:
@@ -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 '<script>' in html
|
||||
assert '<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 '"' in html or '"' in html or ''' 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 '<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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user