Files
openswarm/openswarm-edge/app/bundles.py
T

162 lines
5.7 KiB
Python

"""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