diff --git a/scripts/export_for_fabledcurator.py b/scripts/export_for_fabledcurator.py index 5372b03..2075f7c 100644 --- a/scripts/export_for_fabledcurator.py +++ b/scripts/export_for_fabledcurator.py @@ -11,71 +11,105 @@ Credentials are decrypted in this script's process using GS's secret_key ingest with its own Fernet key. The output JSON contains plaintext credentials — delete it after the FC ingest completes. -Usage: - python scripts/export_for_fabledcurator.py --secret-key "$SECRET_KEY" > gs-export.json +Designed to run INSIDE the live GS container: + docker cp scripts/export_for_fabledcurator.py :/app/scripts/ + docker exec -w /app python scripts/export_for_fabledcurator.py \\ + --secret-key "$SECRET_KEY" > ~/gs-export.json + +The container has GS's Python deps (asyncpg, sqlalchemy, cryptography) +and the runtime env vars (DATABASE_URL, SECRET_KEY) already set. """ from __future__ import annotations import argparse +import asyncio import json import os import sys from datetime import UTC, datetime -# Bootstrap GS's app context. -sys.path.insert(0, "backend") -from app import create_app # noqa: E402 -from app.database import get_session # noqa: E402 +# Ensure the container's WORKDIR (/app) is on sys.path so the GS package +# `app.*` imports resolve. Container layout: /app/app/ is the package. +sys.path.insert(0, os.getcwd()) + +from sqlalchemy import select # noqa: E402 +from sqlalchemy.ext.asyncio import ( # noqa: E402 + AsyncSession, + async_sessionmaker, + create_async_engine, +) + from app.models.credential import Credential # noqa: E402 from app.models.source import Source # noqa: E402 from app.models.subscription import Subscription # noqa: E402 from app.utils.encryption import decrypt_data # noqa: E402 -def _export_subscriptions(session) -> list[dict]: - """Subscriptions with nested sources.""" - out: list[dict] = [] - for sub in session.query(Subscription).all(): - sources_out: list[dict] = [] - for src in sub.sources or []: - sources_out.append({ - "platform": src.platform, - "url": src.url, - "enabled": bool(src.enabled), - "check_interval": int(src.check_interval or 28800), - "metadata": src.metadata_ or {}, - }) - out.append({ - "name": sub.name, - "enabled": bool(sub.enabled), - "metadata": sub.metadata_ or {}, - "sources": sources_out, - }) - return out +def _async_database_url() -> str: + """Resolve GS's async DB URL the same way app.config.Settings does.""" + url = os.environ.get("DATABASE_URL") + if not url: + # GS's default fallback chain — should rarely fire in prod since + # the container's DATABASE_URL is set by compose. + url = "postgresql://gdl:password@localhost:5432/gallery_subscriber" + return url.replace("postgresql://", "postgresql+asyncpg://") -def _export_credentials(session, secret_key: str) -> list[dict]: - """Credentials decrypted to plaintext (FC re-encrypts on ingest).""" - out: list[dict] = [] - for cred in session.query(Credential).all(): - try: - plaintext = decrypt_data(cred.data, secret_key) - except Exception as exc: - print( - f"WARNING: failed to decrypt credential for " - f"platform={cred.platform!r}: {exc}", - file=sys.stderr, - ) - continue - out.append({ - "platform": cred.platform, - "credential_type": cred.credential_type, - "plaintext": plaintext, - "expires_at": ( - cred.expires_at.isoformat() if cred.expires_at else None - ), - }) - return out +async def _export_async(secret_key: str) -> dict: + engine = create_async_engine(_async_database_url()) + Session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + try: + async with Session() as session: + subs_out: list[dict] = [] + subs = (await session.execute(select(Subscription))).scalars().all() + for sub in subs: + srcs = (await session.execute( + select(Source).where(Source.subscription_id == sub.id) + )).scalars().all() + sources_out = [{ + "platform": s.platform, + "url": s.url, + "enabled": bool(s.enabled), + "check_interval": int(s.check_interval or 28800), + "metadata": s.metadata_ or {}, + } for s in srcs] + subs_out.append({ + "name": sub.name, + "enabled": bool(sub.enabled), + "metadata": sub.metadata_ or {}, + "sources": sources_out, + }) + + creds_out: list[dict] = [] + creds = (await session.execute(select(Credential))).scalars().all() + for cred in creds: + try: + plaintext = decrypt_data(cred.data, secret_key) + except Exception as exc: + print( + f"WARNING: failed to decrypt credential for " + f"platform={cred.platform!r}: {exc}", + file=sys.stderr, + ) + continue + creds_out.append({ + "platform": cred.platform, + "credential_type": cred.credential_type, + "plaintext": plaintext, + "expires_at": ( + cred.expires_at.isoformat() if cred.expires_at else None + ), + }) + + return { + "source_app": "gallerysubscriber", + "schema_version": 1, + "exported_at": datetime.now(UTC).isoformat(), + "subscriptions": subs_out, + "credentials": creds_out, + } + finally: + await engine.dispose() def main() -> None: @@ -93,16 +127,7 @@ def main() -> None: ) sys.exit(2) - app = create_app() - with app.app_context(): - with get_session() as session: - export = { - "source_app": "gallerysubscriber", - "schema_version": 1, - "exported_at": datetime.now(UTC).isoformat(), - "subscriptions": _export_subscriptions(session), - "credentials": _export_credentials(session, args.secret_key), - } + export = asyncio.run(_export_async(args.secret_key)) json.dump(export, sys.stdout, indent=2) sys.stdout.write("\n")