Phase 0+1: native post writes, Ghost no longer write-primary
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s

- Final sync script with HTML verification + author→user migration
- Make ghost_id nullable on posts/authors/tags, add UUID/timestamp defaults
- Add user profile fields (bio, slug, profile_image, etc.) to User model
- New PostUser M2M table (replaces post_authors for new posts)
- PostWriter service: direct DB CRUD with Lexical rendering, optimistic
  locking, AP federation, tag upsert
- Rewrite create/edit/settings routes to use PostWriter (no Ghost API calls)
- Neuter Ghost webhooks (post/page/author/tag → 204 no-op)
- Disable Ghost startup sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:33:37 +00:00
parent e8bc228c7f
commit 0f9af31ffe
10 changed files with 1203 additions and 211 deletions

View File

@@ -1,15 +1,11 @@
# suma_browser/webhooks.py
# Ghost webhooks — neutered (Phase 1).
#
# Post/page/author/tag handlers return 204 no-op.
# Member webhook remains active (membership sync handled by account service).
from __future__ import annotations
import os
from quart import Blueprint, request, abort, Response, g
from quart import Blueprint, request, abort, Response
from ..ghost.ghost_sync import (
sync_single_page,
sync_single_post,
sync_single_author,
sync_single_tag,
)
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
@@ -32,6 +28,7 @@ def _extract_id(data: dict, key: str) -> str | None:
@csrf_exempt
@ghost_webhooks.route("/member/", methods=["POST"])
async def webhook_member() -> Response:
"""Member webhook still active — delegates to account service."""
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
@@ -39,7 +36,6 @@ async def webhook_member() -> Response:
if not ghost_id:
abort(400, "no member id")
# Delegate to account service (membership data lives in db_account)
from shared.infrastructure.actions import call_action
try:
await call_action(
@@ -52,61 +48,25 @@ async def webhook_member() -> Response:
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
return Response(status=204)
# --- Neutered handlers: Ghost no longer writes content ---
@csrf_exempt
@ghost_webhooks.post("/post/")
@clear_cache(tag='blog')
async def webhook_post() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "post")
if not ghost_id:
abort(400, "no post id")
await sync_single_post(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/page/")
@clear_cache(tag='blog')
async def webhook_page() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "page")
if not ghost_id:
abort(400, "no page id")
await sync_single_page(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/author/")
@clear_cache(tag='blog')
async def webhook_author() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
if not ghost_id:
abort(400, "no author id")
await sync_single_author(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/tag/")
@clear_cache(tag='blog')
async def webhook_tag() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "tag")
if not ghost_id:
abort(400, "no tag id")
await sync_single_tag(g.s, ghost_id)
return Response(status=204)