diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py index 182792a..395846a 100644 --- a/backend/app/api/credentials.py +++ b/backend/app/api/credentials.py @@ -124,3 +124,56 @@ async def delete_credential(platform: str): except LookupError: return _bad("not_found", status=404) return "", 204 + + +@credentials_bp.route("//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}) diff --git a/backend/app/services/credential_service.py b/backend/app/services/credential_service.py index f467eb0..a8f6d8c 100644 --- a/backend/app/services/credential_service.py +++ b/backend/app/services/credential_service.py @@ -7,7 +7,7 @@ from __future__ import annotations import json import os from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from sqlalchemy import select @@ -163,6 +163,19 @@ class CredentialService: 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 diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index 5e9b17f..497bcb4 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -658,3 +658,64 @@ class GalleryDLService: Path(temp_config_path).unlink() # noqa: ASYNC240 except Exception: pass + + async def verify( + self, + url: str, + artist_slug: str, + platform: str, + source_config: SourceConfig | None = None, + cookies_path: str | None = None, + auth_token: str | None = None, + timeout: float = 45.0, + ) -> tuple[bool, str]: + """Test that credentials authenticate against `url` WITHOUT + downloading anything. Runs gallery-dl in --simulate mode limited + to the first item; if auth is bad the extractor errors before it + can list, which _categorize_error flags as AUTH_ERROR. Returns + (ok, message). Used by the credential Verify button.""" + if source_config is None: + source_config = SourceConfig() + config = self._build_config_for_source(platform, source_config, artist_slug) + if cookies_path: + config["extractor"]["cookies"] = cookies_path + if auth_token and platform == "discord": + config["extractor"].setdefault("discord", {})["token"] = auth_token + if auth_token and platform == "pixiv": + config["extractor"].setdefault("pixiv", {})["refresh-token"] = auth_token + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, dir=str(self._config_dir), + ) as fh: + json.dump(config, fh, indent=2) + temp_config_path = fh.name + try: + cmd = [ + sys.executable, "-m", "gallery_dl", + "--config", temp_config_path, + "--simulate", "--range", "1-1", "--verbose", url, + ] + loop = asyncio.get_running_loop() + proc = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ), + ) + etype, msg = self._categorize_error(proc.returncode, proc.stdout, proc.stderr) + if proc.returncode == 0 or etype == ErrorType.NO_NEW_CONTENT: + return True, "Credentials valid — the feed authenticated." + if etype == ErrorType.AUTH_ERROR: + return False, msg + # Network / not-found / rate-limit / unknown: inconclusive, + # not a definitive credential failure. Surface the reason. + return False, f"Could not confirm ({etype.value}): {msg}" + except subprocess.TimeoutExpired: + return False, f"Verification timed out after {timeout:.0f}s" + except Exception as exc: # noqa: BLE001 + return False, f"Verification error: {exc}" + finally: + try: + Path(temp_config_path).unlink() # noqa: ASYNC240 + except Exception: + pass diff --git a/frontend/src/components/subscriptions/CredentialCard.vue b/frontend/src/components/subscriptions/CredentialCard.vue index 300b95e..0a1d834 100644 --- a/frontend/src/components/subscriptions/CredentialCard.vue +++ b/frontend/src/components/subscriptions/CredentialCard.vue @@ -55,7 +55,21 @@ - + {{ verifyChipLabel }} + + + Verify +