Files
FabledCurator/backend/app/api/credentials.py
T
bvandeusen 597c6d48d3 refactor(dry-B1): consolidate duplicated _bad error helper into api/_responses.py
8 blueprints each defined an identical _bad() (two variants: with/without
detail). Extracted error_response() into api/_responses.py; each blueprint
now imports it `as _bad` so call sites are unchanged. The detail-aware
canonical subsumes both variants. Left settings.py's distinct _bad_int and
the inline jsonify error sites (not duplicated helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:10:04 -04:00

173 lines
6.5 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,
)
from ._responses import error_response as _bad
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
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})