diff --git a/backend/app/frontend.py b/backend/app/frontend.py index 7855148..8bf97ee 100644 --- a/backend/app/frontend.py +++ b/backend/app/frontend.py @@ -1,13 +1,18 @@ """Serves the built Vue SPA from frontend/dist/ with history-mode fallback, -and the on-disk image library + thumbnails from /images. +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_from_directory +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__) @@ -31,6 +36,43 @@ async def serve_image(subpath: str): return await send_from_directory(IMAGES_ROOT, subpath) +@frontend_bp.route("/extension/") +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. + """ + 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] + return await send_file( + latest, mimetype="application/x-xpinstall", + attachment_filename=latest.name, + ) + target = (XPI_DIR / filename).resolve() + try: + target.relative_to(XPI_DIR) + except ValueError: + abort(404) + if not target.is_file(): + abort(404) + return await send_file( + target, mimetype="application/x-xpinstall", + attachment_filename=filename, + ) + + @frontend_bp.route("/") @frontend_bp.route("/") async def serve_spa(subpath: str = ""):