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)
|
@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."""
|
"""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
|
cache_path = CACHE_DIR / content_hash
|
||||||
|
|
||||||
if not cache_path.exists():
|
if not cache_path.exists():
|
||||||
@@ -477,9 +495,55 @@ async def import_to_cache(path: str):
|
|||||||
return {"content_hash": content_hash, "cached": True}
|
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")
|
@app.post("/cache/upload")
|
||||||
async def upload_to_cache(file: UploadFile = File(...)):
|
async def upload_to_cache(file: UploadFile = File(...), username: str = Depends(get_required_user)):
|
||||||
"""Upload a file to cache."""
|
"""Upload a file to cache. Requires authentication."""
|
||||||
# Write to temp file first
|
# Write to temp file first
|
||||||
import tempfile
|
import tempfile
|
||||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||||
@@ -497,6 +561,10 @@ async def upload_to_cache(file: UploadFile = File(...)):
|
|||||||
else:
|
else:
|
||||||
tmp_path.unlink()
|
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)}
|
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:
|
if not current_user:
|
||||||
return '<p class="no-runs"><a href="/ui/login">Login</a> to see cached content.</p>'
|
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 = []
|
cache_items = []
|
||||||
if CACHE_DIR.exists():
|
if CACHE_DIR.exists():
|
||||||
for f in CACHE_DIR.iterdir():
|
for f in CACHE_DIR.iterdir():
|
||||||
if f.is_file() and not f.name.endswith('.provenance.json'):
|
if f.is_file() and not f.name.endswith('.provenance.json') and not f.name.endswith('.meta.json'):
|
||||||
stat = f.stat()
|
if f.name in user_hashes:
|
||||||
cache_items.append({
|
stat = f.stat()
|
||||||
"hash": f.name,
|
cache_items.append({
|
||||||
"size": stat.st_size,
|
"hash": f.name,
|
||||||
"mtime": stat.st_mtime
|
"size": stat.st_size,
|
||||||
})
|
"mtime": stat.st_mtime
|
||||||
|
})
|
||||||
|
|
||||||
# Sort by modification time (newest first)
|
# Sort by modification time (newest first)
|
||||||
cache_items.sort(key=lambda x: x["mtime"], reverse=True)
|
cache_items.sort(key=lambda x: x["mtime"], reverse=True)
|
||||||
|
|
||||||
if not cache_items:
|
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">']
|
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)
|
@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."""
|
"""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)
|
run = load_run(run_id)
|
||||||
if not run:
|
if not run:
|
||||||
return HTMLResponse('<h1>Run not found</h1>', status_code=404)
|
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
|
# Check Celery task status if running
|
||||||
if run.status == "running" and run.celery_task_id:
|
if run.status == "running" and run.celery_task_id:
|
||||||
task = celery_app.AsyncResult(run.celery_task_id)
|
task = celery_app.AsyncResult(run.celery_task_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user