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

571
tests/test_feeds_rss.py Normal file
View File

@@ -0,0 +1,571 @@
"""
Tests for RSS 2.0 with Media RSS extension
Tests cover:
- Media RSS namespace declaration
- RSS enclosure element (first image only)
- Media RSS content elements (all images)
- Media RSS thumbnail (first image)
- Items without media have no media elements
- JSON Feed image field
- JSON Feed omits image when no media
"""
import pytest
from datetime import datetime, timezone
from xml.etree import ElementTree as ET
import json
import time
from pathlib import Path
from PIL import Image
import io
from starpunk import create_app
from starpunk.feeds.rss import generate_rss, generate_rss_streaming
from starpunk.feeds.json_feed import generate_json_feed, generate_json_feed_streaming
from starpunk.notes import create_note, list_notes
from starpunk.media import save_media, attach_media_to_note
def create_test_image(width=800, height=600, format='PNG'):
"""
Generate test image using PIL
Args:
width: Image width in pixels
height: Image height in pixels
format: Image format (PNG, JPEG, GIF, WEBP)
Returns:
Bytes of image data
"""
img = Image.new('RGB', (width, height), color='blue')
buffer = io.BytesIO()
img.save(buffer, format=format)
buffer.seek(0)
return buffer.getvalue()
@pytest.fixture
def app(tmp_path):
"""Create test application"""
test_data_dir = tmp_path / "data"
test_data_dir.mkdir(parents=True, exist_ok=True)
# Create media directory
test_media_dir = test_data_dir / "media"
test_media_dir.mkdir(parents=True, exist_ok=True)
test_config = {
"TESTING": True,
"DATABASE_PATH": test_data_dir / "starpunk.db",
"DATA_PATH": test_data_dir,
"NOTES_PATH": test_data_dir / "notes",
"MEDIA_PATH": test_media_dir,
"SESSION_SECRET": "test-secret-key",
"ADMIN_ME": "https://test.example.com",
"SITE_URL": "https://example.com",
"SITE_NAME": "Test Blog",
"SITE_DESCRIPTION": "A test blog",
"DEV_MODE": False,
}
app = create_app(config=test_config)
yield app
@pytest.fixture
def note_with_single_media(app):
"""Create note with single image attachment"""
from starpunk.media import get_note_media
with app.app_context():
# Create note
note = create_note(
content="# Test Note\n\nNote with one image.",
published=True
)
# Create and attach media
image_data = create_test_image(800, 600, 'JPEG')
media_info = save_media(image_data, 'test-image.jpg')
attach_media_to_note(note.id, [media_info['id']], ['Test caption'])
# Reload note and attach media
notes = list_notes(published_only=True, limit=1)
note = notes[0]
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
return note
@pytest.fixture
def note_with_multiple_media(app):
"""Create note with multiple image attachments"""
from starpunk.media import get_note_media
with app.app_context():
# Create note
note = create_note(
content="# Gallery Note\n\nNote with three images.",
published=True
)
# Create and attach multiple media
media_ids = []
captions = []
for i in range(3):
image_data = create_test_image(800, 600, 'JPEG')
media_info = save_media(image_data, f'image-{i}.jpg')
media_ids.append(media_info['id'])
captions.append(f'Caption {i}')
attach_media_to_note(note.id, media_ids, captions)
# Reload note and attach media
notes = list_notes(published_only=True, limit=1)
note = notes[0]
media = get_note_media(note.id)
object.__setattr__(note, 'media', media)
return note
@pytest.fixture
def note_without_media(app):
"""Create note without media"""
with app.app_context():
note = create_note(
content="# Plain Note\n\nNote without images.",
published=True
)
notes = list_notes(published_only=True, limit=1)
return notes[0]
class TestRSSMediaNamespace:
"""Test RSS feed has Media RSS namespace"""
def test_rss_has_media_namespace(self, app, note_with_single_media):
"""RSS feed should declare Media RSS namespace"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Check for Media RSS namespace declaration
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
def test_rss_streaming_has_media_namespace(self, app, note_with_single_media):
"""Streaming RSS feed should declare Media RSS namespace"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Check for Media RSS namespace declaration
assert 'xmlns:media="http://search.yahoo.com/mrss/"' in feed_xml
class TestRSSEnclosure:
"""Test RSS enclosure element for first image"""
def test_rss_enclosure_for_single_media(self, app, note_with_single_media):
"""RSS item should include enclosure element for first image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
# Should have enclosure
assert enclosure is not None
# Check attributes
assert enclosure.get("url") is not None
assert "https://example.com/media/" in enclosure.get("url")
assert enclosure.get("type") == "image/jpeg"
assert enclosure.get("length") is not None
def test_rss_enclosure_first_image_only(self, app, note_with_multiple_media):
"""RSS item should include only one enclosure (first image)"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosures = item.findall("enclosure")
# Should have exactly one enclosure (RSS 2.0 spec)
assert len(enclosures) == 1
def test_rss_no_enclosure_without_media(self, app, note_without_media):
"""RSS item without media should have no enclosure"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
# Should have no enclosure
assert enclosure is None
class TestRSSMediaContent:
"""Test Media RSS content elements"""
def test_rss_media_content_for_single_image(self, app, note_with_single_media):
"""RSS item should include media:content for image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have one media:content element
assert len(media_contents) == 1
# Check attributes
media_content = media_contents[0]
assert media_content.get("url") is not None
assert "https://example.com/media/" in media_content.get("url")
assert media_content.get("type") == "image/jpeg"
assert media_content.get("medium") == "image"
def test_rss_media_content_for_multiple_images(self, app, note_with_multiple_media):
"""RSS item should include media:content for each image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have three media:content elements
assert len(media_contents) == 3
def test_rss_no_media_content_without_media(self, app, note_without_media):
"""RSS item without media should have no media:content elements"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_contents = item.findall("media:content", namespaces)
# Should have no media:content elements
assert len(media_contents) == 0
class TestRSSMediaThumbnail:
"""Test Media RSS thumbnail element"""
def test_rss_media_thumbnail_for_first_image(self, app, note_with_single_media):
"""RSS item should include media:thumbnail for first image"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnail = item.find("media:thumbnail", namespaces)
# Should have media:thumbnail
assert media_thumbnail is not None
assert media_thumbnail.get("url") is not None
assert "https://example.com/media/" in media_thumbnail.get("url")
def test_rss_media_thumbnail_only_one(self, app, note_with_multiple_media):
"""RSS item should include only one media:thumbnail (first image)"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnails = item.findall("media:thumbnail", namespaces)
# Should have exactly one media:thumbnail
assert len(media_thumbnails) == 1
def test_rss_no_media_thumbnail_without_media(self, app, note_without_media):
"""RSS item without media should have no media:thumbnail"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
# Parse XML with namespace
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_thumbnail = item.find("media:thumbnail", namespaces)
# Should have no media:thumbnail
assert media_thumbnail is None
class TestRSSStreamingMedia:
"""Test streaming RSS generation includes media elements"""
def test_rss_streaming_includes_enclosure(self, app, note_with_single_media):
"""Streaming RSS should include enclosure element"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Parse and check for enclosure
root = ET.fromstring(feed_xml)
channel = root.find("channel")
item = channel.find("item")
enclosure = item.find("enclosure")
assert enclosure is not None
def test_rss_streaming_includes_media_elements(self, app, note_with_single_media):
"""Streaming RSS should include media:content and media:thumbnail"""
with app.app_context():
generator = generate_rss_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_xml = ''.join(generator)
# Parse and check for media elements
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
media_content = item.find("media:content", namespaces)
media_thumbnail = item.find("media:thumbnail", namespaces)
assert media_content is not None
assert media_thumbnail is not None
class TestJSONFeedImage:
"""Test JSON Feed image field"""
def test_json_feed_has_image_field(self, app, note_with_single_media):
"""JSON Feed item should include image field for first image"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
assert item["image"] is not None
assert "https://example.com/media/" in item["image"]
def test_json_feed_image_uses_first_media(self, app, note_with_multiple_media):
"""JSON Feed image field should use first media item URL"""
from starpunk.media import get_note_media
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_multiple_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field with first image
assert "image" in item
# Verify it's the first media item from the note
# (Media is saved with UUID filenames, so we can't check for "image-0.jpg")
media = note_with_multiple_media.media
first_media_path = media[0]['path']
assert first_media_path in item["image"]
def test_json_feed_no_image_field_without_media(self, app, note_without_media):
"""JSON Feed item without media should not have image field"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should NOT have image field (per Q7: absent, not null)
assert "image" not in item
def test_json_feed_streaming_has_image_field(self, app, note_with_single_media):
"""Streaming JSON Feed should include image field"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed_json = ''.join(generator)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
assert "https://example.com/media/" in item["image"]
def test_json_feed_streaming_no_image_without_media(self, app, note_without_media):
"""Streaming JSON Feed without media should omit image field"""
with app.app_context():
generator = generate_json_feed_streaming(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_without_media]
)
feed_json = ''.join(generator)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should NOT have image field
assert "image" not in item
class TestFeedMediaIntegration:
"""Integration tests for media in feeds"""
def test_rss_media_and_html_both_present(self, app, note_with_single_media):
"""RSS should include both media elements AND HTML img tags"""
with app.app_context():
feed_xml = generate_rss(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
# Parse XML
root = ET.fromstring(feed_xml)
namespaces = {'media': 'http://search.yahoo.com/mrss/'}
channel = root.find("channel")
item = channel.find("item")
# Should have media:content
media_content = item.find("media:content", namespaces)
assert media_content is not None
# Should also have HTML img in description
description = item.find("description").text
assert '<img' in description
def test_json_feed_image_and_attachments_both_present(self, app, note_with_single_media):
"""JSON Feed should include both image field AND attachments array"""
with app.app_context():
feed_json = generate_json_feed(
site_url="https://example.com",
site_name="Test Blog",
site_description="A test blog",
notes=[note_with_single_media]
)
feed = json.loads(feed_json)
item = feed["items"][0]
# Should have image field
assert "image" in item
# Should also have attachments array
assert "attachments" in item
assert len(item["attachments"]) > 0

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