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