"""Fetch + cache published app bundles from Tigris (read-only key). A bundle is a single apps/{slug}/bundle.tar.gz object; we unpack it once and cache the per-file bytes keyed by slug with a short TTL so a republish shows up without a restart. Every path lookup is guarded against traversal and never serves Python source.""" from __future__ import annotations import asyncio import io import logging import mimetypes import os import posixpath import tarfile import time from dataclasses import dataclass from typing import Optional import boto3 from botocore.config import Config from botocore.exceptions import ClientError logger = logging.getLogger(__name__) _ENDPOINT = os.environ.get("TIGRIS_ENDPOINT", "https://fly.storage.tigris.dev") _BUCKET = os.environ.get("TIGRIS_BUCKET", "openswarm-app-bundles") _TTL_SECONDS = int(os.environ.get("EDGE_BUNDLE_TTL_SECONDS", "120")) # Misses are cached briefly too, so spraying random subdomains can't turn into one # Tigris GET per request. Short enough that a fresh publish still shows up quickly. _NEG_TTL_SECONDS = int(os.environ.get("EDGE_BUNDLE_NEG_TTL_SECONDS", "30")) _MAX_CACHED_BUNDLES = int(os.environ.get("EDGE_BUNDLE_CACHE_MAX", "200")) _MAX_NEG_CACHED = int(os.environ.get("EDGE_BUNDLE_NEG_CACHE_MAX", "5000")) _MAX_UNPACKED_BYTES = 100 * 1024 * 1024 # guard against a decompression bomb # Browsers are picky about these; mimetypes' OS table can disagree across distros. _MIME_OVERRIDE = { ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".wasm": "application/wasm", ".map": "application/json", } _client = None def _s3(): global _client if _client is None: _client = boto3.client( "s3", endpoint_url=_ENDPOINT, region_name=os.environ.get("TIGRIS_REGION", "auto"), aws_access_key_id=os.environ.get("TIGRIS_ACCESS_KEY_ID", ""), aws_secret_access_key=os.environ.get("TIGRIS_SECRET_ACCESS_KEY", ""), config=Config(signature_version="s3v4"), ) return _client @dataclass class Bundle: files: dict[str, bytes] backend_code: Optional[str] fetched_at: float _cache: dict[str, Bundle] = {} _negative: dict[str, float] = {} # slug -> time of the miss, short TTL def _bundle_key(slug: str) -> str: return f"apps/{slug}/bundle.tar.gz" def _remember_miss(slug: str) -> None: if len(_negative) >= _MAX_NEG_CACHED: _negative.clear() _negative[slug] = time.time() def unpack(tar_gz: bytes) -> Bundle: files: dict[str, bytes] = {} total = 0 with tarfile.open(fileobj=io.BytesIO(tar_gz), mode="r:gz") as tar: for m in tar.getmembers(): if not m.isfile(): continue name = posixpath.normpath(m.name).lstrip("/") if name.startswith("..") or os.path.isabs(name): continue extracted = tar.extractfile(m) if extracted is None: continue data = extracted.read() total += len(data) if total > _MAX_UNPACKED_BYTES: raise ValueError("bundle exceeds the unpacked-size limit") files[name] = data backend = files.get("backend.py") backend_code = backend.decode("utf-8", errors="replace") if backend is not None else None return Bundle(files=files, backend_code=backend_code, fetched_at=time.time()) async def get_bundle(slug: str) -> Optional[Bundle]: cached = _cache.get(slug) if cached and time.time() - cached.fetched_at < _TTL_SECONDS: return cached missed_at = _negative.get(slug) if missed_at is not None and time.time() - missed_at < _NEG_TTL_SECONDS: return None try: obj = await asyncio.to_thread(lambda: _s3().get_object(Bucket=_BUCKET, Key=_bundle_key(slug))) raw = await asyncio.to_thread(obj["Body"].read) except ClientError as e: code = str(e.response.get("Error", {}).get("Code", "")) if code not in ("NoSuchKey", "404", "NoSuchBucket"): # creds/permission/other storage error: log it but still degrade to a # clean not-found rather than 500-ing every app on a storage hiccup. logger.warning("tigris get failed for %s: %s", slug, code or e) _cache.pop(slug, None) _remember_miss(slug) return None except Exception as e: # missing creds, network, malformed bundle: never 500 the whole edge. logger.warning("tigris get error for %s: %s", slug, e) _cache.pop(slug, None) _remember_miss(slug) return None try: bundle = unpack(raw) except Exception as e: logger.warning("bundle unpack failed for %s: %s", slug, e) _remember_miss(slug) return None if len(_cache) >= _MAX_CACHED_BUNDLES: oldest = min(_cache, key=lambda k: _cache[k].fetched_at) _cache.pop(oldest, None) _negative.pop(slug, None) _cache[slug] = bundle return bundle def resolve_file(bundle: Bundle, path: str) -> Optional[tuple[bytes, str]]: """Map a request path to a bundle file, SPA-falling back to index.html. Refuses traversal and Python source (served as index.html instead, never as code).""" rel = posixpath.normpath(path.lstrip("/")) if rel in ("", "."): rel = "index.html" if rel.startswith("..") or rel.endswith(".py"): rel = "index.html" data = bundle.files.get(rel) if data is None: data = bundle.files.get("index.html") rel = "index.html" if data is None: return None ext = posixpath.splitext(rel)[1].lower() mime = _MIME_OVERRIDE.get(ext) or mimetypes.guess_type(rel)[0] or "application/octet-stream" return data, mime