CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 25s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m54s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m19s
Milestone #406 phase 2, with issue #3980 folded in. Phase 1 (2026-09-13) unregistered pixiv so nothing could reach it; the code has sat in the tree uncalled since. DeviantArt is why the second half is not left for later — #3069 retired it in code on 2026-08-27 and its stored session was still in the database seven weeks on. Step 5 — the code. Deletes pixiv_client, pixiv_downloader, pixiv_ingester, platforms/pixiv and their three test modules and fixture, then edits out every remaining reference: the dispatch entry, the campaign-id and verify branches in download_backends, the display-name branch in extension_service, and the comments that still described pixiv as live. The consolidation check the step asked for comes back negative: native_ingest_common has seven non-pixiv callers (patreon, subscribestar, membership_reconcile, membership_roster, ingest_core), so nothing there drops to a single user. Step 6 — the data, alembic 0102. Drops pixiv_seen_media and pixiv_failed_media, and deletes credential rows whose platform is not registered. Written as "not registered" rather than "pixiv" at the step's explicit ask, which is what makes one migration cover two retirements: the pixiv OAuth refresh token and DeviantArt's leftover session (#3980). It is also the only way either row can go — the credentials UI renders one card per platform from /api/platforms and looks the credential up by key, so an unregistered platform's row has no card and no Remove button. Pixiv's Source rows are KEPT, changing the milestone's original data table on the operator's call. `platform` is stored only on Source; neither Post nor ImageRecord carries it. Both FKs are ON DELETE SET NULL, so a delete would not lose the art — but it would drop every pixiv image into the gallery's __unsourced__ bucket and strip the platform chip off every pixiv post. The rows stay disabled (0097) and unregistered, so nothing schedules or downloads through them. Keeping them costs nothing and keeps the attribution that "the art already downloaded from pixiv stays" is about. Step 7 — the guard. test_pixiv_code_and_tables_are_gone asserts absence from the module table and from Base.metadata, not from prose (snippet #3352's trap). The extension and registry negative assertions were already in place from phase 1. The final sweep found one real residue step 4 missed: extension/README.md still advertised pixiv support and carried a "Pixiv OAuth" manual-test item. Also replaces the two deleted dispatch tests with one over the whole NATIVE_INGESTER_PLATFORMS set, so adding a platform and forgetting its ingester class now fails at unit level rather than as a mid-download KeyError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
178 lines
7.0 KiB
Python
178 lines
7.0 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 re
|
|
from pathlib import Path
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from ..build_info import FC_CHANNEL as _FC_CHANNEL
|
|
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 (milestone 271 step 7). Read from build_info rather than the environment
|
|
# a second time: /api/health reports the same value, and two independent
|
|
# `os.environ.get` calls are two things that can drift.
|
|
#
|
|
# Still bound as a module-level name here, so tests monkeypatch
|
|
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
|
|
FC_CHANNEL = _FC_CHANNEL
|
|
|
|
|
|
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 an add resolve the artist's display name via the
|
|
# stored credential (else it falls back to the URL handle). #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)
|