Add content negotiation to /cache/{content_hash}

- Browsers get HTML detail page with video/image preview
- API clients with Accept: application/json get metadata JSON
- Other requests get raw file
- Add /cache/{content_hash}/raw for explicit file downloads
- Remove old /cache/{content_hash}/detail endpoint
- Update all /detail links to use clean /cache/{hash} URL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-09 02:17:46 +00:00
parent 2c008812f6
commit 0e4feeb999

357
server.py
View File

@@ -796,7 +796,7 @@ async def run_detail(run_id: str, request: Request):
media_html += f'''
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-2">Input</div>
<a href="/cache/{input_hash}/detail" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{input_hash[:24]}...</a>
<a href="/cache/{input_hash}" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{input_hash[:24]}...</a>
<div class="mt-3 flex justify-center">{input_elem}</div>
</div>
'''
@@ -813,14 +813,14 @@ async def run_detail(run_id: str, request: Request):
media_html += f'''
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-2">Output</div>
<a href="/cache/{output_hash}/detail" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{output_hash[:24]}...</a>
<a href="/cache/{output_hash}" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{output_hash[:24]}...</a>
<div class="mt-3 flex justify-center">{output_elem}</div>
</div>
'''
media_html += '</div>'
# Build inputs list
inputs_html = ''.join([f'<a href="/cache/{inp}/detail" class="text-blue-400 hover:text-blue-300 font-mono text-xs block">{inp}</a>' for inp in run.inputs])
inputs_html = ''.join([f'<a href="/cache/{inp}" class="text-blue-400 hover:text-blue-300 font-mono text-xs block">{inp}</a>' for inp in run.inputs])
# Infrastructure section
infra_html = ""
@@ -888,7 +888,7 @@ async def run_detail(run_id: str, request: Request):
if run.output_hash:
output_link = f'''<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Output</div>
<a href="/cache/{run.output_hash}/detail" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{run.output_hash}</a>
<a href="/cache/{run.output_hash}" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{run.output_hash}</a>
</div>'''
completed_html = ""
@@ -1654,8 +1654,179 @@ async def ui_discard_recipe(recipe_id: str, request: Request):
@app.get("/cache/{content_hash}")
async def get_cached(content_hash: str):
"""Get cached content by hash."""
async def get_cached(content_hash: str, request: Request):
"""Get cached content by hash. Content negotiation: HTML for browsers, JSON for APIs, file for downloads."""
ctx = get_user_context_from_cookie(request)
cache_path = get_cache_path(content_hash)
if not cache_path:
if wants_html(request):
content = f'<p class="text-red-400">Content not found: {content_hash}</p>'
return HTMLResponse(render_page("Not Found", content, ctx.actor_id if ctx else None, active_tab="media"), status_code=404)
raise HTTPException(404, f"Content {content_hash} not in cache")
# JSON response for API clients
accept = request.headers.get("accept", "")
if "application/json" in accept:
meta = await database.load_item_metadata(content_hash, ctx.actor_id if ctx else None)
cache_item = await database.get_cache_item(content_hash)
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
file_size = cache_path.stat().st_size
media_type = detect_media_type(cache_path)
return {
"content_hash": content_hash,
"size": file_size,
"media_type": media_type,
"ipfs_cid": ipfs_cid,
"meta": meta
}
# HTML response for browsers - show detail page
if wants_html(request):
if not ctx:
content = '<p class="text-gray-400 py-8 text-center"><a href="/login" class="text-blue-400 hover:text-blue-300">Login via L2</a> to view cached content.</p>'
return HTMLResponse(render_page("Login Required", content, None, active_tab="media"), status_code=401)
# Check user has access
user_hashes = await get_user_cache_hashes(ctx.username, ctx.actor_id)
if content_hash not in user_hashes:
content = '<p class="text-red-400 py-8 text-center">Access denied.</p>'
return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="media"), status_code=403)
media_type = detect_media_type(cache_path)
file_size = cache_path.stat().st_size
size_str = f"{file_size:,} bytes"
if file_size > 1024*1024:
size_str = f"{file_size/(1024*1024):.1f} MB"
elif file_size > 1024:
size_str = f"{file_size/1024:.1f} KB"
# Get IPFS CID from database
cache_item = await database.get_cache_item(content_hash)
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
# Build media display HTML
if media_type == "video":
video_src = video_src_for_request(content_hash, request)
media_html = f'<video src="{video_src}" controls autoplay muted loop playsinline class="max-w-full max-h-96 rounded-lg"></video>'
elif media_type == "image":
media_html = f'<img src="/cache/{content_hash}/raw" alt="{content_hash}" class="max-w-full max-h-96 rounded-lg">'
else:
media_html = f'<p class="text-gray-400">Unknown file type. <a href="/cache/{content_hash}/raw" download class="text-blue-400 hover:text-blue-300">Download file</a></p>'
content = f'''
<a href="/media" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to media
</a>
<div class="bg-dark-700 rounded-lg p-6">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white text-sm font-medium rounded-full">{media_type.capitalize()}</span>
<span class="text-gray-400 font-mono text-sm">{content_hash[:24]}...</span>
</div>
<a href="/cache/{content_hash}/raw" download
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
Download
</a>
</div>
<div class="flex justify-center mb-8">
{media_html}
</div>
<div class="border-t border-dark-500 pt-6">
<h2 class="text-lg font-semibold text-white mb-4">Details</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Content Hash (SHA3-256)</div>
<div class="font-mono text-xs text-gray-200 break-all">{content_hash}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Type</div>
<div class="text-gray-200">{media_type}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Size</div>
<div class="text-gray-200">{size_str}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Raw URL</div>
<div class="text-blue-400 text-sm truncate">
<a href="/cache/{content_hash}/raw" class="hover:text-blue-300">/cache/{content_hash}/raw</a>
</div>
</div>
</div>
</div>
'''
# Add IPFS section if we have a CID
if ipfs_cid:
gateway_links = []
if IPFS_GATEWAY_URL:
gateway_links.append(f'''
<a href="{IPFS_GATEWAY_URL}/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors">
Local Gateway
</a>''')
gateway_links.extend([
f'''<a href="https://ipfs.io/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
ipfs.io
</a>''',
f'''<a href="https://dweb.link/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
dweb.link
</a>''',
f'''<a href="https://cloudflare-ipfs.com/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
Cloudflare
</a>''',
])
gateways_html = '\n'.join(gateway_links)
content += f'''
<div class="border-t border-dark-500 pt-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">IPFS</h2>
<div class="bg-dark-600 rounded-lg p-4 mb-4">
<div class="text-sm text-gray-400 mb-1">Content Identifier (CID)</div>
<div class="font-mono text-xs text-gray-200 break-all">{ipfs_cid}</div>
</div>
<div class="text-sm text-gray-400 mb-2">Gateways:</div>
<div class="flex flex-wrap gap-2">
{gateways_html}
</div>
</div>
'''
else:
content += '''
<div class="border-t border-dark-500 pt-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">IPFS</h2>
<div class="text-gray-400 text-sm">Not yet uploaded to IPFS</div>
</div>
'''
content += f'''
<!-- Metadata Section -->
<div class="border-t border-dark-500 pt-6 mt-6" id="metadata-section"
hx-get="/cache/{content_hash}/meta-form" hx-trigger="load" hx-swap="innerHTML">
<div class="text-gray-400">Loading metadata...</div>
</div>
</div>
'''
return HTMLResponse(render_page(f"Cache: {content_hash[:16]}...", content, ctx.actor_id, active_tab="media"))
# Default: return raw file
return FileResponse(cache_path)
@app.get("/cache/{content_hash}/raw")
async def get_cached_raw(content_hash: str):
"""Get raw cached content (file download)."""
cache_path = get_cache_path(content_hash)
if not cache_path:
raise HTTPException(404, f"Content {content_hash} not in cache")
@@ -1724,172 +1895,6 @@ async def get_cached_mp4(content_hash: str):
return FileResponse(mp4_path, media_type="video/mp4")
@app.get("/cache/{content_hash}/detail")
async def cache_detail(content_hash: str, request: Request):
"""View cached content detail. HTML for browsers, JSON for APIs."""
ctx = get_user_context_from_cookie(request)
cache_path = get_cache_path(content_hash)
if not cache_path:
if wants_html(request):
content = f'<p class="text-red-400">Content not found: {content_hash}</p>'
return HTMLResponse(render_page("Not Found", content, ctx.actor_id if ctx else None, active_tab="media"), status_code=404)
raise HTTPException(404, f"Content {content_hash} not in cache")
if wants_html(request):
if not ctx:
content = '<p class="text-gray-400 py-8 text-center"><a href="/login" class="text-blue-400 hover:text-blue-300">Login via L2</a> to view cached content.</p>'
return HTMLResponse(render_page("Login Required", content, None, active_tab="media"), status_code=401)
# Check user has access
user_hashes = await get_user_cache_hashes(ctx.username, ctx.actor_id)
if content_hash not in user_hashes:
content = '<p class="text-red-400 py-8 text-center">Access denied.</p>'
return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="media"), status_code=403)
media_type = detect_media_type(cache_path)
file_size = cache_path.stat().st_size
size_str = f"{file_size:,} bytes"
if file_size > 1024*1024:
size_str = f"{file_size/(1024*1024):.1f} MB"
elif file_size > 1024:
size_str = f"{file_size/1024:.1f} KB"
# Get IPFS CID from database
cache_item = await database.get_cache_item(content_hash)
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
# Build media display HTML
if media_type == "video":
video_src = video_src_for_request(content_hash, request)
media_html = f'<video src="{video_src}" controls autoplay muted loop playsinline class="max-w-full max-h-96 rounded-lg"></video>'
elif media_type == "image":
media_html = f'<img src="/cache/{content_hash}" alt="{content_hash}" class="max-w-full max-h-96 rounded-lg">'
else:
media_html = f'<p class="text-gray-400">Unknown file type. <a href="/cache/{content_hash}" download class="text-blue-400 hover:text-blue-300">Download file</a></p>'
content = f'''
<a href="/media" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to media
</a>
<div class="bg-dark-700 rounded-lg p-6">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white text-sm font-medium rounded-full">{media_type.capitalize()}</span>
<span class="text-gray-400 font-mono text-sm">{content_hash[:24]}...</span>
</div>
<a href="/cache/{content_hash}" download
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
Download
</a>
</div>
<div class="flex justify-center mb-8">
{media_html}
</div>
<div class="border-t border-dark-500 pt-6">
<h2 class="text-lg font-semibold text-white mb-4">Details</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Content Hash (SHA3-256)</div>
<div class="font-mono text-xs text-gray-200 break-all">{content_hash}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Type</div>
<div class="text-gray-200">{media_type}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Size</div>
<div class="text-gray-200">{size_str}</div>
</div>
<div class="bg-dark-600 rounded-lg p-4">
<div class="text-sm text-gray-400 mb-1">Raw URL</div>
<div class="text-blue-400 text-sm truncate">
<a href="/cache/{content_hash}" class="hover:text-blue-300">/cache/{content_hash}</a>
</div>
</div>
</div>
</div>
'''
# Add IPFS section if we have a CID
if ipfs_cid:
# Build gateway links - local gateway first if configured
gateway_links = []
if IPFS_GATEWAY_URL:
gateway_links.append(f'''
<a href="{IPFS_GATEWAY_URL}/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors">
Local Gateway
</a>''')
gateway_links.extend([
f'''<a href="https://ipfs.io/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
ipfs.io
</a>''',
f'''<a href="https://dweb.link/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
dweb.link
</a>''',
f'''<a href="https://cloudflare-ipfs.com/ipfs/{ipfs_cid}" target="_blank" rel="noopener"
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors">
Cloudflare
</a>''',
])
gateways_html = '\n'.join(gateway_links)
content += f'''
<div class="border-t border-dark-500 pt-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">IPFS</h2>
<div class="bg-dark-600 rounded-lg p-4 mb-4">
<div class="text-sm text-gray-400 mb-1">Content Identifier (CID)</div>
<div class="font-mono text-xs text-gray-200 break-all">{ipfs_cid}</div>
</div>
<div class="text-sm text-gray-400 mb-2">Gateways:</div>
<div class="flex flex-wrap gap-2">
{gateways_html}
</div>
</div>
'''
else:
content += '''
<div class="border-t border-dark-500 pt-6 mt-6">
<h2 class="text-lg font-semibold text-white mb-4">IPFS</h2>
<div class="text-gray-400 text-sm">Not yet uploaded to IPFS</div>
</div>
'''
content += f'''
<!-- Metadata Section -->
<div class="border-t border-dark-500 pt-6 mt-6" id="metadata-section"
hx-get="/cache/{content_hash}/meta-form" hx-trigger="load" hx-swap="innerHTML">
<div class="text-gray-400">Loading metadata...</div>
</div>
</div>
'''
return HTMLResponse(render_page(f"Cache: {content_hash[:16]}...", content, ctx.actor_id, active_tab="media"))
# JSON response - return metadata
meta = await database.load_item_metadata(content_hash, ctx.actor_id if ctx else None)
cache_item = await database.get_cache_item(content_hash)
ipfs_cid = cache_item.get("ipfs_cid") if cache_item else None
file_size = cache_path.stat().st_size
media_type = detect_media_type(cache_path)
return {
"content_hash": content_hash,
"size": file_size,
"media_type": media_type,
"ipfs_cid": ipfs_cid,
"meta": meta
}
@app.get("/cache/{content_hash}/meta-form", response_class=HTMLResponse)
async def cache_meta_form(content_hash: str, request: Request):
"""Clean URL redirect to the HTMX meta form."""
@@ -1898,12 +1903,6 @@ async def cache_meta_form(content_hash: str, request: Request):
return RedirectResponse(f"/ui/cache/{content_hash}/meta-form", status_code=302)
@app.get("/ui/cache/{content_hash}")
async def ui_cache_view(content_hash: str):
"""Redirect to clean URL."""
return RedirectResponse(url=f"/cache/{content_hash}/detail", status_code=302)
@app.get("/ui/cache/{content_hash}/meta-form", response_class=HTMLResponse)
async def ui_cache_meta_form(content_hash: str, request: Request):
"""HTMX partial: metadata editing form for a cached item."""
@@ -2387,7 +2386,7 @@ async def list_media(
size_str = f"{size} bytes"
html_parts.append(f'''
<a href="/cache/{content_hash}/detail" class="block">
<a href="/cache/{content_hash}" class="block">
<div class="bg-dark-700 rounded-lg p-4 hover:bg-dark-600 transition-colors">
<div class="flex items-center justify-between gap-2 mb-3">
<span class="px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">{media_type}</span>