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:
357
server.py
357
server.py
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user