56970fb66d
Operator-flagged 2026-05-28, two asks.
**1. Credential Verify (was missing vs GS — and now actually verifies).**
GS's Verify was a stub (`TODO: implement actual verification` — just
stamped last_verified). FC does a real check, which matters given the
recent auth pain (subscribestar age cookie, HF host-only PHPSESSID):
- GalleryDLService.verify(url, platform, cookies_path, auth_token) runs
gallery-dl in `--simulate --range 1-1` mode (no download) against the
URL with the materialized credentials, then reuses _categorize_error:
returncode 0 / NO_NEW_CONTENT → valid; AUTH_ERROR → invalid; other →
inconclusive (reason surfaced). 45s timeout.
- POST /api/credentials/<platform>/verify picks an enabled Source for
the platform to probe, runs verify, and on success stamps
credential.last_verified (new CredentialService.mark_verified).
Returns {valid: bool|null, reason, last_verified?}. valid=null means
untestable (no credential, or no enabled source to point at).
- CredentialCard gains a Verify button (on credentialed cards) + a
result chip (Verified ✓ / Failed / Untestable) and a toast with the
reason. SettingsTab reloads on @verified so last_verified refreshes.
**2. Live download-activity feedback.** The Downloads tab was static —
no way to tell if downloads were succeeding without manually hitting
Refresh. It now auto-polls: stats every 4s, and the event list too
while anything is queued/running. Polling pauses when the tab is
backgrounded (document.hidden) and the list reload is skipped on idle
ticks to stay light. A pulsing "● live" indicator next to the stat
chips shows when auto-refresh is active (queued+running > 0); honors
prefers-reduced-motion.
Tests: verify endpoint — untestable with no credential, untestable with
no enabled source, valid+stamped on success (gallery-dl mocked), and
auth-failure reported without stamping.
180 lines
6.7 KiB
Python
180 lines
6.7 KiB
Python
"""FC-3b: /api/credentials — CRUD with X-Extension-Key auth.
|
|
|
|
Browser requests (no header) are accepted per the homelab posture —
|
|
there is no user-session model in FC. The X-Extension-Key check
|
|
exists to give the extension a revocable path: a request that
|
|
supplies the header must match `app_setting.extension_api_key`.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from ..extensions import get_session
|
|
from ..models import AppSetting, Credential
|
|
from ..services.credential_crypto import CredentialCrypto
|
|
from ..services.credential_service import (
|
|
CredentialService,
|
|
EmptyDataError,
|
|
UnknownPlatformError,
|
|
WrongAuthTypeError,
|
|
)
|
|
|
|
credentials_bp = Blueprint("credentials", __name__, url_prefix="/api/credentials")
|
|
|
|
|
|
# Production key path; tests pass `cookies_dir=tmp_path` via the
|
|
# service constructor (the API uses the default). The Fernet key
|
|
# file is also at `/images/secrets/credential_key.b64`.
|
|
_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
|
_crypto: CredentialCrypto | None = None
|
|
|
|
|
|
def _get_crypto() -> CredentialCrypto:
|
|
global _crypto
|
|
if _crypto is None:
|
|
_crypto = CredentialCrypto(_KEY_PATH)
|
|
return _crypto
|
|
|
|
|
|
def _bad(error: str, *, status: int = 400, detail: str | None = None, **extra):
|
|
body = {"error": error}
|
|
if detail is not None:
|
|
body["detail"] = detail
|
|
body.update(extra)
|
|
return jsonify(body), status
|
|
|
|
|
|
async def _ext_key_ok(session) -> bool:
|
|
"""If X-Extension-Key is supplied, it must match the stored value.
|
|
Missing header → True (browser path; accepted per homelab posture).
|
|
"""
|
|
supplied = request.headers.get("X-Extension-Key")
|
|
if supplied is None:
|
|
return True
|
|
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
|
|
|
|
|
|
@credentials_bp.route("", methods=["GET"])
|
|
async def list_credentials():
|
|
async with get_session() as session:
|
|
if not await _ext_key_ok(session):
|
|
return _bad("unauthorized", status=401)
|
|
records = await CredentialService(session, _get_crypto()).list()
|
|
return jsonify([r.to_dict() for r in records])
|
|
|
|
|
|
@credentials_bp.route("/<platform>", methods=["GET"])
|
|
async def get_credential(platform: str):
|
|
async with get_session() as session:
|
|
if not await _ext_key_ok(session):
|
|
return _bad("unauthorized", status=401)
|
|
record = await CredentialService(session, _get_crypto()).get(platform)
|
|
if record is None:
|
|
return _bad("not_found", status=404)
|
|
return jsonify(record.to_dict())
|
|
|
|
|
|
@credentials_bp.route("", methods=["POST"])
|
|
async def upsert_credential():
|
|
body = await request.get_json()
|
|
if not isinstance(body, dict):
|
|
return _bad("invalid_body", detail="body must be a JSON object")
|
|
try:
|
|
platform = body["platform"]
|
|
credential_type = body["credential_type"]
|
|
data = body["data"]
|
|
except KeyError:
|
|
return _bad("invalid_body", detail="platform, credential_type, data are required")
|
|
if not isinstance(data, str):
|
|
return _bad("invalid_body", detail="data must be a string")
|
|
|
|
async with get_session() as session:
|
|
if not await _ext_key_ok(session):
|
|
return _bad("unauthorized", status=401)
|
|
existed = (await session.execute(
|
|
select(Credential).where(Credential.platform == platform)
|
|
)).scalar_one_or_none() is not None
|
|
svc = CredentialService(session, _get_crypto())
|
|
try:
|
|
record = await svc.upsert(
|
|
platform=platform, credential_type=credential_type, data=data,
|
|
)
|
|
except UnknownPlatformError as exc:
|
|
return _bad("unknown_platform", detail=str(exc))
|
|
except WrongAuthTypeError as exc:
|
|
return _bad("wrong_auth_type", detail=str(exc), expected=exc.expected)
|
|
except EmptyDataError as exc:
|
|
return _bad("empty_data", detail=str(exc))
|
|
return jsonify(record.to_dict()), (200 if existed else 201)
|
|
|
|
|
|
@credentials_bp.route("/<platform>", methods=["DELETE"])
|
|
async def delete_credential(platform: str):
|
|
async with get_session() as session:
|
|
if not await _ext_key_ok(session):
|
|
return _bad("unauthorized", status=401)
|
|
svc = CredentialService(session, _get_crypto())
|
|
try:
|
|
await svc.delete(platform)
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
return "", 204
|
|
|
|
|
|
@credentials_bp.route("/<platform>/verify", methods=["POST"])
|
|
async def verify_credential(platform: str):
|
|
"""Test the stored credential by running gallery-dl --simulate
|
|
against one of the platform's enabled sources. On success stamps
|
|
last_verified. Returns {valid: bool|null, reason, last_verified?}.
|
|
valid=null means "couldn't test" (no credential, or no enabled
|
|
source to point at)."""
|
|
from ..models import Artist, Source
|
|
from ..services.gallery_dl import GalleryDLService, SourceConfig
|
|
|
|
async with get_session() as session:
|
|
if not await _ext_key_ok(session):
|
|
return _bad("unauthorized", status=401)
|
|
svc = CredentialService(session, _get_crypto())
|
|
record = await svc.get(platform)
|
|
if record is None:
|
|
return jsonify({"valid": None, "reason": "No credential stored for this platform."})
|
|
|
|
# Pick an enabled source for this platform to point the probe at.
|
|
row = (await session.execute(
|
|
select(Source, Artist)
|
|
.join(Artist, Artist.id == Source.artist_id)
|
|
.where(Source.platform == platform, Source.enabled.is_(True))
|
|
.order_by(Source.id.asc())
|
|
)).first()
|
|
if row is None:
|
|
return jsonify({
|
|
"valid": None,
|
|
"reason": "No enabled source for this platform to verify against — add a subscription first.",
|
|
})
|
|
source, artist = row
|
|
|
|
cookies_path = await svc.get_cookies_path(platform)
|
|
auth_token = await svc.get_token(platform)
|
|
|
|
gdl = GalleryDLService(images_root=Path("/images"))
|
|
ok, message = await gdl.verify(
|
|
url=source.url,
|
|
artist_slug=artist.slug,
|
|
platform=platform,
|
|
source_config=SourceConfig.from_dict(source.config_overrides or {}),
|
|
cookies_path=str(cookies_path) if cookies_path else None,
|
|
auth_token=auth_token,
|
|
)
|
|
|
|
last_verified = None
|
|
if ok:
|
|
async with get_session() as session:
|
|
ts = await CredentialService(session, _get_crypto()).mark_verified(platform)
|
|
last_verified = ts.isoformat() if ts else None
|
|
return jsonify({"valid": ok, "reason": message, "last_verified": last_verified})
|