From 0cbfd8771183835a98f352b72578e69f62afed9f Mon Sep 17 00:00:00 2001 From: gilesb Date: Wed, 7 Jan 2026 18:18:16 +0000 Subject: [PATCH] feat: per-user cache visibility and login protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/server.py b/server.py index fba56e6..f11778d 100644 --- a/server.py +++ b/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''' + + +Login Required | Art DAG L1 + +

Art DAG L1 Server

+

Login to view cached content.

+ + +''', 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('

Access denied

', 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 '

Login to see cached content.

' - # 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 '

Cache is empty.

' + return '

No cached files. Upload files or run effects to see them here.

' html_parts = ['
'] @@ -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''' + + +Login Required | Art DAG L1 + +

Art DAG L1 Server

+

Login to view run details.

+ + +''', status_code=401) + run = load_run(run_id) if not run: return HTMLResponse('

Run not found

', 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('

Access denied

', 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)