d65f0b2091
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
Operator-asked 2026-05-31 (during sidecar synthetic anchor cleanup): "the add source/subscription button idea to the firefox extension so it can tell me if a source/artist is added or not and offer an option to add it if it isn't." Plan tracked in Scribe task #507. ## Backend - `ExtensionService.probe(url)` — read-only resolution. Reuses `_derive` for platform+slug, then 2 SELECTs. Returns one of: - `source_match` (exact (artist, platform, url) Source exists) - `artist_match` (artist exists, this URL isn't a Source yet; collapses the sidecar-synthetic-only case from v26.06.01.0) - `new` (neither exists) - `unknown_platform` (URL didn't match any artist-page regex) - `GET /api/extension/probe?url=...` route with `X-Extension-Key` auth posture matching `/quick-add-source`. Read-only, side-effect free. - 6 backend tests in tests/test_api_extension.py covering each state + auth + invalid URL. ## Extension - `api.js`: `probeSource(url)` mirroring `quickAddSource` shape. - `background.js`: `PROBE_SOURCE` + `OPEN_ARTIST_PAGE` handlers. The latter strips the `/api` suffix from configured `apiUrl` (placeholder format per options.html) and opens `${base}/artist/{slug}` in a new tab via `browser.tabs.create`. - `content-script.js`: probe-first render — on page-load and SPA navigation, asks the backend for the URL's state and renders the chip in the matching color/copy on FIRST paint instead of flashing generic "Add" and updating after. Click handler branches: `source_match` → OPEN_ARTIST_PAGE; `artist_match`/`new` → existing ADD_AS_SOURCE flow (then re-probes so the chip flips green immediately, no wait for next nav). - `content-script.css`: three state-color modifiers (--new, --artist-match, --source-match) on the FC parchment-on-slate palette. Sage for already-added, amber for artist-exists, accent orange for new. ## Versioning - `extension/manifest.json` + `extension/package.json` → 1.0.6. build.yml's sign-extension job will fire on push to main since no `ext-1.0.6` Forgejo/Gitea release exists yet — exercises the regenerated AMO keys end-to-end. ## Behavior on the sidecar-synthetic case Filesystem-imported "Dymkens"-style artist with only a sidecar synthetic Source: probe returns `artist_match` (not `new`), so the chip reads "+ Add Patreon source to Dymkens" rather than offering to recreate the artist. Clicking adds the real Source; existing `_source_for_sidecar` preference logic (v26.06.01.0) routes future gallery-dl Posts to the real one.
138 lines
4.9 KiB
Python
138 lines
4.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 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$")
|
|
|
|
|
|
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()
|
|
return stored is not None and supplied == stored
|
|
|
|
|
|
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")
|
|
|
|
async with get_session() as session:
|
|
if not await _ext_key_required(session):
|
|
return _bad("unauthorized", status=401)
|
|
try:
|
|
result = await ExtensionService(session).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]
|
|
return {
|
|
"installed": True,
|
|
"version": _extract_version(latest.name),
|
|
"xpi_url": f"/extension/{latest.name}",
|
|
"latest_url": "/extension/fabledcurator-latest.xpi",
|
|
"sha256": _sha256(latest),
|
|
}
|
|
|
|
|
|
@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)
|