import json import re import httpx from fastapi import Depends, FastAPI, Request from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse) from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from supabase import create_client from app.auth import Auth0Bearer from app.config import settings from app.file_serving import ( HTML_EXTS, HTML_GZ_EXT, IMAGE_EXTS, PDF_EXT, serve_html_file, serve_html_gz_file, serve_image_file, serve_binary_file ) app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="static") supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY) # --- Load and flatten mapping.json from Supabase bucket --- def fetch_mapping_json(): resp = supabase.storage.from_(settings.BUCKET_NAME).download("mapping.json") if hasattr(resp, "read"): content = resp.read() else: content = resp if isinstance(content, bytes): content = content.decode("utf-8", errors="replace") return json.loads(content) def flatten_mapping(node, parent_path=""): """Recursively flattens a nested mapping.json structure into a path:uuid dict.""" mapping = {} if node["type"] == "file": full_path = parent_path + node["name"] mapping[full_path] = node["random_filename"] elif node["type"] == "folder": folder_path = parent_path + node["name"] + "/" for child in node.get("children", []): mapping.update(flatten_mapping(child, folder_path)) return mapping mapping_json = fetch_mapping_json() file_mapping = flatten_mapping(mapping_json) @app.get("/file/{file_path:path}") async def serve_file(file_path: str, user=Depends(Auth0Bearer())): """Serves HTML, image, or other files from Supabase storage, mapping logical to UUID paths.""" mapped_path = file_mapping.get(f"root/{file_path}", file_path) file_resp = supabase.storage.from_(settings.BUCKET_NAME).download(mapped_path) # Serve pre-compressed .html.gz files as gzip if file_path.endswith(HTML_GZ_EXT): return serve_html_gz_file(file_resp, file_path) # Serve .html and .htm files as HTMLResponse (utf-8) if any(file_path.endswith(ext) for ext in HTML_EXTS): return serve_html_file(file_resp, file_path) # Handle image files if any(file_path.lower().endswith(ext) for ext in IMAGE_EXTS): return serve_image_file(file_resp, file_path) # Default: serve as download (binary) return serve_binary_file(file_resp, file_path) # Serve raw files for embedding (PDF iframe) @app.get("/file/raw/{file_path:path}") async def serve_raw_file(file_path: str, user=Depends(Auth0Bearer())): mapped_path = file_mapping.get(file_path, file_path) file_resp = supabase.storage.from_(settings.BUCKET_NAME).download(mapped_path) # Handle PDF if file_path.endswith(PDF_EXT): return serve_binary_file(file_resp, file_path, media_type="application/pdf") # Default: serve as binary return serve_binary_file(file_resp, file_path, media_type="application/octet-stream") @app.get("/login") async def login(): login_url = ( f"https://{settings.AUTH0_DOMAIN}/authorize?audience={settings.API_AUDIENCE}" f"&response_type=code&client_id={settings.AUTH0_CLIENT_ID}" f"&redirect_uri={settings.AUTH0_CALLBACK_URL}" ) return RedirectResponse(login_url) @app.get("/logout") async def logout(): response = RedirectResponse( f"https://{settings.AUTH0_DOMAIN}/v2/logout?client_id={settings.AUTH0_CLIENT_ID}&returnTo={settings.AUTH0_LOGOUT_REDIRECT_URL}" ) response.delete_cookie("access_token") return response @app.get("/callback", response_class=HTMLResponse) async def callback(request: Request): code = request.query_params.get("code") if not code: return HTMLResponse("

Login failed: No code found.

", status_code=400) token_url = f"https://{settings.AUTH0_DOMAIN}/oauth/token" headers = {"content-type": "application/x-www-form-urlencoded"} data = { "grant_type": "authorization_code", "client_id": settings.AUTH0_CLIENT_ID, "client_secret": settings.AUTH0_CLIENT_SECRET, "code": code, "redirect_uri": settings.AUTH0_CALLBACK_URL, } async with httpx.AsyncClient() as client: token_resp = await client.post(token_url, data=data, headers=headers) if token_resp.status_code != 200: return HTMLResponse( f"

Token exchange failed: {token_resp.text}

", status_code=400 ) tokens = token_resp.json() access_token = tokens.get("access_token") if not access_token: return HTMLResponse( "

Token exchange failed: No access token.

", status_code=400 ) response = RedirectResponse("/sdp") response.set_cookie( key="access_token", value=access_token, httponly=True, secure=False ) return response @app.get("/", response_class=HTMLResponse) async def hello_world(request: Request): # Render a login button at the top left with dark mode using a template return templates.TemplateResponse("login.html", {"request": request}) def extract_prefix(name): match = re.match(r"(\d+)\.", name) return int(match.group(1)) if match else float('inf') def render_bootstrap_tree(node, parent_path="", is_root=False): if node["type"] == "folder": li_class = "root-folder" if is_root else "" html = f'
  • ' \ + f'' \ + f'' \ + f'{node["name"]}/' \ + f'' if node.get("children"): children = node["children"] folders = [c for c in children if c["type"] == "folder"] files = [c for c in children if c["type"] == "file"] folders = sorted(folders, key=lambda x: extract_prefix(x["name"])) files = sorted(files, key=lambda x: extract_prefix(x["name"])) sorted_children = folders + files html += '' html += '
  • ' return html elif node["type"] == "file": file_path = parent_path + node["name"] html = ( f'
  • ' f'' f'{node["name"]}' f'
  • ' ) return html return "" @app.get("/sdp", response_class=HTMLResponse) async def show_bucket_tree(request: Request, user=Depends(Auth0Bearer())): tree_html = "".join(render_bootstrap_tree(child, is_root=True) for child in mapping_json["children"]) return templates.TemplateResponse( "tree.html", {"request": request, "tree_html": tree_html} ) @app.api_route("/health", methods=["GET", "HEAD"]) async def health(): return JSONResponse({"status": "ok"}) @app.middleware("http") async def check_auth(request: Request, call_next): public_paths = ["/login", "/logout", "/callback", "/health", "/"] if request.url.path not in public_paths: auth = request.headers.get("authorization") cookie_token = request.cookies.get("access_token") if not auth and not cookie_token: return RedirectResponse("/login") response = await call_next(request) return response