index.py (8639B)
1 import os 2 import re 3 import json 4 import random 5 import string 6 from datetime import datetime, timezone 7 from typing import List, Optional 8 9 import httpx 10 from fastapi import FastAPI, File, UploadFile, Form, HTTPException 11 from fastapi.responses import HTMLResponse 12 from huggingface_hub import HfApi, hf_hub_download 13 from huggingface_hub.utils import EntryNotFoundError 14 15 # Fix for Vercel's read-only file system (os error 30) 16 os.environ["HF_HOME"] = "/tmp" 17 os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0" 18 19 app = FastAPI(docs_url=None, redoc_url=None) 20 21 DATASET_REPO = "notamitgamer/uploads" 22 HF_TOKEN = os.getenv("HF_TOKEN") 23 MAX_UPLOAD_BYTES = int(4.4 * 1024 * 1024) # stay under Vercel's 4.5MB body limit 24 25 # --------------------------------------------------------------------------- 26 # Helpers 27 # --------------------------------------------------------------------------- 28 29 _SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9._-]+") 30 31 def sanitize_id(raw: str, ext: str) -> str: 32 """Turn a user-supplied custom_id into a safe repo path segment.""" 33 raw = raw.strip() 34 raw = raw.replace("/", "-").replace("\\", "-") 35 raw = _SAFE_ID_RE.sub("", raw) 36 raw = raw.lstrip(".-") 37 if not raw: 38 raise HTTPException(status_code=400, detail="custom_id is invalid after sanitization") 39 raw = raw[:80] 40 if ext and not raw.endswith(ext): 41 raw += ext 42 return raw 43 44 def random_id(ext: str) -> str: 45 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 46 random_str = "".join(random.choices(string.ascii_letters + string.digits, k=6)) 47 return f"{timestamp}-{random_str}{ext}" 48 49 def random_batch_id() -> str: 50 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 51 random_str = "".join(random.choices(string.ascii_letters + string.digits, k=8)) 52 return f"{timestamp}-{random_str}" 53 54 def get_api() -> HfApi: 55 if not HF_TOKEN: 56 raise HTTPException(status_code=500, detail="HF_TOKEN environment variable is not set") 57 return HfApi() 58 59 def ensure_repo(api: HfApi): 60 api.create_repo( 61 repo_id=DATASET_REPO, 62 repo_type="dataset", 63 exist_ok=True, 64 token=HF_TOKEN, 65 private=False, 66 ) 67 68 def upload_bytes(api: HfApi, content: bytes, path_in_repo: str): 69 api.upload_file( 70 path_or_fileobj=content, 71 path_in_repo=path_in_repo, 72 repo_id=DATASET_REPO, 73 repo_type="dataset", 74 token=HF_TOKEN, 75 ) 76 77 # --------------------------------------------------------------------------- 78 # Upload a single file 79 # --------------------------------------------------------------------------- 80 81 def handle_one_upload( 82 api: HfApi, 83 filename: str, 84 content: bytes, 85 custom_id: Optional[str] 86 ) -> dict: 87 ext = os.path.splitext(filename)[1] 88 item_id = sanitize_id(custom_id, ext) if custom_id else random_id(ext) 89 90 upload_bytes(api, content, item_id) 91 92 # Since expiry is removed, all links just use the root static redirect 93 link_path = f"/{item_id}" 94 95 return { 96 "item_id": item_id, 97 "link_path": link_path, 98 "size": len(content), 99 } 100 101 # --------------------------------------------------------------------------- 102 # Routes 103 # --------------------------------------------------------------------------- 104 105 @app.post("/api/upload") 106 async def upload_files( 107 files: List[UploadFile] = File(...), 108 custom_id: Optional[str] = Form(None) 109 ): 110 api = get_api() 111 ensure_repo(api) 112 113 if custom_id and len(files) > 1: 114 raise HTTPException(status_code=400, detail="custom_id can only be used when uploading a single file") 115 116 results = [] 117 for f in files: 118 content = await f.read() 119 if len(content) > MAX_UPLOAD_BYTES: 120 raise HTTPException( 121 status_code=413, 122 detail=f"'{f.filename}' exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB request limit", 123 ) 124 try: 125 result = handle_one_upload(api, f.filename or "file", content, custom_id) 126 results.append(result) 127 except HTTPException: 128 raise 129 except Exception as e: 130 raise HTTPException(status_code=500, detail=f"Failed to upload '{f.filename}': {e}") 131 132 batch_path = None 133 if len(results) > 1: 134 batch_id = random_batch_id() 135 manifest = { 136 "item_ids": [r["item_id"] for r in results], 137 "created_at": datetime.now(timezone.utc).isoformat() 138 } 139 upload_bytes(api, json.dumps(manifest).encode(), f"_batches/{batch_id}.json") 140 batch_path = f"/batch/{batch_id}" 141 142 return {"files": results, "batch_path": batch_path} 143 144 @app.post("/api/upload-url") 145 async def upload_from_url( 146 url: str = Form(...), 147 custom_id: Optional[str] = Form(None) 148 ): 149 if not url.startswith(("http://", "https://")): 150 raise HTTPException(status_code=400, detail="Only http/https URLs are supported") 151 152 api = get_api() 153 ensure_repo(api) 154 155 try: 156 async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client: 157 resp = await client.get(url) 158 resp.raise_for_status() 159 content = resp.content 160 except httpx.HTTPError as e: 161 raise HTTPException(status_code=400, detail=f"Could not fetch URL: {e}") 162 163 if len(content) > MAX_UPLOAD_BYTES: 164 raise HTTPException( 165 status_code=413, 166 detail=f"Remote file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB limit", 167 ) 168 169 filename = url.split("/")[-1].split("?")[0] or "file" 170 try: 171 result = handle_one_upload(api, filename, content, custom_id) 172 except HTTPException: 173 raise 174 except Exception as e: 175 raise HTTPException(status_code=500, detail=str(e)) 176 177 return {"files": [result]} 178 179 @app.get("/batch/{batch_id}", response_class=HTMLResponse) 180 async def serve_batch(batch_id: str): 181 """Renders a simple page listing every file uploaded together in one batch.""" 182 try: 183 manifest_path = hf_hub_download( 184 repo_id=DATASET_REPO, 185 repo_type="dataset", 186 filename=f"_batches/{batch_id}.json", 187 token=HF_TOKEN, 188 cache_dir="/tmp" # Explicitly force /tmp to avoid Vercel Errno 30 189 ) 190 with open(manifest_path) as fh: 191 manifest = json.load(fh) 192 except EntryNotFoundError: 193 return HTMLResponse(status_code=404, content=_batch_html([], not_found=True)) 194 except Exception as e: 195 raise HTTPException(status_code=500, detail=str(e)) 196 197 item_ids = manifest.get("item_ids", []) 198 files = [{"item_id": i, "link_path": f"/{i}"} for i in item_ids] 199 200 return HTMLResponse(content=_batch_html(files)) 201 202 def _batch_html(files: list, not_found: bool = False) -> str: 203 if not_found: 204 body = '<p class="empty">Batch not found.</p>' 205 elif not files: 206 body = '<p class="empty">This batch is empty.</p>' 207 else: 208 rows = "\n".join( 209 f'''<div class="row"> 210 <span class="name">{f["item_id"]}</span> 211 <a class="open" href="{f["link_path"]}" target="_blank">Open</a> 212 </div>''' 213 for f in files 214 ) 215 body = f'<div class="list">{rows}</div>' 216 217 return f"""<!DOCTYPE html> 218 <html lang="en"> 219 <head> 220 <meta charset="UTF-8"> 221 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 222 <title>Batch Upload</title> 223 <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet"> 224 <style> 225 body {{ font-family: 'Lexend', sans-serif; background: #FDFCFB; color: #1A1C19; margin: 0; padding: 2rem 1rem; }} 226 @media (prefers-color-scheme: dark) {{ body {{ background: #1A1C19; color: #E3E3DC; }} }} 227 .wrap {{ max-width: 520px; margin: 0 auto; }} 228 h1 {{ font-size: 1.4rem; margin-bottom: 1.5rem; }} 229 .list {{ display: flex; flex-direction: column; gap: 0.5rem; }} 230 .row {{ display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; 231 background: #F1F1EA; padding: 0.9rem 1rem; border-radius: 12px; }} 232 @media (prefers-color-scheme: dark) {{ .row {{ background: #2D2F2B; }} }} 233 .name {{ font-size: 0.85rem; word-break: break-all; }} 234 .open {{ flex-shrink: 0; background: #386A20; color: #fff; text-decoration: none; 235 font-size: 0.85rem; font-weight: 600; padding: 0.45rem 0.9rem; border-radius: 8px; }} 236 @media (prefers-color-scheme: dark) {{ .open {{ background: #9CD67D; color: #0C3900; }} }} 237 .empty {{ font-size: 0.9rem; opacity: 0.7; }} 238 </style> 239 </head> 240 <body> 241 <div class="wrap"> 242 <h1>Batch upload · {len(files)} file{'s' if len(files) != 1 else ''}</h1> 243 {body} 244 </div> 245 </body> 246 </html>"""