main.py (9019B)
1 import os 2 import shutil 3 import uuid 4 import mimetypes 5 import io 6 import zipfile 7 import httpx 8 from pathlib import Path 9 from fastapi import FastAPI, Request, File, UploadFile, HTTPException 10 from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse 11 from fastapi.templating import Jinja2Templates 12 13 # Added format_size to the import list 14 from .storage import is_file, list_directory, list_files_recursive, upload_temp_file, repo_stats, HF_REPO_ID, format_size 15 16 app = FastAPI() 17 18 templates = Jinja2Templates(directory="app/templates") 19 20 STATIC_DIR = Path(__file__).parent / "static" 21 22 _NO_STORE_FILES = {"manifest.json", "sw.js"} 23 24 @app.get("/static/{filename}") 25 async def static_no_cache_root(filename: str): 26 if filename not in _NO_STORE_FILES: 27 raise HTTPException(status_code=404) 28 file_path = STATIC_DIR / filename 29 if not file_path.is_file(): 30 raise HTTPException(status_code=404) 31 return FileResponse( 32 file_path, 33 headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, 34 ) 35 36 @app.get("/static/icons/{filename}") 37 async def static_icons(filename: str): 38 file_path = STATIC_DIR / "icons" / filename 39 if not file_path.is_file(): 40 raise HTTPException(status_code=404) 41 return FileResponse( 42 file_path, 43 headers={"Cache-Control": "public, max-age=86400"}, 44 ) 45 46 @app.get("/favicon.ico") 47 async def favicon(): 48 return FileResponse( 49 STATIC_DIR / "favicon.ico", 50 headers={"Cache-Control": "public, max-age=86400"}, 51 ) 52 53 CDN_BASE_URL = os.getenv("CDN_BASE_URL", "https://cdn-zt7p.onrender.com") 54 RAW_DOMAIN = os.getenv("RAW_DOMAIN", "raw.cdn.amit.is-a.dev") 55 RAW_BASE_URL = os.getenv("RAW_BASE_URL", f"https://{RAW_DOMAIN}") 56 RAW_PREFIX = "raw/" 57 58 def render_context(extra: dict) -> dict: 59 stats = repo_stats() 60 ctx = dict(extra) 61 ctx["repo_file_count"] = stats["file_count"] if stats else None 62 ctx["repo_size_str"] = stats["size_str"] if stats else None 63 return ctx 64 65 @app.exception_handler(404) 66 async def not_found_handler(request: Request, exc: HTTPException): 67 if request.url.hostname == RAW_DOMAIN: 68 return PlainTextResponse("404: File Not Found", status_code=404) 69 return templates.TemplateResponse(request, "index.html", render_context({"page": "404"}), status_code=404) 70 71 async def stream_raw(path: str): 72 hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" 73 client = httpx.AsyncClient(follow_redirects=True) 74 req = client.build_request("GET", hf_url) 75 r = await client.send(req, stream=True) 76 77 if r.status_code != 200: 78 await client.aclose() 79 raise HTTPException(status_code=404, detail="File not found") 80 81 headers = { 82 "Access-Control-Allow-Origin": "*", 83 "Cache-Control": "public, max-age=31536000", 84 "X-Content-Type-Options": "nosniff" 85 } 86 87 # Safely proxy critical headers (like Content-Encoding for gzip handling) 88 for h in ["Content-Type", "Content-Encoding", "Content-Length", "Etag", "Accept-Ranges"]: 89 if h in r.headers: 90 headers[h] = r.headers[h] 91 92 # Explicit override for text files in case HF serves as octet-stream 93 filename = path.split("/")[-1].lower() 94 if filename.endswith(".md") or filename.endswith(".txt"): 95 if headers.get("Content-Type", "application/octet-stream") == "application/octet-stream": 96 headers["Content-Type"] = "text/plain; charset=utf-8" 97 98 async def stream_generator(): 99 try: 100 # Using aiter_raw guarantees we don't accidentally decompress the stream if it's encoded 101 async for chunk in r.aiter_raw(): 102 yield chunk 103 finally: 104 await client.aclose() 105 106 return StreamingResponse(stream_generator(), headers=headers) 107 108 @app.get("/api/download/{path:path}") 109 async def download_file(path: str): 110 hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{path}" 111 client = httpx.AsyncClient(follow_redirects=True) 112 req = client.build_request("GET", hf_url) 113 r = await client.send(req, stream=True) 114 115 if r.status_code != 200: 116 await client.aclose() 117 raise HTTPException(status_code=404, detail="Not found") 118 119 filename = path.split("/")[-1] 120 headers = { 121 "Content-Disposition": f'attachment; filename="{filename}"', 122 "Content-Type": r.headers.get("Content-Type", "application/octet-stream") 123 } 124 125 # Forward length and encoding to allow browser progress bars and raw file integrity 126 for h in ["Content-Length", "Content-Encoding", "Etag"]: 127 if h in r.headers: 128 headers[h] = r.headers[h] 129 130 async def stream_generator(): 131 try: 132 async for chunk in r.aiter_raw(): 133 yield chunk 134 finally: 135 await client.aclose() 136 137 return StreamingResponse(stream_generator(), headers=headers) 138 139 @app.post("/api/upload") 140 async def handle_upload(files: list[UploadFile] = File(...)): 141 results = [] 142 for file in files: 143 temp_path = f"/tmp/{uuid.uuid4()}-{file.filename}" 144 with open(temp_path, "wb") as f: 145 shutil.copyfileobj(file.file, f) 146 147 try: 148 hf_path = upload_temp_file(temp_path, file.filename) 149 finally: 150 if os.path.exists(temp_path): 151 os.remove(temp_path) 152 153 results.append({ 154 "filename": file.filename, 155 "cdn_url": f"{CDN_BASE_URL}/{hf_path}", 156 "raw_url": f"{RAW_BASE_URL}/{hf_path}" 157 }) 158 159 return {"files": results} 160 161 @app.get("/api/zip-stats/{path:path}") 162 async def zip_stats(path: str): 163 clean_path = path.strip("/") 164 try: 165 files = list_files_recursive(clean_path) 166 except ValueError as e: 167 raise HTTPException(status_code=413, detail=str(e)) 168 169 if not files: 170 raise HTTPException(status_code=404, detail="Folder is empty or not found") 171 172 total_size = sum(f["size"] for f in files) 173 return { 174 "file_count": len(files), 175 "total_size": total_size, 176 "size_str": format_size(total_size) 177 } 178 179 @app.get("/api/download-zip/{path:path}") 180 async def download_zip(path: str): 181 clean_path = path.strip("/") 182 183 try: 184 files = list_files_recursive(clean_path) 185 except ValueError as e: 186 raise HTTPException(status_code=413, detail=str(e)) 187 188 if not files: 189 raise HTTPException(status_code=404, detail="Folder is empty or not found") 190 191 prefix_len = len(clean_path.rstrip("/")) + 1 if clean_path else 0 192 193 buffer = io.BytesIO() 194 async with httpx.AsyncClient(follow_redirects=True) as client: 195 with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: 196 for f in files: 197 hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{f['path']}" 198 r = await client.get(hf_url) 199 if r.status_code != 200: 200 continue 201 arcname = f["path"][prefix_len:] if prefix_len else f["path"] 202 zf.writestr(arcname, r.content) 203 204 buffer.seek(0) 205 zip_filename = (clean_path.rstrip("/").split("/")[-1] if clean_path else HF_REPO_ID.split("/")[-1]) + ".zip" 206 207 return StreamingResponse( 208 buffer, 209 media_type="application/zip", 210 headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'} 211 ) 212 213 @app.api_route("/ping", methods=["GET", "HEAD"], response_class=PlainTextResponse) 214 async def ping(): 215 return "Server is awake!" 216 217 @app.api_route("/{path:path}", methods=["GET", "HEAD"]) 218 async def serve(request: Request, path: str): 219 clean_path = path.strip("/") 220 221 if request.url.hostname == RAW_DOMAIN: 222 if not clean_path: 223 return HTMLResponse("Specify a file path.", status_code=200) 224 return await stream_raw(clean_path) 225 226 if clean_path == RAW_PREFIX.rstrip("/") or clean_path.startswith(RAW_PREFIX): 227 raw_path = clean_path[len(RAW_PREFIX):] 228 if not raw_path: 229 return HTMLResponse("/raw/ — specify a file path after this prefix.", status_code=200) 230 return RedirectResponse(f"{RAW_BASE_URL}/{raw_path}") 231 232 if clean_path == "upload": 233 return templates.TemplateResponse(request, "index.html", render_context({"page": "upload"})) 234 235 if clean_path and await is_file(clean_path): 236 filename = clean_path.split("/")[-1] 237 return templates.TemplateResponse(request, "index.html", render_context({ 238 "page": "file", 239 "path": clean_path, 240 "filename": filename, 241 "raw_base_url": RAW_BASE_URL 242 })) 243 244 items = list_directory(clean_path) 245 if clean_path and not items: 246 raise HTTPException(status_code=404, detail="Not Found") 247 248 return templates.TemplateResponse(request, "index.html", render_context({ 249 "page": "listing", 250 "path": clean_path, 251 "items": items, 252 "raw_base_url": RAW_BASE_URL 253 }))