feat: per-user cache visibility and login protection

- Run detail page requires login and ownership check
- Cache uploads require login and track uploader metadata
- Cache list filtered to show only user's own files (uploaded or from runs)
- Cache detail view requires login and ownership check
- Add helper functions for cache metadata and user hash lookup

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 18:18:16 +00:00
parent e01db2ed26
commit 0cbfd87711

116
server.py
View File

@@ -364,8 +364,26 @@ async def get_cached(content_hash: str):
@app.get("/ui/cache/{content_hash}", response_class=HTMLResponse)
async def ui_cache_view(content_hash: str):
async def ui_cache_view(content_hash: str, request: Request):
"""View cached content with appropriate display."""
current_user = get_user_from_cookie(request)
if not current_user:
return HTMLResponse(f'''
<!DOCTYPE html>
<html>
<head><title>Login Required | Art DAG L1</title><style>{UI_CSS}</style></head>
<body>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<div class="run"><p><a href="/ui/login">Login</a> to view cached content.</p></div>
</body>
</html>
''', status_code=401)
# Check user has access to this file
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
return HTMLResponse('<h1>Access denied</h1>', status_code=403)
cache_path = CACHE_DIR / content_hash
if not cache_path.exists():
@@ -477,9 +495,55 @@ async def import_to_cache(path: str):
return {"content_hash": content_hash, "cached": True}
def save_cache_meta(content_hash: str, uploader: str, filename: str = None):
"""Save metadata for a cached file."""
meta_path = CACHE_DIR / f"{content_hash}.meta.json"
meta = {
"uploader": uploader,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"filename": filename
}
# Don't overwrite existing metadata (preserve original uploader)
if not meta_path.exists():
with open(meta_path, "w") as f:
json.dump(meta, f)
def load_cache_meta(content_hash: str) -> dict:
"""Load metadata for a cached file."""
meta_path = CACHE_DIR / f"{content_hash}.meta.json"
if meta_path.exists():
with open(meta_path) as f:
return json.load(f)
return {}
def get_user_cache_hashes(username: str) -> set:
"""Get all cache hashes owned by or associated with a user."""
actor_id = f"@{username}@{L2_DOMAIN}"
hashes = set()
# Files uploaded by user
if CACHE_DIR.exists():
for f in CACHE_DIR.iterdir():
if f.name.endswith('.meta.json'):
meta = load_cache_meta(f.name.replace('.meta.json', ''))
if meta.get("uploader") in (username, actor_id):
hashes.add(f.name.replace('.meta.json', ''))
# Files from user's runs (inputs and outputs)
for run in list_all_runs():
if run.username in (username, actor_id):
hashes.update(run.inputs)
if run.output_hash:
hashes.add(run.output_hash)
return hashes
@app.post("/cache/upload")
async def upload_to_cache(file: UploadFile = File(...)):
"""Upload a file to cache."""
async def upload_to_cache(file: UploadFile = File(...), username: str = Depends(get_required_user)):
"""Upload a file to cache. Requires authentication."""
# Write to temp file first
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as tmp:
@@ -497,6 +561,10 @@ async def upload_to_cache(file: UploadFile = File(...)):
else:
tmp_path.unlink()
# Save uploader metadata
actor_id = f"@{username}@{L2_DOMAIN}"
save_cache_meta(content_hash, actor_id, file.filename)
return {"content_hash": content_hash, "filename": file.filename, "size": len(content)}
@@ -927,23 +995,27 @@ async def ui_cache_list(request: Request):
if not current_user:
return '<p class="no-runs"><a href="/ui/login">Login</a> to see cached content.</p>'
# Get all cached files
# Get hashes owned by/associated with this user
user_hashes = get_user_cache_hashes(current_user)
# Get cache items that belong to the user
cache_items = []
if CACHE_DIR.exists():
for f in CACHE_DIR.iterdir():
if f.is_file() and not f.name.endswith('.provenance.json'):
stat = f.stat()
cache_items.append({
"hash": f.name,
"size": stat.st_size,
"mtime": stat.st_mtime
})
if f.is_file() and not f.name.endswith('.provenance.json') and not f.name.endswith('.meta.json'):
if f.name in user_hashes:
stat = f.stat()
cache_items.append({
"hash": f.name,
"size": stat.st_size,
"mtime": stat.st_mtime
})
# Sort by modification time (newest first)
cache_items.sort(key=lambda x: x["mtime"], reverse=True)
if not cache_items:
return '<p class="no-runs">Cache is empty.</p>'
return '<p class="no-runs">No cached files. Upload files or run effects to see them here.</p>'
html_parts = ['<div class="runs">']
@@ -996,12 +1068,30 @@ async def ui_cache_list(request: Request):
@app.get("/ui/detail/{run_id}", response_class=HTMLResponse)
async def ui_detail_page(run_id: str):
async def ui_detail_page(run_id: str, request: Request):
"""Full detail page for a run."""
current_user = get_user_from_cookie(request)
if not current_user:
return HTMLResponse(f'''
<!DOCTYPE html>
<html>
<head><title>Login Required | Art DAG L1</title><style>{UI_CSS}</style></head>
<body>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<div class="run"><p><a href="/ui/login">Login</a> to view run details.</p></div>
</body>
</html>
''', status_code=401)
run = load_run(run_id)
if not run:
return HTMLResponse('<h1>Run not found</h1>', status_code=404)
# Check user owns this run
actor_id = f"@{current_user}@{L2_DOMAIN}"
if run.username not in (current_user, actor_id):
return HTMLResponse('<h1>Access denied</h1>', status_code=403)
# Check Celery task status if running
if run.status == "running" and run.celery_task_id:
task = celery_app.AsyncResult(run.celery_task_id)