cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / storage.py

storage.py (14349B)


      1 import os
      2 import re
      3 import time
      4 import unicodedata
      5 import uuid
      6 import json
      7 import tempfile
      8 import asyncio
      9 import httpx
     10 from huggingface_hub import HfApi
     11 
     12 # Files live in a Hugging Face Storage Bucket (mutable, non-versioned object
     13 # storage) instead of a git-backed dataset repo: every write is a plain object
     14 # write rather than a commit, so uploads and folder syncs avoid git overhead and
     15 # commit rate limits. HF_REPO_ID is still honoured as a fallback so existing
     16 # deployments keep working until HF_BUCKET_ID is set.
     17 HF_BUCKET_ID = os.getenv("HF_BUCKET_ID") or os.getenv("HF_REPO_ID", "notamitgamer/cdn")
     18 HF_REPO_ID = HF_BUCKET_ID  # backwards-compatible alias used by older imports
     19 HF_TOKEN = os.getenv("HF_TOKEN")
     20 BUCKET_PRIVATE = os.getenv("HF_BUCKET_PRIVATE", "0") == "1"
     21 CACHE_TTL = 60
     22 MAX_CACHE_SIZE = 500
     23 ZIP_MAX_FILES = 300
     24 ZIP_MAX_TOTAL_BYTES = 500 * 1024 * 1024
     25 REPO_STATS_CACHE_TTL = 300
     26 SEARCH_RESULT_LIMIT = 10
     27 
     28 api = HfApi(token=HF_TOKEN)
     29 
     30 
     31 def bucket_url(path: str) -> str:
     32     """Direct (CDN-backed) download URL for an object in the bucket."""
     33     return f"https://huggingface.co/buckets/{HF_BUCKET_ID}/resolve/{path}"
     34 
     35 
     36 def auth_headers() -> dict:
     37     """Bearer header, only needed when the bucket is private."""
     38     if BUCKET_PRIVATE and HF_TOKEN:
     39         return {"Authorization": f"Bearer {HF_TOKEN}"}
     40     return {}
     41 
     42 
     43 _bucket_ready = False
     44 
     45 
     46 def _ensure_bucket():
     47     global _bucket_ready
     48     if _bucket_ready:
     49         return
     50     api.create_bucket(HF_BUCKET_ID, private=BUCKET_PRIVATE, exist_ok=True)
     51     _bucket_ready = True
     52 
     53 _cache = {}
     54 
     55 _key_locks: dict[str, asyncio.Lock] = {}
     56 
     57 def _get_key_lock(key: str) -> asyncio.Lock:
     58     lock = _key_locks.get(key)
     59     if lock is None:
     60         lock = asyncio.Lock()
     61         _key_locks[key] = lock
     62         
     63         if len(_key_locks) > MAX_CACHE_SIZE:
     64             _key_locks.pop(next(iter(_key_locks)))
     65     return lock
     66 
     67 def _get_cache(key):
     68     if key in _cache:
     69         expiry, value = _cache[key]
     70         if expiry > time.time():
     71             _cache[key] = _cache.pop(key)
     72             return value
     73         del _cache[key]
     74     return None
     75 
     76 def _set_cache(key, value):
     77     if key in _cache:
     78         del _cache[key]
     79     elif len(_cache) >= MAX_CACHE_SIZE:
     80         oldest_key = next(iter(_cache))
     81         del _cache[oldest_key]
     82 
     83     _cache[key] = (time.time() + CACHE_TTL, value)
     84 
     85 async def is_file(path: str) -> bool:
     86     info = await get_file_info(path)
     87     return info["exists"]
     88 
     89 async def get_file_info(path: str) -> dict:
     90     if not path:
     91         return {"exists": False, "size": None, "content_type": None}
     92 
     93     cache_key = f"file_info_{path}"
     94     cached = _get_cache(cache_key)
     95     if cached is not None:
     96         return cached
     97 
     98     async with _get_key_lock(cache_key):
     99         cached = _get_cache(cache_key)
    100         if cached is not None:
    101             return cached
    102 
    103         hf_url = bucket_url(path)
    104         async with httpx.AsyncClient(follow_redirects=True) as client:
    105             r = await client.head(hf_url, headers=auth_headers())
    106             exists = r.status_code == 200
    107             size = None
    108             content_type = None
    109             if exists:
    110                 try:
    111                     size = int(r.headers["Content-Length"]) if "Content-Length" in r.headers else None
    112                 except (ValueError, TypeError):
    113                     size = None
    114                 content_type = r.headers.get("Content-Type")
    115             result = {"exists": exists, "size": size, "content_type": content_type}
    116             _set_cache(cache_key, result)
    117             return result
    118 
    119 async def get_path_info(path: str) -> dict:
    120     """Like get_file_info(), for deciding whether a URL is a file page or a folder listing.
    121 
    122     Opening a folder used to cost a wasted HEAD request (a folder isn't a file, so it 404s) before the
    123     listing was fetched. When the parent folder's listing is already cached, which it normally is
    124     because that is the page you clicked from, it already says whether `path` is a file or a folder
    125     (and a file's size), so the HEAD is skipped. Anything not found there falls back to get_file_info().
    126     """
    127     parent = path.rpartition("/")[0]
    128     for entry in _get_cache(f"list_dir_{parent}") or ():
    129         if entry["path"] == path:
    130             if entry["is_dir"]:
    131                 return {"exists": False, "size": None, "content_type": None}
    132             return {"exists": True, "size": entry.get("size"), "content_type": None}
    133     return await get_file_info(path)
    134 
    135 def _under(path: str, items: list) -> list:
    136     """Keep only entries inside `path` (defensive: guards against prefix over-match
    137     such as 'docs' also matching 'docs-old/...')."""
    138     if not path:
    139         return items
    140     prefix = path.rstrip("/") + "/"
    141     return [it for it in items if it.path.startswith(prefix)]
    142 
    143 def _fetch_tree(path: str):
    144     path = path.strip("/")
    145     items = list(api.list_bucket_tree(
    146         HF_BUCKET_ID, prefix=path or None, recursive=False
    147     ))
    148     return _under(path, items)
    149 
    150 def _fetch_tree_recursive(path: str = ""):
    151     path = path.strip("/")
    152     items = list(api.list_bucket_tree(
    153         HF_BUCKET_ID, prefix=path or None, recursive=True
    154     ))
    155     return _under(path, items)
    156 
    157 _HIDDEN_PREFIXES = ("_batches", "_shortened", "_logs", "_admin",)
    158 
    159 def _is_hidden_path(path: str) -> bool:
    160     return any(path == p or path.startswith(p + "/") for p in _HIDDEN_PREFIXES)
    161 
    162 async def list_directory(path: str):
    163     cache_key = f"list_dir_{path}"
    164     cached = _get_cache(cache_key)
    165     if cached is not None:
    166         return cached
    167 
    168     # One fetch per folder at a time: when a prefetch and a real click ask for the same folder
    169     # together, the second waits for the first instead of repeating the Hugging Face call.
    170     async with _get_key_lock(cache_key):
    171         cached = _get_cache(cache_key)
    172         if cached is not None:
    173             return cached
    174 
    175         try:
    176             items = await asyncio.to_thread(_fetch_tree, path)
    177         except Exception as e:
    178             print(f"[storage] list_repo_tree failed for {path!r}: {e}")
    179             return []
    180 
    181         files_and_folders = []
    182         for item in items:
    183             if _is_hidden_path(item.path):
    184                 continue
    185 
    186             name = item.path.split("/")[-1]
    187             is_dir = not hasattr(item, "size")
    188             size = getattr(item, "size", 0)
    189 
    190             size_str = format_size(size) if not is_dir else "-"
    191 
    192             files_and_folders.append({
    193                 "name": name,
    194                 "path": item.path,
    195                 "is_dir": is_dir,
    196                 "size": size,
    197                 "size_str": size_str
    198             })
    199 
    200         files_and_folders.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
    201 
    202         _set_cache(cache_key, files_and_folders)
    203         return files_and_folders
    204 
    205 async def list_files_recursive(path: str):
    206     try:
    207         items = await asyncio.to_thread(_fetch_tree_recursive, path)
    208     except Exception:
    209         return []
    210 
    211     files = []
    212     total_size = 0
    213     for item in items:
    214         if _is_hidden_path(item.path):
    215             continue
    216         if hasattr(item, "size"):
    217             size = getattr(item, "size", 0) or 0
    218             total_size += size
    219             files.append({"path": item.path, "size": size})
    220             if len(files) > ZIP_MAX_FILES or total_size > ZIP_MAX_TOTAL_BYTES:
    221                 raise ValueError(
    222                     f"Folder too large to zip (limit: {ZIP_MAX_FILES} files / "
    223                     f"{ZIP_MAX_TOTAL_BYTES // (1024 * 1024)} MB)."
    224                 )
    225     return files
    226 
    227 def format_size(size_bytes) -> str:
    228     if size_bytes is None:
    229         return "-"
    230     size = float(size_bytes)
    231     units = ["B", "KB", "MB", "GB", "TB"]
    232     idx = 0
    233     while size >= 1024 and idx < len(units) - 1:
    234         size /= 1024
    235         idx += 1
    236     if idx == 0:
    237         return f"{int(size)} {units[idx]}"
    238     return f"{size:.2f} {units[idx]}"
    239 
    240 async def _get_full_tree():
    241 
    242     cache_key = "full_tree"
    243     cached = _get_cache(cache_key)
    244     if cached is not None:
    245         return cached
    246 
    247     async with _get_key_lock(cache_key):
    248         cached = _get_cache(cache_key)
    249         if cached is not None:
    250             return cached
    251 
    252         try:
    253             raw_items = await asyncio.to_thread(_fetch_tree_recursive, "")
    254         except Exception as e:
    255             print(f"[storage] full tree fetch failed: {e}")
    256             return []
    257 
    258         items = []
    259         for item in raw_items:
    260             if _is_hidden_path(item.path):
    261                 continue
    262 
    263             name = item.path.split("/")[-1]
    264             is_dir = not hasattr(item, "size")
    265             size = getattr(item, "size", 0) or 0
    266             up = getattr(item, "uploaded_at", None)
    267             items.append({
    268                 "name": name,
    269                 "path": item.path,
    270                 "is_dir": is_dir,
    271                 "size": size,
    272                 "ts": int(up.timestamp()) if up else 0,
    273                 "size_str": format_size(size) if not is_dir else "-",
    274             })
    275 
    276         _set_cache(cache_key, items)
    277         return items
    278 
    279 async def repo_stats():
    280     cache_key = "repo_stats"
    281     cached = _get_cache(cache_key)
    282     if cached is not None:
    283         return cached
    284 
    285     items = await _get_full_tree()
    286     if not items:
    287         return None
    288 
    289     file_count = sum(1 for it in items if not it["is_dir"])
    290     total_size = sum(it["size"] for it in items if not it["is_dir"])
    291 
    292     result = {"file_count": file_count, "size_str": format_size(total_size)}
    293     _set_cache(cache_key, result)
    294     return result
    295 
    296 async def search_files(query: str):
    297     q = query.strip().lower()
    298     if not q:
    299         return []
    300 
    301     items = await _get_full_tree()
    302     results = [
    303         it for it in items
    304         if q in it["name"].lower() and it["name"] != ".gitattributes"
    305     ]
    306     results.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
    307     return [
    308         {"name": it["name"], "path": it["path"], "is_dir": it["is_dir"], "size_str": it["size_str"]}
    309         for it in results[:SEARCH_RESULT_LIMIT]
    310     ]
    311 
    312 def _do_upload(temp_path: str, hf_path: str):
    313     _ensure_bucket()
    314     api.batch_bucket_files(
    315         HF_BUCKET_ID,
    316         add=[(temp_path, hf_path)],
    317     )
    318 
    319 def _do_upload_bytes(data: bytes, hf_path: str):
    320     _ensure_bucket()
    321     api.batch_bucket_files(
    322         HF_BUCKET_ID,
    323         add=[(data, hf_path)],
    324     )
    325 
    326 def delete_object(path: str):
    327     """Delete one object, or everything under path/ (for a synced owner/repo folder)."""
    328     _ensure_bucket()
    329     path = path.strip("/")
    330     items = [it.path for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=path, recursive=True)
    331              if it.type == "file" and (it.path == path or it.path.startswith(path + "/"))]
    332     if items:
    333         api.batch_bucket_files(HF_BUCKET_ID, delete=items)
    334     _cache.clear()
    335 
    336 
    337 _UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
    338 
    339 
    340 def sanitize_filename(filename: str, max_len: int = 100) -> str:
    341     """Turn any user-supplied filename into a safe object-key segment.
    342 
    343     Accepts spaces, accents, symbols, emoji and path fragments: directory parts
    344     are dropped, accents are folded to ASCII, and anything else unsafe becomes
    345     a single underscore. The extension is kept.
    346     """
    347     name = (filename or "").replace("\\", "/").split("/")[-1]
    348     name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
    349     stem, dot, ext = name.rpartition(".")
    350     if not dot:
    351         stem, ext = name, ""
    352     stem = _UNSAFE_NAME_CHARS.sub("_", stem).strip("._") or "file"
    353     ext = _UNSAFE_NAME_CHARS.sub("", ext)[:10]
    354     stem = stem[: max_len - len(ext) - 1]
    355     return f"{stem}.{ext}" if ext else stem
    356 
    357 
    358 async def upload_temp_file(temp_path: str, filename: str, folder: str = "uploads") -> str:
    359     slug = str(uuid.uuid4())[:8]
    360     safe_filename = sanitize_filename(filename)
    361     hf_path = f"{folder}/{slug}-{safe_filename}"
    362 
    363     await asyncio.to_thread(_do_upload, temp_path, hf_path)
    364 
    365     _cache.clear()
    366     return hf_path
    367 
    368 def _do_upload_folder_scoped(local_dir: str, dest_prefix: str):
    369     # Mirror local_dir into the bucket under dest_prefix. Every added path and every
    370     # deleted path is built from dest_prefix, so this can only ever create, update
    371     # or delete objects under that one folder - it never touches anything outside it.
    372     _ensure_bucket()
    373     dest_prefix = dest_prefix.strip("/")
    374 
    375     local_files = {}
    376     for root, _dirs, names in os.walk(local_dir):
    377         for name in names:
    378             full = os.path.join(root, name)
    379             rel = os.path.relpath(full, local_dir).replace(os.sep, "/")
    380             local_files[f"{dest_prefix}/{rel}"] = full
    381 
    382     remote_files = {
    383         it.path
    384         for it in api.list_bucket_tree(HF_BUCKET_ID, prefix=dest_prefix, recursive=True)
    385         if it.type == "file" and it.path.startswith(dest_prefix + "/")
    386     }
    387 
    388     stale = sorted(remote_files - local_files.keys())
    389     adds = [(full, remote) for remote, full in sorted(local_files.items())]
    390 
    391     if not adds and not stale:
    392         return
    393     api.batch_bucket_files(
    394         HF_BUCKET_ID,
    395         add=adds or None,
    396         delete=stale or None,
    397     )
    398 
    399 async def upload_folder_scoped(local_dir: str, owner: str, repo: str) -> str:
    400     dest_prefix = f"{owner}/{repo}"
    401     await asyncio.to_thread(_do_upload_folder_scoped, local_dir, dest_prefix)
    402     _cache.clear()
    403     return dest_prefix
    404 
    405 async def write_batch_manifest(batch_id: str, files: list[dict]) -> None:
    406     manifest = {
    407         "batch_id": batch_id,
    408         "created": time.time(),
    409         "files": files,
    410     }
    411 
    412     fd, tmp_path = tempfile.mkstemp(suffix=".json")
    413     try:
    414         with os.fdopen(fd, "w") as f:
    415             json.dump(manifest, f)
    416         await asyncio.to_thread(_do_upload, tmp_path, f"_batches/{batch_id}.json")
    417     finally:
    418         if os.path.exists(tmp_path):
    419             os.remove(tmp_path)
    420 
    421     _cache.clear()
    422 
    423 async def get_batch_manifest(batch_id: str):
    424     cache_key = f"batch_{batch_id}"
    425     cached = _get_cache(cache_key)
    426     if cached is not None:
    427         return cached
    428 
    429     hf_url = bucket_url(f"_batches/{batch_id}.json")
    430     async with httpx.AsyncClient(follow_redirects=True) as client:
    431         r = await client.get(hf_url, headers=auth_headers())
    432         if r.status_code != 200:
    433             return None
    434         try:
    435             manifest = r.json()
    436         except ValueError:
    437             return None
    438 
    439     _set_cache(cache_key, manifest)
    440     return manifest