CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m29s
CI and images / build-web (push) Successful in 1m43s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 2s
Operator, 2026-09-25: "the extension update trigger from inside the extension doesn't work and the manual update seems to not move it to the most recent version or at least mark it the most recent." - fabledcurator-latest.xpi was served with Quart's default `public, max-age=43200`: one URL whose bytes change every release, so a browser that had fetched it reinstalled the previous build for 12 hours (measured on the instance). It is now `no-cache` (the ETag keeps an unchanged file a 304); versioned XPIs are `immutable`. - The web Settings card installs/downloads the VERSIONED xpi_url, which can only ever be that build's bytes. - The popup's Update button did tabs.create() on the .xpi, which Firefox refuses (NS_ERROR_FAILURE on a 200: it only installs from a user click on a web page). It now opens FC's install card (/subscriptions?tab=settings). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
100 lines
3.5 KiB
Python
100 lines
3.5 KiB
Python
"""Serves the built Vue SPA from frontend/dist/ with history-mode fallback,
|
|
the on-disk image library + thumbnails from /images, and the signed
|
|
Firefox extension XPI from frontend/dist/extension/.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from quart import Blueprint, abort, send_file, send_from_directory
|
|
|
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
IMAGES_ROOT = Path("/images")
|
|
XPI_DIR = FRONTEND_DIST / "extension"
|
|
|
|
_XPI_NAME_RE = re.compile(r"^fabledcurator-[\w.-]+\.xpi$")
|
|
|
|
frontend_bp = Blueprint("frontend", __name__)
|
|
|
|
|
|
@frontend_bp.route("/images/<path:subpath>")
|
|
async def serve_image(subpath: str):
|
|
"""Serve a file from the /images volume (originals + thumbnails).
|
|
|
|
Without this route the SPA catch-all below would swallow image
|
|
requests and return index.html, leaving the browser to render the
|
|
aspect-ratio-shaped grey placeholder.
|
|
"""
|
|
target = (IMAGES_ROOT / subpath).resolve()
|
|
# Defend against path-traversal: refuse anything that escapes /images.
|
|
try:
|
|
target.relative_to(IMAGES_ROOT)
|
|
except ValueError:
|
|
abort(404)
|
|
if not target.is_file():
|
|
abort(404)
|
|
return await send_from_directory(IMAGES_ROOT, subpath)
|
|
|
|
|
|
@frontend_bp.route("/extension/<filename>")
|
|
async def serve_extension(filename: str):
|
|
"""Serve the signed FC Firefox extension XPI.
|
|
|
|
Path whitelist: filename must match fabledcurator-*.xpi. The special
|
|
name fabledcurator-latest.xpi serves the most-recently-modified XPI
|
|
in the directory.
|
|
|
|
The application/x-xpinstall MIME tells Firefox to show its native
|
|
install prompt instead of downloading the file as a blob.
|
|
|
|
Caching differs by name, and has to. A versioned name is one build's bytes
|
|
forever, so it can be cached for good. `fabledcurator-latest.xpi` is ONE
|
|
URL whose bytes change on every release, and Quart's default for a file
|
|
is `public, max-age=43200`: a browser that fetched it once reused those
|
|
bytes for 12 hours, so "install the latest" quietly reinstalled the
|
|
previous build (operator-flagged 2026-09-25). It is `no-cache` — the ETag
|
|
still makes an unchanged file a cheap 304.
|
|
"""
|
|
if not _XPI_NAME_RE.fullmatch(filename):
|
|
abort(404)
|
|
if not XPI_DIR.is_dir():
|
|
abort(404)
|
|
if filename == "fabledcurator-latest.xpi":
|
|
xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime)
|
|
if not xpis:
|
|
abort(404)
|
|
latest = xpis[-1]
|
|
resp = await send_file(
|
|
latest, mimetype="application/x-xpinstall",
|
|
attachment_filename=latest.name,
|
|
)
|
|
return _cache(resp, "no-cache")
|
|
target = (XPI_DIR / filename).resolve()
|
|
try:
|
|
target.relative_to(XPI_DIR)
|
|
except ValueError:
|
|
abort(404)
|
|
if not target.is_file():
|
|
abort(404)
|
|
resp = await send_file(
|
|
target, mimetype="application/x-xpinstall",
|
|
attachment_filename=filename,
|
|
)
|
|
return _cache(resp, "public, max-age=31536000, immutable")
|
|
|
|
|
|
def _cache(resp, policy: str):
|
|
"""Set the XPI's Cache-Control, dropping the Expires send_file adds so the
|
|
two can never disagree."""
|
|
resp.headers["Cache-Control"] = policy
|
|
resp.headers.pop("Expires", None)
|
|
return resp
|
|
|
|
|
|
@frontend_bp.route("/")
|
|
@frontend_bp.route("/<path:subpath>")
|
|
async def serve_spa(subpath: str = ""):
|
|
if subpath and (FRONTEND_DIST / subpath).is_file():
|
|
return await send_from_directory(FRONTEND_DIST, subpath)
|
|
return await send_from_directory(FRONTEND_DIST, "index.html")
|