uploads

Log | Files | Refs

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 &middot; {len(files)} file{'s' if len(files) != 1 else ''}</h1>
    243     {body}
    244   </div>
    245 </body>
    246 </html>"""
© notamitgamer • Site Built: 2026-07-21 13:58:23 UTC • git-mirror commit: 1037f62 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror