CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
extension / lint (push) Successful in 28s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Failing after 5s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 2m4s
Closes the half of the ask the signing work didn't: a way to tell a dev
build from a main one. FC_CHANNEL is baked into the web image at build
time and /api/extension/manifest reports it as its own key, next to
version — the popup banner, the toolbar tooltip and the Settings card all
name it.
Beside the version, never inside it. A `1.0.3499884-dev` suffix is the
obvious shortcut and it is the exact failure this design comes from:
versionIsNewer parses each dotted segment with parseInt, so a suffixed
segment reads as 0, every dev build compares equal to every other, and
"no update available" stops being distinguishable from "I cannot read this
version". The comparator already degrades rather than discarding (rule
150), which is a reason not to NEED the suffix, not a licence to add one.
Two tests hold the line — one backend, asserting version and channel are
separate keys; one frontend, asserting the rendered version text stays the
bare derived number.
Optional on the read side, and absent rather than defaulted. An image
built before this field says nothing by not having the key; an image built
without a channel now says nothing the same way, so there is one absence
to handle instead of a second spelling of "unknown". Every reader drops
the label entirely when it is missing and reads exactly as it did before.
Reported verbatim rather than validated against {dev, main}: if an image
declares something else, showing what it claims helps whoever is debugging
more than dropping it would.
FC_CHANNEL is declared LAST in the Dockerfile. An ARG invalidates every
layer below it, and this is the one value that differs between the dev and
main builds of identical source — earlier, and the two channels could
never share a cached pip install. A tag push counts as main: a vYY.MM.DD
tag is cut from main, so that image is a main-channel artifact wearing an
immutable name.
No channel switcher, deliberately. background.js:34 already records that
Firefox's static update_url cannot apply, because every FC instance is a
different host — so the extension asks its configured backend, and the
channel IS the instance it points at. Switching is repointing apiUrl and
reinstalling from that host. A separate setting would contradict each
server build shipping its own extension.
This commit touches packaged extension files, so it moves the derived
version and will sign a new one via AMO — the first push to exercise the
extension-changed path from dev end to end.
175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""FC-3g: /api/extension — quick-add-source for the Firefox extension
|
|
+ install-time manifest for the Settings card.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from ..extensions import get_session
|
|
from ..models import AppSetting
|
|
from ..services.extension_service import (
|
|
ExtensionService,
|
|
InvalidUrlError,
|
|
UnknownPlatformError,
|
|
)
|
|
from ..services.source_service import KNOWN_PLATFORMS
|
|
from ._responses import error_response as _bad
|
|
|
|
extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension")
|
|
|
|
# Default XPI directory; tests override via monkeypatching this module-
|
|
# level constant.
|
|
XPI_DIR = Path("/app/frontend/dist/extension")
|
|
|
|
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
|
|
|
|
# Which channel this image belongs to — "dev" or "main" — baked in at build
|
|
# time from the FC_CHANNEL build arg (milestone 271 step 7). Empty for a local
|
|
# build, or for any image predating the field. Tests override by monkeypatching
|
|
# this constant, same as XPI_DIR above.
|
|
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
|
|
|
|
|
|
async def _ext_key_required(session) -> bool:
|
|
"""Unlike /api/credentials (which accepts the browser path with no
|
|
header), quick-add-source writes server state and must be explicitly
|
|
authenticated."""
|
|
supplied = request.headers.get("X-Extension-Key")
|
|
if supplied is None:
|
|
return False
|
|
stored = (await session.execute(
|
|
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
|
)).scalar_one_or_none()
|
|
if stored is None:
|
|
return False
|
|
# compare_digest, not `==`: the stored key is a shared secret, and a
|
|
# short-circuiting compare leaks its prefix through timing. Costs nothing
|
|
# here — it is not that this route is exposed (#3072). Compared as BYTES:
|
|
# compare_digest's str form rejects non-ASCII with TypeError, and this
|
|
# header is attacker-supplied, so a str compare would turn a junk key into
|
|
# a 500 instead of a 403.
|
|
return hmac.compare_digest(supplied.encode("utf-8"), stored.encode("utf-8"))
|
|
|
|
|
|
def _extract_version(xpi_name: str) -> str:
|
|
m = _XPI_VERSION_RE.search(xpi_name)
|
|
return m.group("version") if m else "unknown"
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as fp:
|
|
for chunk in iter(lambda: fp.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
@extension_bp.route("/probe", methods=["GET"])
|
|
async def probe_source():
|
|
"""Read-only resolution of a creator-page URL: tells the extension
|
|
whether this URL is already a Source, is for an Artist that exists
|
|
but with a different URL, is brand new, or doesn't match any known
|
|
platform pattern. Drives the content-script chip's color/copy
|
|
BEFORE the operator clicks, so the button can show 'already added'
|
|
without requiring an add-attempt."""
|
|
url = (request.args.get("url") or "").strip()
|
|
if not url:
|
|
return _bad("invalid_body", detail="url query parameter is required")
|
|
async with get_session() as session:
|
|
if not await _ext_key_required(session):
|
|
return _bad("unauthorized", status=401)
|
|
result = await ExtensionService(session).probe(url)
|
|
return jsonify(result)
|
|
|
|
|
|
@extension_bp.route("/quick-add-source", methods=["POST"])
|
|
async def quick_add_source():
|
|
body = await request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
return _bad("invalid_body", detail="body must be a JSON object")
|
|
url = body.get("url")
|
|
if not isinstance(url, str) or not url.strip():
|
|
return _bad("invalid_body", detail="url is required")
|
|
|
|
from .credentials import _get_crypto
|
|
|
|
async with get_session() as session:
|
|
if not await _ext_key_required(session):
|
|
return _bad("unauthorized", status=401)
|
|
try:
|
|
# crypto lets a pixiv add resolve the artist's display name via the
|
|
# stored OAuth token (else it falls back to the numeric id). #130.
|
|
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
|
|
except UnknownPlatformError as exc:
|
|
return _bad(
|
|
"unknown_platform",
|
|
detail=str(exc),
|
|
known=sorted(KNOWN_PLATFORMS),
|
|
)
|
|
except InvalidUrlError as exc:
|
|
return _bad("invalid_url", detail=str(exc))
|
|
return jsonify(result), (201 if result["created_source"] else 200)
|
|
|
|
|
|
def _read_manifest_sync() -> dict | None:
|
|
"""All the filesystem-touching work for /api/extension/manifest,
|
|
in a sync helper so the async route can dispatch it via
|
|
asyncio.to_thread (ASYNC240: no pathlib I/O in async functions)."""
|
|
if not XPI_DIR.is_dir():
|
|
return None
|
|
# Exclude the `fabledcurator-latest.xpi` alias when picking the file to
|
|
# extract a version from — it's a copy of the latest versioned XPI,
|
|
# written at the same mtime by build.yml, and would otherwise tie or
|
|
# win the sort (operator-flagged 2026-05-26: UI displayed "v latest"
|
|
# because `_extract_version("fabledcurator-latest.xpi")` returns
|
|
# the literal "latest"). The alias still serves as `latest_url`.
|
|
versioned = [
|
|
p for p in XPI_DIR.glob("fabledcurator-*.xpi")
|
|
if p.name != "fabledcurator-latest.xpi"
|
|
]
|
|
if not versioned:
|
|
return None
|
|
versioned.sort(key=lambda p: p.stat().st_mtime)
|
|
latest = versioned[-1]
|
|
info = {
|
|
"installed": True,
|
|
"version": _extract_version(latest.name),
|
|
"xpi_url": f"/extension/{latest.name}",
|
|
"latest_url": "/extension/fabledcurator-latest.xpi",
|
|
"sha256": _sha256(latest),
|
|
}
|
|
# The channel goes BESIDE the version, never inside it. A `-dev` suffix is
|
|
# what silently disabled the dev channel in the sibling project this design
|
|
# comes from: the comparator returned nothing for a non-integer segment, so
|
|
# every dev version compared equal and "no update available" became
|
|
# indistinguishable from "I cannot read this version".
|
|
#
|
|
# Omitted rather than defaulted when unset. Absence already has a meaning
|
|
# every reader must handle — an image built before this field existed says
|
|
# exactly the same thing by not having the key — so a blank channel reuses
|
|
# that path instead of inventing a second "unknown" spelling.
|
|
#
|
|
# Reported verbatim, not validated against {"dev", "main"}: if an image
|
|
# declares something else, showing what it actually claims is more useful
|
|
# to whoever is debugging it than dropping the value on the floor.
|
|
if FC_CHANNEL:
|
|
info["channel"] = FC_CHANNEL
|
|
return info
|
|
|
|
|
|
@extension_bp.route("/manifest", methods=["GET"])
|
|
async def extension_manifest():
|
|
info = await asyncio.to_thread(_read_manifest_sync)
|
|
if info is None:
|
|
return jsonify({"installed": False}), 404
|
|
return jsonify(info)
|