"""
Hot-updated extension assets: the overlay UI module (JS + CSS) is served from
here so content scripts can pick up UI changes without reinstalling the
extension. State lives on disk in settings.ext_assets_dir:
overlay.js / overlay.css asset files (written by POST /publish)
manifest.json commit point — written last, read by everyone
The core extension (background/content scripts) is NOT hot-updatable (MV3);
its version is compared against the manifest by the client and surfaced as an
"update available" hint.
"""
import base64
import hashlib
import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from ..config import settings
from ..deps import require_user
from ..schemas import PublishIn
router = APIRouter(prefix="/ext", tags=["ext-assets"])
# these endpoints are public static assets consumed by the *content script*,
# which runs with the host page's origin — a fixed origin allowlist can't name
# every page, so pages get ACAO * here; the app-wide CORS middleware already
# answers chrome-/moz-extension origins, and a second ACAO header would be a
# duplicate the browser rejects
EXTENSION_ORIGIN = re.compile(r"^(chrome|moz)-extension://")
def _cors_headers(request: Request) -> dict[str, str]:
origin = request.headers.get("origin", "")
return {} if EXTENSION_ORIGIN.match(origin) else {"Access-Control-Allow-Origin": "*"}
# served asset files and their content types — anything else is a 404
ASSET_TYPES = {
"overlay.js": "text/javascript",
"overlay.css": "text/css",
}
MAX_ASSET_BYTES = 5 * 1024 * 1024 # per file
# stable download links live on caddy (web dist), they exist whenever the
# panel is deployed — shown even when nothing is published yet
DOWNLOADS = {
"chrome": "/ext/bugtrail-chrome.zip",
"firefox": "/ext/bugtrail-firefox.zip",
}
def _read_manifest() -> dict | None:
try:
return json.loads((Path(settings.ext_assets_dir) / "manifest.json").read_text("utf8"))
except (OSError, ValueError):
return None
def _downloads_for(version: str | None) -> dict:
return {
**DOWNLOADS,
"chrome_v": f"/ext/bugtrail-chrome-{version}.zip" if version else None,
"firefox_v": f"/ext/bugtrail-firefox-{version}.zip" if version else None,
}
@router.get("/manifest")
async def manifest(request: Request) -> JSONResponse:
published = _read_manifest()
if published is None:
published = {"version": None, "published_at": None, "assets": None, "downloads": _downloads_for(None)}
# downloads are computed, not stored — they always match the panel deploy
published["downloads"] = _downloads_for(published.get("version"))
return JSONResponse(published, headers=_cors_headers(request))
@router.get("/assets/{name}")
async def asset(name: str, request: Request) -> FileResponse:
if name not in ASSET_TYPES:
raise HTTPException(404, "Unknown asset")
path = Path(settings.ext_assets_dir) / name
if not path.is_file():
raise HTTPException(404, "Asset not published")
# cache busting is done by the ?v= query the client appends; the ETag is a
# formality for revalidation
sha = hashlib.sha256(path.read_bytes()).hexdigest()
return FileResponse(
path,
media_type=ASSET_TYPES[name],
headers={"Cache-Control": "public, max-age=300", "ETag": f'"{sha}"', **_cors_headers(request)},
)
@router.post("/publish")
async def publish(payload: PublishIn, _user=Depends(require_user)) -> dict:
bad = [name for name in payload.files if name not in ASSET_TYPES]
if bad:
raise HTTPException(422, f"Unknown asset names: {', '.join(bad)}")
directory = Path(settings.ext_assets_dir)
directory.mkdir(parents=True, exist_ok=True)
assets: dict[str, dict[str, str]] = {}
for name, b64 in payload.files.items():
try:
content = base64.b64decode(b64, validate=True)
except Exception:
raise HTTPException(422, f"{name}: invalid base64")
if len(content) > MAX_ASSET_BYTES:
raise HTTPException(422, f"{name}: larger than {MAX_ASSET_BYTES} bytes")
tmp = directory / f"{name}.tmp"
tmp.write_bytes(content)
# os.replace is atomic on POSIX — readers never see a partial file
os.replace(tmp, directory / name)
assets[name] = {
"url": f"/api/ext/assets/{name}",
"sha256": hashlib.sha256(content).hexdigest(),
}
manifest: dict = {
"version": payload.version,
"published_at": datetime.now(UTC).isoformat(),
"assets": {
"overlay_js": assets["overlay.js"],
"overlay_css": assets["overlay.css"],
},
}
# manifest.json last: it is the commit point clients key off
manifest_path = directory / "manifest.json"
tmp = manifest_path.with_name("manifest.json.tmp")
tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), "utf8")
os.replace(tmp, manifest_path)
manifest["downloads"] = _downloads_for(payload.version)
return manifest