Files
FabledCurator/backend/app/services/credential_service.py
T
bvandeusen 56970fb66d feat(credentials+downloads): real credential Verify button + live download-activity polling
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.
2026-05-28 07:52:20 -04:00

227 lines
7.9 KiB
Python

"""FC-3b: CRUD over Credential rows + on-demand cookies-file
materialisation for gallery-dl (consumed by FC-3c).
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Credential
from .credential_crypto import CredentialCrypto
from .platforms import PLATFORMS, auth_type_for
class CredentialServiceError(Exception):
"""Base."""
class UnknownPlatformError(CredentialServiceError):
pass
class WrongAuthTypeError(CredentialServiceError):
def __init__(self, expected: str):
super().__init__(f"wrong credential_type for platform; expected {expected!r}")
self.expected = expected
class EmptyDataError(CredentialServiceError):
pass
@dataclass(frozen=True)
class CredentialRecord:
platform: str
credential_type: str
captured_at: str
expires_at: str | None
last_verified: str | None
def to_dict(self) -> dict:
return {
"platform": self.platform,
"credential_type": self.credential_type,
"captured_at": self.captured_at,
"expires_at": self.expires_at,
"last_verified": self.last_verified,
}
def _to_record(row: Credential) -> CredentialRecord:
return CredentialRecord(
platform=row.platform,
credential_type=row.credential_type,
captured_at=row.captured_at.isoformat() if row.captured_at else "",
expires_at=row.expires_at.isoformat() if row.expires_at else None,
last_verified=row.last_verified.isoformat() if row.last_verified else None,
)
# Default cookies dir under the image root; tests override.
_DEFAULT_COOKIES_DIR = Path("/images/cookies")
class CredentialService:
def __init__(
self,
session: AsyncSession,
crypto: CredentialCrypto,
cookies_dir: Path | None = None,
):
self.session = session
self.crypto = crypto
self.cookies_dir = Path(cookies_dir) if cookies_dir else _DEFAULT_COOKIES_DIR
async def list(self) -> list[CredentialRecord]:
rows = (await self.session.execute(
select(Credential).order_by(Credential.platform.asc())
)).scalars().all()
return [_to_record(r) for r in rows]
async def get(self, platform: str) -> CredentialRecord | None:
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
return _to_record(row) if row else None
async def upsert(
self,
*,
platform: str,
credential_type: str,
data: str,
expires_at: datetime | None = None,
) -> CredentialRecord:
if platform not in PLATFORMS:
raise UnknownPlatformError(f"unknown platform: {platform!r}")
expected = auth_type_for(platform)
if credential_type != expected:
raise WrongAuthTypeError(expected=expected or "")
cleaned = (data or "").strip()
if not cleaned:
raise EmptyDataError("data must be a non-empty string")
blob = self.crypto.encrypt(cleaned)
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
row = Credential(
platform=platform,
credential_type=credential_type,
encrypted_blob=blob,
expires_at=expires_at,
)
self.session.add(row)
else:
row.credential_type = credential_type
row.encrypted_blob = blob
row.expires_at = expires_at
row.last_verified = None # invalidate prior verification
await self.session.commit()
await self.session.refresh(row)
return _to_record(row)
async def delete(self, platform: str) -> None:
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
raise LookupError(f"no credential for platform {platform!r}")
await self.session.delete(row)
await self.session.commit()
async def get_cookies_path(self, platform: str) -> Path | None:
"""Decrypt the credential and write a Netscape cookies.txt for
gallery-dl. Returns None if no credential or wrong kind."""
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None or row.credential_type != "cookies":
return None
plaintext = self.crypto.decrypt(row.encrypted_blob)
netscape = _to_netscape(plaintext)
netscape = _augment_cookies(platform, netscape)
self.cookies_dir.mkdir(parents=True, exist_ok=True)
out = self.cookies_dir / f"{platform}_cookies.txt"
out.write_text(netscape)
os.chmod(out, 0o600)
return out
async def get_token(self, platform: str) -> str | None:
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None or row.credential_type != "token":
return None
return self.crypto.decrypt(row.encrypted_blob)
async def mark_verified(self, platform: str) -> datetime | None:
"""Stamp last_verified=now after a successful verify. Returns the
timestamp, or None if the credential is gone."""
row = (await self.session.execute(
select(Credential).where(Credential.platform == platform)
)).scalar_one_or_none()
if row is None:
return None
ts = datetime.now(UTC)
row.last_verified = ts
await self.session.commit()
return ts
def _augment_cookies(platform: str, netscape: str) -> str:
"""Delegate to the platform's `augment_cookies` hook if one is
registered (subscribestar, hentaifoundry, etc. — see
`services/platforms/<name>.py`). No-op when the platform doesn't
register a hook (Patreon, DeviantArt). Centralizing the
quirks-per-platform in the platforms package means adding a new
platform's cookie quirks doesn't require touching this file."""
info = PLATFORMS.get(platform)
if info is None or info.augment_cookies is None:
return netscape
return info.augment_cookies(netscape)
def _to_netscape(plaintext: str) -> str:
"""Accept either Netscape-format text (the extension's output) or a
JSON array of cookie dicts (a manual-paste edge case); produce
Netscape-format text suitable for gallery-dl --cookies."""
stripped = plaintext.strip()
if not stripped:
return ""
if stripped.startswith("#") or "\t" in stripped:
return plaintext # already Netscape
try:
loaded = json.loads(stripped)
except json.JSONDecodeError:
return plaintext # write as-is and hope; gallery-dl will complain if invalid
if isinstance(loaded, dict):
loaded = [loaded]
if not isinstance(loaded, list):
return plaintext
lines = ["# Netscape HTTP Cookie File"]
for c in loaded:
domain = str(c.get("domain", ""))
if domain and not domain.startswith("."):
domain = "." + domain
flag = "TRUE"
path = str(c.get("path", "/"))
secure = "TRUE" if c.get("secure", False) else "FALSE"
expiration = c.get("expiration") or c.get("expirationDate") or 0
try:
expiration_str = str(int(float(expiration)))
except (TypeError, ValueError):
expiration_str = "0"
name = str(c.get("name", ""))
value = str(c.get("value", ""))
lines.append("\t".join([domain, flag, path, secure, expiration_str, name, value]))
return "\n".join(lines) + "\n"