Decouple cross-domain DB queries for per-app database split
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m2s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m2s
Move Ghost membership sync from blog to account service so blog no longer queries account tables (users, ghost_labels, etc.). Account runs membership sync at startup and exposes HTTP action/data endpoints for webhook-triggered syncs and user lookups. Key changes: - account/services/ghost_membership.py: all membership sync functions - account/bp/actions + data: ghost-sync-member, user-by-email, newsletters - blog ghost_sync.py: stripped to content-only (posts, authors, tags) - blog webhook member: delegates to account via call_action() - try_publish: opens federation session when DBs differ - oauth.py callback: uses get_account_session() for OAuthCode - page_configs moved from db_events to db_blog in split script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
_config/move-page-configs.sql
Normal file
17
_config/move-page-configs.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Move page_configs data from db_events to db_blog.
|
||||
-- Run after split-databases.sh if page_configs data ended up in db_events.
|
||||
--
|
||||
-- Usage:
|
||||
-- PGHOST=db PGUSER=postgres PGPASSWORD=change-me psql -f move-page-configs.sql
|
||||
--
|
||||
|
||||
-- Step 1: Dump page_configs from db_events into db_blog
|
||||
\c db_events
|
||||
COPY page_configs TO '/tmp/page_configs.csv' WITH CSV HEADER;
|
||||
|
||||
\c db_blog
|
||||
TRUNCATE page_configs;
|
||||
COPY page_configs FROM '/tmp/page_configs.csv' WITH CSV HEADER;
|
||||
|
||||
-- Step 2: Verify
|
||||
SELECT count(*) AS blog_page_configs FROM page_configs;
|
||||
@@ -42,6 +42,7 @@ DB_TABLES[db_blog]="
|
||||
menu_items
|
||||
menu_nodes
|
||||
container_relations
|
||||
page_configs
|
||||
"
|
||||
|
||||
DB_TABLES[db_market]="
|
||||
@@ -78,7 +79,6 @@ DB_TABLES[db_events]="
|
||||
calendar_entry_posts
|
||||
ticket_types
|
||||
tickets
|
||||
page_configs
|
||||
"
|
||||
|
||||
DB_TABLES[db_federation]="
|
||||
|
||||
@@ -77,6 +77,25 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_account_bp())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
from bp.data.routes import register as register_data
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
# --- Ghost membership sync at startup ---
|
||||
@app.before_serving
|
||||
async def _sync_ghost_membership():
|
||||
from services.ghost_membership import sync_all_membership_from_ghost
|
||||
from shared.db.session import get_session
|
||||
try:
|
||||
async with get_session() as s:
|
||||
await sync_all_membership_from_ghost(s)
|
||||
await s.commit()
|
||||
print("[account] Ghost membership sync complete")
|
||||
except Exception as e:
|
||||
print(f"[account] Ghost membership sync failed (non-fatal): {e}")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
0
account/bp/actions/__init__.py
Normal file
0
account/bp/actions/__init__.py
Normal file
64
account/bp/actions/routes.py
Normal file
64
account/bp/actions/routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Account app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (blog webhooks) via the internal action client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- ghost-sync-member ---
|
||||
async def _ghost_sync_member():
|
||||
"""Sync a single Ghost member into db_account."""
|
||||
data = await request.get_json()
|
||||
ghost_id = data.get("ghost_id")
|
||||
if not ghost_id:
|
||||
return {"error": "ghost_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_single_member
|
||||
await sync_single_member(g.s, ghost_id)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["ghost-sync-member"] = _ghost_sync_member
|
||||
|
||||
# --- ghost-push-member ---
|
||||
async def _ghost_push_member():
|
||||
"""Push a local user's membership data to Ghost."""
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return {"error": "user_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_member_to_ghost
|
||||
result_id = await sync_member_to_ghost(g.s, int(user_id))
|
||||
return {"ok": True, "ghost_id": result_id}
|
||||
|
||||
_handlers["ghost-push-member"] = _ghost_push_member
|
||||
|
||||
return bp
|
||||
0
account/bp/data/__init__.py
Normal file
0
account/bp/data/__init__.py
Normal file
64
account/bp/data/routes.py
Normal file
64
account/bp/data/routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Account app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from sqlalchemy import select
|
||||
from shared.models import User
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- user-by-email ---
|
||||
async def _user_by_email():
|
||||
"""Return user_id for a given email address."""
|
||||
email = request.args.get("email", "").strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
result = await g.s.execute(
|
||||
select(User.id).where(User.email.ilike(email))
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return {"user_id": row[0]}
|
||||
|
||||
_handlers["user-by-email"] = _user_by_email
|
||||
|
||||
# --- newsletters ---
|
||||
async def _newsletters():
|
||||
"""Return all Ghost newsletters (for blog post editor)."""
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
|
||||
.order_by(GhostNewsletter.name)
|
||||
)
|
||||
return [
|
||||
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
_handlers["newsletters"] = _newsletters
|
||||
|
||||
return bp
|
||||
621
account/services/ghost_membership.py
Normal file
621
account/services/ghost_membership.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""Ghost membership sync — account-owned.
|
||||
|
||||
Handles Ghost ↔ DB sync for user/membership data:
|
||||
- Ghost → DB: fetch members from Ghost API, upsert into account tables
|
||||
- DB → Ghost: push local user changes back to Ghost API
|
||||
|
||||
All tables involved (users, ghost_labels, user_labels, ghost_newsletters,
|
||||
user_newsletters, ghost_tiers, ghost_subscriptions) live in db_account.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, delete, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from shared.models import User
|
||||
from shared.models.ghost_membership_entities import (
|
||||
GhostLabel, UserLabel,
|
||||
GhostNewsletter, UserNewsletter,
|
||||
GhostTier, GhostSubscription,
|
||||
)
|
||||
|
||||
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt
|
||||
from urllib.parse import quote
|
||||
|
||||
GHOST_ADMIN_API_URL = os.environ.get("GHOST_ADMIN_API_URL", "")
|
||||
|
||||
|
||||
def _auth_header() -> dict[str, str]:
|
||||
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
|
||||
|
||||
|
||||
def _iso(val: str | None) -> datetime | None:
|
||||
if not val:
|
||||
return None
|
||||
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _to_str_or_none(v) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (dict, list, set, tuple, bytes, bytearray)):
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _sanitize_member_payload(payload: dict) -> dict:
|
||||
"""Coerce types Ghost expects and drop empties to avoid 422/500 quirks."""
|
||||
out: dict = {}
|
||||
|
||||
email = _to_str_or_none(payload.get("email"))
|
||||
if email:
|
||||
out["email"] = email.lower()
|
||||
|
||||
name = _to_str_or_none(payload.get("name"))
|
||||
if name is not None:
|
||||
out["name"] = name
|
||||
|
||||
note = _to_str_or_none(payload.get("note"))
|
||||
if note is not None:
|
||||
out["note"] = note
|
||||
|
||||
if "subscribed" in payload:
|
||||
out["subscribed"] = bool(payload.get("subscribed"))
|
||||
|
||||
labels = []
|
||||
for item in payload.get("labels") or []:
|
||||
gid = _to_str_or_none(item.get("id"))
|
||||
gname = _to_str_or_none(item.get("name"))
|
||||
if gid:
|
||||
labels.append({"id": gid})
|
||||
elif gname:
|
||||
labels.append({"name": gname})
|
||||
if labels:
|
||||
out["labels"] = labels
|
||||
|
||||
newsletters = []
|
||||
for item in payload.get("newsletters") or []:
|
||||
gid = _to_str_or_none(item.get("id"))
|
||||
gname = _to_str_or_none(item.get("name"))
|
||||
row = {"subscribed": bool(item.get("subscribed", True))}
|
||||
if gid:
|
||||
row["id"] = gid
|
||||
newsletters.append(row)
|
||||
elif gname:
|
||||
row["name"] = gname
|
||||
newsletters.append(row)
|
||||
if newsletters:
|
||||
out["newsletters"] = newsletters
|
||||
|
||||
gid = _to_str_or_none(payload.get("id"))
|
||||
if gid:
|
||||
out["id"] = gid
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _member_email(m: dict[str, Any]) -> Optional[str]:
|
||||
email = (m.get("email") or "").strip().lower() or None
|
||||
return email
|
||||
|
||||
|
||||
# ---- upsert helpers for related entities ----
|
||||
|
||||
async def _upsert_label(sess: AsyncSession, data: dict) -> GhostLabel:
|
||||
res = await sess.execute(select(GhostLabel).where(GhostLabel.ghost_id == data["id"]))
|
||||
obj = res.scalar_one_or_none()
|
||||
if not obj:
|
||||
obj = GhostLabel(ghost_id=data["id"])
|
||||
sess.add(obj)
|
||||
obj.name = data.get("name") or obj.name
|
||||
obj.slug = data.get("slug") or obj.slug
|
||||
await sess.flush()
|
||||
return obj
|
||||
|
||||
|
||||
async def _upsert_newsletter(sess: AsyncSession, data: dict) -> GhostNewsletter:
|
||||
res = await sess.execute(select(GhostNewsletter).where(GhostNewsletter.ghost_id == data["id"]))
|
||||
obj = res.scalar_one_or_none()
|
||||
if not obj:
|
||||
obj = GhostNewsletter(ghost_id=data["id"])
|
||||
sess.add(obj)
|
||||
obj.name = data.get("name") or obj.name
|
||||
obj.slug = data.get("slug") or obj.slug
|
||||
obj.description = data.get("description") or obj.description
|
||||
await sess.flush()
|
||||
return obj
|
||||
|
||||
|
||||
async def _upsert_tier(sess: AsyncSession, data: dict) -> GhostTier:
|
||||
res = await sess.execute(select(GhostTier).where(GhostTier.ghost_id == data["id"]))
|
||||
obj = res.scalar_one_or_none()
|
||||
if not obj:
|
||||
obj = GhostTier(ghost_id=data["id"])
|
||||
sess.add(obj)
|
||||
obj.name = data.get("name") or obj.name
|
||||
obj.slug = data.get("slug") or obj.slug
|
||||
obj.type = data.get("type") or obj.type
|
||||
obj.visibility = data.get("visibility") or obj.visibility
|
||||
await sess.flush()
|
||||
return obj
|
||||
|
||||
|
||||
def _price_cents(sd: dict) -> Optional[int]:
|
||||
try:
|
||||
return int((sd.get("price") or {}).get("amount"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---- find/create user by ghost_id or email ----
|
||||
|
||||
async def _find_or_create_user_by_ghost_or_email(sess: AsyncSession, data: dict) -> User:
|
||||
ghost_id = data.get("id")
|
||||
email = _member_email(data)
|
||||
|
||||
if ghost_id:
|
||||
res = await sess.execute(select(User).where(User.ghost_id == ghost_id))
|
||||
u = res.scalar_one_or_none()
|
||||
if u:
|
||||
return u
|
||||
|
||||
if email:
|
||||
res = await sess.execute(select(User).where(User.email.ilike(email)))
|
||||
u = res.scalar_one_or_none()
|
||||
if u:
|
||||
if ghost_id and not u.ghost_id:
|
||||
u.ghost_id = ghost_id
|
||||
return u
|
||||
|
||||
u = User(email=email or f"_ghost_{ghost_id}@invalid.local")
|
||||
if ghost_id:
|
||||
u.ghost_id = ghost_id
|
||||
sess.add(u)
|
||||
await sess.flush()
|
||||
return u
|
||||
|
||||
|
||||
# ---- apply membership data to user ----
|
||||
|
||||
async def _apply_user_membership(sess: AsyncSession, user: User, m: dict) -> User:
|
||||
"""Apply Ghost member payload to local User."""
|
||||
sess.add(user)
|
||||
|
||||
user.name = m.get("name") or user.name
|
||||
user.ghost_status = m.get("status") or user.ghost_status
|
||||
user.ghost_subscribed = bool(m.get("subscribed", True))
|
||||
user.ghost_note = m.get("note") or user.ghost_note
|
||||
user.avatar_image = m.get("avatar_image") or user.avatar_image
|
||||
user.stripe_customer_id = (
|
||||
(m.get("stripe") or {}).get("customer_id")
|
||||
or (m.get("customer") or {}).get("id")
|
||||
or m.get("stripe_customer_id")
|
||||
or user.stripe_customer_id
|
||||
)
|
||||
user.ghost_raw = dict(m)
|
||||
flag_modified(user, "ghost_raw")
|
||||
|
||||
await sess.flush()
|
||||
|
||||
# Labels join
|
||||
label_ids: list[int] = []
|
||||
for ld in m.get("labels") or []:
|
||||
lbl = await _upsert_label(sess, ld)
|
||||
label_ids.append(lbl.id)
|
||||
await sess.execute(delete(UserLabel).where(UserLabel.user_id == user.id))
|
||||
for lid in label_ids:
|
||||
sess.add(UserLabel(user_id=user.id, label_id=lid))
|
||||
await sess.flush()
|
||||
|
||||
# Newsletters join with subscribed flag
|
||||
nl_rows: list[tuple[int, bool]] = []
|
||||
for nd in m.get("newsletters") or []:
|
||||
nl = await _upsert_newsletter(sess, nd)
|
||||
nl_rows.append((nl.id, bool(nd.get("subscribed", True))))
|
||||
await sess.execute(delete(UserNewsletter).where(UserNewsletter.user_id == user.id))
|
||||
for nl_id, subbed in nl_rows:
|
||||
sess.add(UserNewsletter(user_id=user.id, newsletter_id=nl_id, subscribed=subbed))
|
||||
await sess.flush()
|
||||
|
||||
# Subscriptions
|
||||
for sd in m.get("subscriptions") or []:
|
||||
sid = sd.get("id")
|
||||
if not sid:
|
||||
continue
|
||||
|
||||
tier_id: Optional[int] = None
|
||||
if sd.get("tier"):
|
||||
tier = await _upsert_tier(sess, sd["tier"])
|
||||
await sess.flush()
|
||||
tier_id = tier.id
|
||||
|
||||
res = await sess.execute(select(GhostSubscription).where(GhostSubscription.ghost_id == sid))
|
||||
sub = res.scalar_one_or_none()
|
||||
if not sub:
|
||||
sub = GhostSubscription(ghost_id=sid, user_id=user.id)
|
||||
sess.add(sub)
|
||||
|
||||
sub.user_id = user.id
|
||||
sub.status = sd.get("status") or sub.status
|
||||
sub.cadence = (sd.get("plan") or {}).get("interval") or sd.get("cadence") or sub.cadence
|
||||
sub.price_amount = _price_cents(sd)
|
||||
sub.price_currency = (sd.get("price") or {}).get("currency") or sub.price_currency
|
||||
sub.stripe_customer_id = (
|
||||
(sd.get("customer") or {}).get("id")
|
||||
or (sd.get("stripe") or {}).get("customer_id")
|
||||
or sub.stripe_customer_id
|
||||
)
|
||||
sub.stripe_subscription_id = (
|
||||
sd.get("stripe_subscription_id")
|
||||
or (sd.get("stripe") or {}).get("subscription_id")
|
||||
or sub.stripe_subscription_id
|
||||
)
|
||||
if tier_id is not None:
|
||||
sub.tier_id = tier_id
|
||||
sub.raw = dict(sd)
|
||||
flag_modified(sub, "raw")
|
||||
|
||||
await sess.flush()
|
||||
return user
|
||||
|
||||
|
||||
# =====================================================
|
||||
# PUSH MEMBERS FROM LOCAL DB -> GHOST (DB -> Ghost)
|
||||
# =====================================================
|
||||
|
||||
def _ghost_member_payload_base(u: User) -> dict:
|
||||
email = _to_str_or_none(getattr(u, "email", None))
|
||||
payload: dict = {}
|
||||
if email:
|
||||
payload["email"] = email.lower()
|
||||
|
||||
name = _to_str_or_none(getattr(u, "name", None))
|
||||
if name:
|
||||
payload["name"] = name
|
||||
|
||||
note = _to_str_or_none(getattr(u, "ghost_note", None))
|
||||
if note:
|
||||
payload["note"] = note
|
||||
|
||||
subscribed = getattr(u, "ghost_subscribed", True)
|
||||
payload["subscribed"] = bool(subscribed)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def _newsletters_for_user(sess: AsyncSession, user_id: int) -> list[dict]:
|
||||
q = await sess.execute(
|
||||
select(GhostNewsletter.ghost_id, UserNewsletter.subscribed, GhostNewsletter.name)
|
||||
.join(UserNewsletter, UserNewsletter.newsletter_id == GhostNewsletter.id)
|
||||
.where(UserNewsletter.user_id == user_id)
|
||||
)
|
||||
seen = set()
|
||||
out: list[dict] = []
|
||||
for gid, subscribed, name in q.all():
|
||||
gid = (gid or "").strip() or None
|
||||
name = (name or "").strip() or None
|
||||
row: dict = {"subscribed": bool(subscribed)}
|
||||
if gid:
|
||||
key = ("id", gid)
|
||||
if key in seen:
|
||||
continue
|
||||
row["id"] = gid
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
elif name:
|
||||
key = ("name", name.lower())
|
||||
if key in seen:
|
||||
continue
|
||||
row["name"] = name
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
async def _labels_for_user(sess: AsyncSession, user_id: int) -> list[dict]:
|
||||
q = await sess.execute(
|
||||
select(GhostLabel.ghost_id, GhostLabel.name)
|
||||
.join(UserLabel, UserLabel.label_id == GhostLabel.id)
|
||||
.where(UserLabel.user_id == user_id)
|
||||
)
|
||||
seen = set()
|
||||
out: list[dict] = []
|
||||
for gid, name in q.all():
|
||||
gid = (gid or "").strip() or None
|
||||
name = (name or "").strip() or None
|
||||
if gid:
|
||||
key = ("id", gid)
|
||||
if key not in seen:
|
||||
out.append({"id": gid})
|
||||
seen.add(key)
|
||||
elif name:
|
||||
key = ("name", name.lower())
|
||||
if key not in seen:
|
||||
out.append({"name": name})
|
||||
seen.add(key)
|
||||
return out
|
||||
|
||||
|
||||
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
|
||||
if not email:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(
|
||||
f"{GHOST_ADMIN_API_URL}/members/?filter=email:{quote(email)}&limit=1",
|
||||
headers=_auth_header(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
members = resp.json().get("members") or []
|
||||
return members[0] if members else None
|
||||
|
||||
|
||||
async def _ghost_upsert_member(payload: dict, ghost_id: str | None = None) -> dict:
|
||||
"""Create/update a member, with sanitization + 5xx retry/backoff."""
|
||||
safe_keys = ("email", "name", "note", "subscribed", "labels", "newsletters", "id")
|
||||
pl_raw = {k: v for k, v in payload.items() if k in safe_keys}
|
||||
pl = _sanitize_member_payload(pl_raw)
|
||||
|
||||
async def _request_with_retry(client: httpx.AsyncClient, method: str, url: str, json: dict) -> httpx.Response:
|
||||
delay = 0.5
|
||||
for attempt in range(3):
|
||||
r = await client.request(method, url, headers=_auth_header(), json=json)
|
||||
if r.status_code >= 500:
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
return r
|
||||
return r
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
|
||||
async def _put(mid: str, p: dict) -> dict:
|
||||
r = await _request_with_retry(
|
||||
client, "PUT",
|
||||
f"{GHOST_ADMIN_API_URL}/members/{mid}/",
|
||||
{"members": [p]},
|
||||
)
|
||||
if r.status_code == 404:
|
||||
existing = await _ghost_find_member_by_email(p.get("email", ""))
|
||||
if existing and existing.get("id"):
|
||||
r2 = await _request_with_retry(
|
||||
client, "PUT",
|
||||
f"{GHOST_ADMIN_API_URL}/members/{existing['id']}/",
|
||||
{"members": [p]},
|
||||
)
|
||||
r2.raise_for_status()
|
||||
return (r2.json().get("members") or [None])[0] or {}
|
||||
r3 = await _request_with_retry(
|
||||
client, "POST",
|
||||
f"{GHOST_ADMIN_API_URL}/members/",
|
||||
{"members": [p]},
|
||||
)
|
||||
r3.raise_for_status()
|
||||
return (r3.json().get("members") or [None])[0] or {}
|
||||
|
||||
if r.status_code == 422:
|
||||
body = (r.text or "").lower()
|
||||
retry = dict(p)
|
||||
dropped = False
|
||||
if '"note"' in body or "for note" in body:
|
||||
retry.pop("note", None); dropped = True
|
||||
if '"name"' in body or "for name" in body:
|
||||
retry.pop("name", None); dropped = True
|
||||
if "labels.name" in body:
|
||||
retry.pop("labels", None); dropped = True
|
||||
if dropped:
|
||||
r2 = await _request_with_retry(
|
||||
client, "PUT",
|
||||
f"{GHOST_ADMIN_API_URL}/members/{mid}/",
|
||||
{"members": [retry]},
|
||||
)
|
||||
if r2.status_code == 404:
|
||||
existing = await _ghost_find_member_by_email(retry.get("email", ""))
|
||||
if existing and existing.get("id"):
|
||||
r3 = await _request_with_retry(
|
||||
client, "PUT",
|
||||
f"{GHOST_ADMIN_API_URL}/members/{existing['id']}/",
|
||||
{"members": [retry]},
|
||||
)
|
||||
r3.raise_for_status()
|
||||
return (r3.json().get("members") or [None])[0] or {}
|
||||
r3 = await _request_with_retry(
|
||||
client, "POST",
|
||||
f"{GHOST_ADMIN_API_URL}/members/",
|
||||
{"members": [retry]},
|
||||
)
|
||||
r3.raise_for_status()
|
||||
return (r3.json().get("members") or [None])[0] or {}
|
||||
r2.raise_for_status()
|
||||
return (r2.json().get("members") or [None])[0] or {}
|
||||
r.raise_for_status()
|
||||
return (r.json().get("members") or [None])[0] or {}
|
||||
|
||||
async def _post_upsert(p: dict) -> dict:
|
||||
r = await _request_with_retry(
|
||||
client, "POST",
|
||||
f"{GHOST_ADMIN_API_URL}/members/?upsert=true",
|
||||
{"members": [p]},
|
||||
)
|
||||
if r.status_code == 422:
|
||||
lower = (r.text or "").lower()
|
||||
|
||||
retry = dict(p)
|
||||
changed = False
|
||||
if '"note"' in lower or "for note" in lower:
|
||||
retry.pop("note", None); changed = True
|
||||
if '"name"' in lower or "for name" in lower:
|
||||
retry.pop("name", None); changed = True
|
||||
if "labels.name" in lower:
|
||||
retry.pop("labels", None); changed = True
|
||||
|
||||
if changed:
|
||||
r2 = await _request_with_retry(
|
||||
client, "POST",
|
||||
f"{GHOST_ADMIN_API_URL}/members/?upsert=true",
|
||||
{"members": [retry]},
|
||||
)
|
||||
if r2.status_code != 422:
|
||||
r2.raise_for_status()
|
||||
return (r2.json().get("members") or [None])[0] or {}
|
||||
lower = (r2.text or "").lower()
|
||||
|
||||
if "already exists" in lower and "email address" in lower:
|
||||
existing = await _ghost_find_member_by_email(p.get("email", ""))
|
||||
if existing and existing.get("id"):
|
||||
return await _put(existing["id"], p)
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
"Validation error, cannot edit member.",
|
||||
request=r.request,
|
||||
response=r,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return (r.json().get("members") or [None])[0] or {}
|
||||
|
||||
if ghost_id:
|
||||
return await _put(ghost_id, pl)
|
||||
return await _post_upsert(pl)
|
||||
|
||||
|
||||
async def sync_member_to_ghost(sess: AsyncSession, user_id: int) -> Optional[str]:
|
||||
"""Push a single user's membership data to Ghost."""
|
||||
res = await sess.execute(select(User).where(User.id == user_id))
|
||||
user = res.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
payload = _ghost_member_payload_base(user)
|
||||
|
||||
labels = await _labels_for_user(sess, user.id)
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
|
||||
ghost_member = await _ghost_upsert_member(payload, ghost_id=user.ghost_id)
|
||||
|
||||
if ghost_member:
|
||||
gm_id = ghost_member.get("id")
|
||||
if gm_id and user.ghost_id != gm_id:
|
||||
user.ghost_id = gm_id
|
||||
user.ghost_raw = dict(ghost_member)
|
||||
flag_modified(user, "ghost_raw")
|
||||
await sess.flush()
|
||||
return user.ghost_id or gm_id
|
||||
return user.ghost_id
|
||||
|
||||
|
||||
async def sync_members_to_ghost(
|
||||
sess: AsyncSession,
|
||||
changed_since: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Upsert a batch of users to Ghost. Returns count processed."""
|
||||
stmt = select(User.id)
|
||||
if changed_since:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
User.created_at >= changed_since,
|
||||
and_(User.last_login_at != None, User.last_login_at >= changed_since),
|
||||
)
|
||||
)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
ids = [row[0] for row in (await sess.execute(stmt)).all()]
|
||||
processed = 0
|
||||
for uid in ids:
|
||||
try:
|
||||
await sync_member_to_ghost(sess, uid)
|
||||
processed += 1
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"[ghost sync] failed upsert for user {uid}: {e.response.status_code} {e.response.text}")
|
||||
except Exception as e:
|
||||
print(f"[ghost sync] failed upsert for user {uid}: {e}")
|
||||
return processed
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Membership fetch/sync (Ghost -> DB) bulk + single
|
||||
# =====================================================
|
||||
|
||||
async def fetch_all_members_from_ghost() -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(
|
||||
f"{GHOST_ADMIN_API_URL}/members/?include=labels,subscriptions,tiers,newsletters&limit=all",
|
||||
headers=_auth_header(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("members", [])
|
||||
|
||||
|
||||
async def sync_all_membership_from_ghost(sess: AsyncSession) -> None:
|
||||
"""Bulk sync: fetch all members from Ghost, upsert into DB."""
|
||||
members = await fetch_all_members_from_ghost()
|
||||
|
||||
label_bucket: Dict[str, dict[str, Any]] = {}
|
||||
tier_bucket: Dict[str, dict[str, Any]] = {}
|
||||
newsletter_bucket: Dict[str, dict[str, Any]] = {}
|
||||
|
||||
for m in members:
|
||||
for l in m.get("labels") or []:
|
||||
label_bucket[l["id"]] = l
|
||||
for n in m.get("newsletters") or []:
|
||||
newsletter_bucket[n["id"]] = n
|
||||
for s in m.get("subscriptions") or []:
|
||||
t = s.get("tier")
|
||||
if isinstance(t, dict) and t.get("id"):
|
||||
tier_bucket[t["id"]] = t
|
||||
|
||||
for L in label_bucket.values():
|
||||
await _upsert_label(sess, L)
|
||||
for T in tier_bucket.values():
|
||||
await _upsert_tier(sess, T)
|
||||
for N in newsletter_bucket.values():
|
||||
await _upsert_newsletter(sess, N)
|
||||
|
||||
for gm in members:
|
||||
user = await _find_or_create_user_by_ghost_or_email(sess, gm)
|
||||
await _apply_user_membership(sess, user, gm)
|
||||
|
||||
|
||||
async def fetch_single_member_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.get(
|
||||
f"{GHOST_ADMIN_API_URL}/members/{ghost_id}/?include=labels,newsletters,subscriptions,tiers",
|
||||
headers=_auth_header(),
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("members") or data.get("member") or []
|
||||
if isinstance(items, dict):
|
||||
return items
|
||||
return (items[0] if items else None)
|
||||
|
||||
|
||||
async def sync_single_member(sess: AsyncSession, ghost_id: str) -> None:
|
||||
"""Sync a single member from Ghost into DB."""
|
||||
m = await fetch_single_member_from_ghost(ghost_id)
|
||||
if m is None:
|
||||
return
|
||||
|
||||
for l in m.get("labels") or []:
|
||||
await _upsert_label(sess, l)
|
||||
for n in m.get("newsletters") or []:
|
||||
await _upsert_newsletter(sess, n)
|
||||
for s in m.get("subscriptions") or []:
|
||||
if isinstance(s.get("tier"), dict):
|
||||
await _upsert_tier(sess, s["tier"])
|
||||
|
||||
user = await _find_or_create_user_by_ghost_or_email(sess, m)
|
||||
await _apply_user_membership(sess, user, m)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,14 +47,10 @@ def register(url_prefix, title):
|
||||
|
||||
@blogs_bp.before_app_serving
|
||||
async def init():
|
||||
from .ghost.ghost_sync import (
|
||||
sync_all_content_from_ghost,
|
||||
sync_all_membership_from_ghost,
|
||||
)
|
||||
from .ghost.ghost_sync import sync_all_content_from_ghost
|
||||
|
||||
async with get_session() as s:
|
||||
await sync_all_content_from_ghost(s)
|
||||
await sync_all_membership_from_ghost(s)
|
||||
await s.commit()
|
||||
|
||||
@blogs_bp.before_request
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
from quart import Blueprint, request, abort, Response, g
|
||||
|
||||
from ..ghost.ghost_sync import (
|
||||
sync_single_member,
|
||||
sync_single_page,
|
||||
sync_single_post,
|
||||
sync_single_author,
|
||||
@@ -18,18 +17,12 @@ ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webh
|
||||
def _check_secret(req) -> None:
|
||||
expected = os.getenv("GHOST_WEBHOOK_SECRET")
|
||||
if not expected:
|
||||
# if you don't set a secret, we allow anything (dev mode)
|
||||
return
|
||||
got = req.args.get("secret") or req.headers.get("X-Webhook-Secret")
|
||||
if got != expected:
|
||||
abort(401)
|
||||
|
||||
def _extract_id(data: dict, key: str) -> str | None:
|
||||
"""
|
||||
key is "post", "tag", or "user"/"author".
|
||||
Ghost usually sends { key: { current: { id: ... }, previous: { id: ... } } }
|
||||
We'll try current first, then previous.
|
||||
"""
|
||||
block = data.get(key) or {}
|
||||
cur = block.get("current") or {}
|
||||
prev = block.get("previous") or {}
|
||||
@@ -38,7 +31,6 @@ def _extract_id(data: dict, key: str) -> str | None:
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.route("/member/", methods=["POST"])
|
||||
#@ghost_webhooks.post("/member/")
|
||||
async def webhook_member() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
@@ -47,9 +39,17 @@ async def webhook_member() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no member id")
|
||||
|
||||
# sync one post
|
||||
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
|
||||
await sync_single_member(g.s, ghost_id)
|
||||
# Delegate to account service (membership data lives in db_account)
|
||||
from shared.infrastructure.actions import call_action
|
||||
try:
|
||||
await call_action(
|
||||
"account", "ghost-sync-member",
|
||||
payload={"ghost_id": ghost_id},
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@@ -63,8 +63,6 @@ async def webhook_post() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no post id")
|
||||
|
||||
# sync one post
|
||||
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
@@ -80,8 +78,6 @@ async def webhook_page() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no page id")
|
||||
|
||||
# sync one post
|
||||
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
@@ -93,13 +89,10 @@ async def webhook_author() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
# Ghost calls them "user" in webhook payload in many versions,
|
||||
# and you want authors in your mirror. We'll try both keys.
|
||||
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
|
||||
if not ghost_id:
|
||||
abort(400, "no author id")
|
||||
|
||||
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"]
|
||||
await sync_single_author(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
@@ -115,6 +108,5 @@ async def webhook_tag() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no tag id")
|
||||
|
||||
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"]
|
||||
await sync_single_tag(g.s, ghost_id)
|
||||
return Response(status=204)
|
||||
|
||||
@@ -465,17 +465,18 @@ def register():
|
||||
@require_post_author
|
||||
async def edit(slug: str):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
from sqlalchemy import select as sa_select
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
ghost_id = g.post_data["post"]["ghost_id"]
|
||||
is_page = bool(g.post_data["post"].get("is_page"))
|
||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||
save_success = request.args.get("saved") == "1"
|
||||
|
||||
newsletters = (await g.s.execute(
|
||||
sa_select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)).scalars().all()
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
# Convert dicts to objects with .name/.ghost_id attributes for template compat
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_template(
|
||||
|
||||
46
shared/infrastructure/ghost_admin_token.py
Normal file
46
shared/infrastructure/ghost_admin_token.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import time
|
||||
import jwt # PyJWT
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def _split_key(raw_key: str) -> Tuple[str, bytes]:
|
||||
"""
|
||||
raw_key is the 'id:secret' from Ghost.
|
||||
Returns (id, secret_bytes)
|
||||
"""
|
||||
key_id, key_secret_hex = raw_key.split(':', 1)
|
||||
secret_bytes = bytes.fromhex(key_secret_hex)
|
||||
return key_id, secret_bytes
|
||||
|
||||
|
||||
def make_ghost_admin_jwt() -> str:
|
||||
"""
|
||||
Generate a short-lived JWT suitable for Authorization: Ghost <token>
|
||||
"""
|
||||
raw_key = os.environ["GHOST_ADMIN_API_KEY"]
|
||||
key_id, secret_bytes = _split_key(raw_key)
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"iat": now,
|
||||
"exp": now + 5 * 60, # now + 5 minutes
|
||||
"aud": "/admin/",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"alg": "HS256",
|
||||
"kid": key_id,
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
secret_bytes,
|
||||
algorithm="HS256",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# PyJWT returns str in recent versions; Ghost expects bare token string
|
||||
return token
|
||||
@@ -22,7 +22,7 @@ from quart import (
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.db.session import get_session
|
||||
from shared.db.session import get_session, get_account_session
|
||||
from shared.models.oauth_code import OAuthCode
|
||||
from shared.infrastructure.urls import account_url, app_url
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
@@ -100,7 +100,8 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
expected_redirect = app_url(app_name, "/auth/callback")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async with get_session() as s:
|
||||
# OAuthCode lives in db_account — use account session
|
||||
async with get_account_session() as s:
|
||||
async with s.begin():
|
||||
result = await s.execute(
|
||||
select(OAuthCode)
|
||||
|
||||
@@ -4,6 +4,9 @@ The originating service calls try_publish() directly, which creates the
|
||||
APActivity (with process_state='pending') in the same DB transaction.
|
||||
The EventProcessor picks it up and the delivery wildcard handler POSTs
|
||||
to follower inboxes.
|
||||
|
||||
When the federation database is separate from the caller's database,
|
||||
this module opens its own federation session for all AP reads/writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,6 +20,12 @@ from shared.services.registry import services
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _needs_federation_session() -> bool:
|
||||
"""True when the federation DB differs from the app's default DB."""
|
||||
from shared.db.session import DATABASE_URL, DATABASE_URL_FEDERATION
|
||||
return DATABASE_URL_FEDERATION != DATABASE_URL
|
||||
|
||||
|
||||
async def try_publish(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -38,6 +47,42 @@ async def try_publish(
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
if _needs_federation_session():
|
||||
from shared.db.session import get_federation_session
|
||||
async with get_federation_session() as fed_s:
|
||||
async with fed_s.begin():
|
||||
await _publish_inner(
|
||||
fed_s,
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
object_type=object_type,
|
||||
object_data=object_data,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
)
|
||||
else:
|
||||
await _publish_inner(
|
||||
session,
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
object_type=object_type,
|
||||
object_data=object_data,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
)
|
||||
|
||||
|
||||
async def _publish_inner(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
activity_type: str,
|
||||
object_type: str,
|
||||
object_data: dict,
|
||||
source_type: str,
|
||||
source_id: int,
|
||||
) -> None:
|
||||
"""Core publish logic using a session bound to the federation DB."""
|
||||
actor = await services.federation.get_actor_by_user_id(session, user_id)
|
||||
if not actor:
|
||||
return
|
||||
@@ -48,28 +93,24 @@ async def try_publish(
|
||||
)
|
||||
if existing:
|
||||
if activity_type == "Create" and existing.activity_type != "Delete":
|
||||
return # already published (allow re-Create after Delete/unpublish)
|
||||
return
|
||||
if activity_type == "Delete" and existing.activity_type == "Delete":
|
||||
return # already deleted
|
||||
return
|
||||
elif activity_type in ("Delete", "Update"):
|
||||
return # never published, nothing to delete/update
|
||||
return
|
||||
|
||||
# Stable object ID within a publish cycle. After Delete + re-Create
|
||||
# we append a version suffix so remote servers (Mastodon) treat it as
|
||||
# a brand-new post rather than ignoring the tombstoned ID.
|
||||
# Stable object ID within a publish cycle
|
||||
domain = os.getenv("AP_DOMAIN", "federation.rose-ash.com")
|
||||
base_object_id = (
|
||||
f"https://{domain}/users/{actor.preferred_username}"
|
||||
f"/objects/{source_type.lower()}/{source_id}"
|
||||
)
|
||||
if activity_type == "Create" and existing and existing.activity_type == "Delete":
|
||||
# Count prior Creates to derive a version number
|
||||
create_count = await services.federation.count_activities_for_source(
|
||||
session, source_type, source_id, activity_type="Create",
|
||||
)
|
||||
object_data["id"] = f"{base_object_id}/v{create_count + 1}"
|
||||
elif activity_type in ("Update", "Delete") and existing and existing.object_data:
|
||||
# Use the same object ID as the most recent activity
|
||||
object_data["id"] = existing.object_data.get("id", base_object_id)
|
||||
else:
|
||||
object_data["id"] = base_object_id
|
||||
|
||||
Reference in New Issue
Block a user