Fix cross-DB queries: move page_configs to cart, fix OAuth code_hash lookup

page_configs table lives in db_cart but blog was querying it directly,
causing UndefinedTableError. Move all PageConfig read/write endpoints to
cart service and have blog proxy via fetch_data/call_action.

Also fix OAuth callback to use code_hash lookup (codes are now stored
hashed) and pass grant_token in redirect URL to prevent auth loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:43:21 +00:00
parent 98aee1f656
commit dd52417241
11 changed files with 228 additions and 180 deletions

View File

@@ -123,7 +123,7 @@ def register(url_prefix="/auth"):
sep = "&" if "?" in redirect_uri else "?"
return redirect(
f"{redirect_uri}{sep}code={code}&state={state}"
f"&account_did={account_did}"
f"&account_did={account_did}&grant_token={grant_token}"
)
# --- OAuth2 token exchange (for external clients like artdag) -------------

View File

@@ -31,51 +31,13 @@ def register() -> Blueprint:
result = await handler()
return jsonify(result or {"ok": True})
# --- update-page-config ---
# --- update-page-config (proxy to cart, where page_configs table lives) ---
async def _update_page_config():
"""Create or update a PageConfig (used by events payment admin)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
"""Create or update a PageConfig — proxies to cart service."""
from shared.infrastructure.actions import call_action
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
return await call_action("cart", "update-page-config", payload=data)
_handlers["update-page-config"] = _update_page_config

View File

@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag
from shared.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag
@@ -280,7 +279,6 @@ class DBClient:
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
joinedload(Post.page_config),
)
.limit(limit)
.offset(offset_val)
@@ -288,10 +286,24 @@ class DBClient:
rows: List[Post] = list((await self.sess.execute(q)).scalars())
# Load PageConfigs from cart service (page_configs lives in db_cart)
post_ids = [p.id for p in rows]
pc_map: Dict[int, dict] = {}
if post_ids:
from shared.infrastructure.data_client import fetch_data
try:
raw_pcs = await fetch_data("cart", "page-configs-batch",
params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)})
if isinstance(raw_pcs, list):
for pc in raw_pcs:
pc_map[pc["container_id"]] = pc
except Exception:
pass # graceful degradation — pages render without features
def _page_to_public(p: Post) -> Dict[str, Any]:
d = _post_to_public(p)
pc = p.page_config
d["features"] = pc.features if pc else {}
pc = pc_map.get(p.id)
d["features"] = pc["features"] if pc else {}
return d
pages_list = [_page_to_public(p) for p in rows]

View File

@@ -7,25 +7,9 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from shared.infrastructure.data_client import DATA_HEADER
from shared.infrastructure.data_client import DATA_HEADER, fetch_data
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
from shared.models.page_config import PageConfig
def _page_config_dict(pc: PageConfig) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}
def register() -> Blueprint:
@@ -90,55 +74,30 @@ def register() -> Blueprint:
_handlers["search-posts"] = _search_posts
# --- page-config ---
# --- page-config (proxy to cart, where page_configs table lives) ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
return await fetch_data("cart", "page-config",
params={"container_type": request.args.get("container_type", "page"),
"container_id": request.args.get("container_id", "")},
required=False)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
# --- page-config-by-id (proxy to cart) ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
return await fetch_data("cart", "page-config-by-id",
params={"id": request.args.get("id", "")},
required=False)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
# --- page-configs-batch (proxy to cart) ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
return await fetch_data("cart", "page-configs-batch",
params={"container_type": request.args.get("container_type", "page"),
"ids": request.args.get("ids", "")},
required=False) or []
_handlers["page-configs-batch"] = _page_configs_batch

View File

@@ -22,24 +22,23 @@ def register():
@require_admin
async def admin(slug: str):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.models.page_config import PageConfig
from sqlalchemy import select as sa_select
from shared.infrastructure.data_client import fetch_data
# Load features for page admin
# Load features for page admin (page_configs lives in db_cart)
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post["id"])
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
raw_pc = await fetch_data("cart", "page-config",
params={"container_type": "page", "container_id": post["id"]},
required=False)
if raw_pc:
features = raw_pc.get("features") or {}
sumup_configured = bool(raw_pc.get("sumup_api_key"))
sumup_merchant_code = raw_pc.get("sumup_merchant_code") or ""
sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or ""
ctx = {
"features": features,
@@ -61,12 +60,9 @@ def register():
@bp.put("/features/")
@require_admin
async def update_features(slug: str):
"""Update PageConfig.features for a page."""
from shared.models.page_config import PageConfig
from models.ghost_content import Post
from sqlalchemy import select as sa_select
"""Update PageConfig.features for a page (page_configs lives in db_cart)."""
from shared.infrastructure.actions import call_action
from quart import jsonify
import json
post = g.post_data.get("post")
if not post or not post.get("is_page"):
@@ -74,21 +70,9 @@ def register():
post_id = post["id"]
# Load or create PageConfig
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
# Parse request body
body = await request.get_json()
if body is None:
# Fall back to form data
form = await request.form
body = {}
for key in ("calendar", "market"):
@@ -99,38 +83,30 @@ def register():
if not isinstance(body, dict):
return jsonify({"error": "Expected JSON object with feature flags."}), 400
# Merge features
features = dict(pc.features or {})
for key, val in body.items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
# Update via cart action (page_configs lives in db_cart)
result = await call_action("cart", "update-page-config", payload={
"container_type": "page",
"container_id": post_id,
"features": body,
})
pc.features = features
from sqlalchemy.orm.attributes import flag_modified
flag_modified(pc, "features")
await g.s.flush()
features = result.get("features", {})
# Return updated features panel
html = await render_template(
"_types/post/admin/_features_panel.html",
features=features,
post=post,
sumup_configured=bool(pc.sumup_api_key),
sumup_merchant_code=pc.sumup_merchant_code or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "",
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return await make_response(html)
@bp.put("/admin/sumup/")
@require_admin
async def update_sumup(slug: str):
"""Update PageConfig SumUp credentials for a page."""
from shared.models.page_config import PageConfig
from sqlalchemy import select as sa_select
"""Update PageConfig SumUp credentials for a page (page_configs lives in db_cart)."""
from shared.infrastructure.actions import call_action
from quart import jsonify
post = g.post_data.get("post")
@@ -139,37 +115,30 @@ def register():
post_id = post["id"]
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None
pc.sumup_checkout_prefix = checkout_prefix or None
# Only update API key if non-empty (allows updating other fields without re-entering key)
payload: dict = {
"container_type": "page",
"container_id": post_id,
"sumup_merchant_code": merchant_code or None,
"sumup_checkout_prefix": checkout_prefix or None,
}
if api_key:
pc.sumup_api_key = api_key
payload["sumup_api_key"] = api_key
await g.s.flush()
result = await call_action("cart", "update-page-config", payload=payload)
features = pc.features or {}
features = result.get("features", {})
html = await render_template(
"_types/post/admin/_features_panel.html",
features=features,
post=post,
sumup_configured=bool(pc.sumup_api_key),
sumup_merchant_code=pc.sumup_merchant_code or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "",
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return await make_response(html)

View File

@@ -3,12 +3,11 @@ from __future__ import annotations
import re
import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO
from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
from shared.services.registry import services
@@ -42,10 +41,10 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
pc = (await sess.execute(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None or not (pc.features or {}).get("market"):
raw_pc = await fetch_data("cart", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False)
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try:

View File

@@ -47,4 +47,66 @@ def register() -> Blueprint:
_handlers["adopt-cart-for-user"] = _adopt_cart
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (page_configs lives in db_cart)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
return bp

View File

@@ -256,7 +256,7 @@ def register(url_prefix: str) -> Blueprint:
if order.page_config_id:
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
raw_pc = await fetch_data("blog", "page-config-by-id",
raw_pc = await fetch_data("cart", "page-config-by-id",
params={"id": order.page_config_id},
required=False)
post = await fetch_data("blog", "post-by-id",

View File

@@ -178,7 +178,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = p
raw_pcs = await fetch_data("blog", "page-configs-batch",
raw_pcs = await fetch_data("cart", "page-configs-batch",
params={"container_type": "page",
"ids": ",".join(str(i) for i in post_ids)},
required=False) or []

View File

@@ -111,4 +111,77 @@ def register() -> Blueprint:
_handlers["cart-items"] = _cart_items
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
from shared.models.page_config import PageConfig
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
return bp
def _page_config_dict(pc) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}

View File

@@ -95,23 +95,33 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
if not code or not state or state != expected_state:
current_app.logger.warning("OAuth callback: bad state or missing code")
# Set _pnone_at to prevent immediate re-trigger of prompt=none
import time as _time
qsession["_pnone_at"] = _time.time()
return redirect("/")
expected_redirect = app_url(app_name, "/auth/callback")
now = datetime.now(timezone.utc)
# OAuthCode uses hashed storage — look up by code_hash
import hashlib
code_hash = hashlib.sha256(code.encode()).hexdigest()
# 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)
.where(OAuthCode.code == code)
.where(OAuthCode.code_hash == code_hash)
.with_for_update()
)
oauth_code = result.scalar_one_or_none()
if not oauth_code:
current_app.logger.warning("OAuth callback: code not found")
# Set _pnone_at to prevent redirect loop
import time as _time
qsession["_pnone_at"] = _time.time()
return redirect("/")
if oauth_code.used_at is not None:
@@ -132,7 +142,9 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
oauth_code.used_at = now
user_id = oauth_code.user_id
grant_token = oauth_code.grant_token
# grant_token is passed in the redirect URL (raw, for session storage)
grant_token = request.args.get("grant_token")
# Set local session with grant token for revocation checking
qsession[SESSION_USER_KEY] = user_id