Decouple cross-domain DB queries for per-app database split

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:
giles
2026-02-25 11:32:14 +00:00
parent 50a9e5d952
commit 95bd32bd71
15 changed files with 1007 additions and 808 deletions

View 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;

View File

@@ -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]="

View File

@@ -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

View File

View 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

View File

64
account/bp/data/routes.py Normal file
View 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

View 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

View File

@@ -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

View File

@@ -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,10 +63,8 @@ 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)
@csrf_exempt
@@ -80,10 +78,8 @@ 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)
@csrf_exempt
@@ -93,15 +89,12 @@ 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)
@csrf_exempt
@@ -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)

View File

@@ -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(

View 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

View File

@@ -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)

View File

@@ -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