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