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 "?" sep = "&" if "?" in redirect_uri else "?"
return redirect( return redirect(
f"{redirect_uri}{sep}code={code}&state={state}" 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) ------------- # --- OAuth2 token exchange (for external clients like artdag) -------------

View File

@@ -31,51 +31,13 @@ def register() -> Blueprint:
result = await handler() result = await handler()
return jsonify(result or {"ok": True}) 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(): async def _update_page_config():
"""Create or update a PageConfig (used by events payment admin).""" """Create or update a PageConfig — proxies to cart service."""
from shared.models.page_config import PageConfig from shared.infrastructure.actions import call_action
from sqlalchemy import select
data = await request.get_json(force=True) data = await request.get_json(force=True)
container_type = data.get("container_type", "page") return await call_action("cart", "update-page-config", payload=data)
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),
}
_handlers["update-page-config"] = _update_page_config _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 sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag from models.ghost_content import Post, Author, Tag, PostTag
from shared.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag from models.tag_group import TagGroup, TagGroupTag
@@ -280,7 +279,6 @@ class DBClient:
joinedload(Post.primary_tag), joinedload(Post.primary_tag),
selectinload(Post.authors), selectinload(Post.authors),
selectinload(Post.tags), selectinload(Post.tags),
joinedload(Post.page_config),
) )
.limit(limit) .limit(limit)
.offset(offset_val) .offset(offset_val)
@@ -288,10 +286,24 @@ class DBClient:
rows: List[Post] = list((await self.sess.execute(q)).scalars()) 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]: def _page_to_public(p: Post) -> Dict[str, Any]:
d = _post_to_public(p) d = _post_to_public(p)
pc = p.page_config pc = pc_map.get(p.id)
d["features"] = pc.features if pc else {} d["features"] = pc["features"] if pc else {}
return d return d
pages_list = [_page_to_public(p) for p in rows] 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 quart import Blueprint, g, jsonify, request
from sqlalchemy import select from shared.infrastructure.data_client import DATA_HEADER, fetch_data
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services 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: def register() -> Blueprint:
@@ -90,55 +74,30 @@ def register() -> Blueprint:
_handlers["search-posts"] = _search_posts _handlers["search-posts"] = _search_posts
# --- page-config --- # --- page-config (proxy to cart, where page_configs table lives) ---
async def _page_config(): async def _page_config():
"""Return a single PageConfig by container_type + container_id.""" """Return a single PageConfig by container_type + container_id."""
ct = request.args.get("container_type", "page") return await fetch_data("cart", "page-config",
cid = request.args.get("container_id", type=int) params={"container_type": request.args.get("container_type", "page"),
if cid is None: "container_id": request.args.get("container_id", "")},
return None required=False)
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 _handlers["page-config"] = _page_config
# --- page-config-by-id --- # --- page-config-by-id (proxy to cart) ---
async def _page_config_by_id(): async def _page_config_by_id():
"""Return a single PageConfig by its primary key.""" return await fetch_data("cart", "page-config-by-id",
pc_id = request.args.get("id", type=int) params={"id": request.args.get("id", "")},
if pc_id is None: required=False)
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 _handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch --- # --- page-configs-batch (proxy to cart) ---
async def _page_configs_batch(): async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated).""" return await fetch_data("cart", "page-configs-batch",
ct = request.args.get("container_type", "page") params={"container_type": request.args.get("container_type", "page"),
ids_raw = request.args.get("ids", "") "ids": request.args.get("ids", "")},
if not ids_raw: required=False) or []
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 _handlers["page-configs-batch"] = _page_configs_batch

View File

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

View File

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

View File

@@ -47,4 +47,66 @@ def register() -> Blueprint:
_handlers["adopt-cart-for-user"] = _adopt_cart _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 return bp

View File

@@ -256,7 +256,7 @@ def register(url_prefix: str) -> Blueprint:
if order.page_config_id: if order.page_config_id:
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict 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}, params={"id": order.page_config_id},
required=False) required=False)
post = await fetch_data("blog", "post-by-id", 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) p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = 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", params={"container_type": "page",
"ids": ",".join(str(i) for i in post_ids)}, "ids": ",".join(str(i) for i in post_ids)},
required=False) or [] required=False) or []

View File

@@ -111,4 +111,77 @@ def register() -> Blueprint:
_handlers["cart-items"] = _cart_items _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 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: if not code or not state or state != expected_state:
current_app.logger.warning("OAuth callback: bad state or missing code") 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("/") return redirect("/")
expected_redirect = app_url(app_name, "/auth/callback") expected_redirect = app_url(app_name, "/auth/callback")
now = datetime.now(timezone.utc) 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 # OAuthCode lives in db_account — use account session
async with get_account_session() as s: async with get_account_session() as s:
async with s.begin(): async with s.begin():
result = await s.execute( result = await s.execute(
select(OAuthCode) select(OAuthCode)
.where(OAuthCode.code == code) .where(OAuthCode.code_hash == code_hash)
.with_for_update() .with_for_update()
) )
oauth_code = result.scalar_one_or_none() oauth_code = result.scalar_one_or_none()
if not oauth_code: if not oauth_code:
current_app.logger.warning("OAuth callback: code not found") 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("/") return redirect("/")
if oauth_code.used_at is not None: 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 oauth_code.used_at = now
user_id = oauth_code.user_id 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 # Set local session with grant token for revocation checking
qsession[SESSION_USER_KEY] = user_id qsession[SESSION_USER_KEY] = user_id