Monorepo: consolidate 7 repos into one

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitea
.env
_snapshot
docs
schema.sql
**/.gitmodules
**/.gitignore
**/README.md
**/__pycache__
**/.pytest_cache
**/node_modules
**/*.pyc

72
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,72 @@
name: Build and Deploy
on:
push:
branches: [main, decoupling]
env:
REGISTRY: registry.rose-ash.com:5000
COOP_DIR: /root/rose-ash
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build and deploy changed apps
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd ${{ env.COOP_DIR }}
git fetch origin ${{ github.ref_name }}
# Detect what changed since current HEAD
CHANGED=\$(git diff --name-only HEAD origin/${{ github.ref_name }})
git reset --hard origin/${{ github.ref_name }}
REBUILD_ALL=false
if echo \"\$CHANGED\" | grep -q '^shared/'; then
REBUILD_ALL=true
fi
if echo \"\$CHANGED\" | grep -q '^docker-compose.yml'; then
REBUILD_ALL=true
fi
for app in blog market cart events federation account; do
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\"; then
echo \"Building \$app...\"
docker build \
--build-arg CACHEBUST=\$(date +%s) \
-f \$app/Dockerfile \
-t ${{ env.REGISTRY }}/\$app:latest \
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
.
docker push ${{ env.REGISTRY }}/\$app:latest
docker push ${{ env.REGISTRY }}/\$app:${{ github.sha }}
else
echo \"Skipping \$app (no changes)\"
fi
done
source .env
docker stack deploy -c docker-compose.yml coop
echo 'Waiting for services to update...'
sleep 10
docker stack services coop
"

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.env
node_modules/
*.egg-info/
dist/
build/
.venv/
venv/
_snapshot/
_debug/

83
_config/app-config.yaml Normal file
View File

@@ -0,0 +1,83 @@
root: "/rose-ash-wholefood-coop" # no trailing slash needed (we normalize it)
host: "https://rose-ash.com"
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: ROSE-ASH 2.0
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "https://blog.rose-ash.com"
market: "https://market.rose-ash.com"
cart: "https://cart.rose-ash.com"
events: "https://events.rose-ash.com"
federation: "https://federation.rose-ash.com"
account: "https://account.rose-ash.com"
cache:
fs_root: /app/_snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/ciders
- branded-goods/wines
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
product-details:
- General Information
- A Note About Prices
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "jfwlekjfwef798ewf769ew8f679ew8f7weflwef"

50
account/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
# ---------- Python application ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
# Install system deps + psql client
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY account/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
# ---------- Runtime setup ----------
COPY account/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

0
account/__init__.py Normal file
View File

65
account/app.py Normal file
View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from shared.services.registry import services
from bp import register_account_bp, register_auth_bp, register_fragments
async def account_context() -> dict:
"""Account app context processor."""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "account", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps)
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
return ctx
def create_app() -> "Quart":
from services import register_domain_services
app = create_base_app(
"account",
context_fn=account_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
# --- blueprints ---
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_account_bp())
app.register_blueprint(register_fragments())
return app
app = create_app()

3
account/bp/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp
from .fragments import register_fragments

View File

View File

@@ -0,0 +1,168 @@
"""Account pages blueprint.
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
Mounted at root /.
"""
from __future__ import annotations
from quart import (
Blueprint,
request,
render_template,
make_response,
redirect,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
oob = {
"oob_extends": "oob_elements.html",
"extends": "_types/root/_index.html",
"parent_id": "root-header-child",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"parent_header": "_types/root/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
def register(url_prefix="/"):
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
@account_bp.context_processor
async def context():
events_nav, cart_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
])
return {"oob": oob, "account_nav_html": events_nav + cart_nav}
@account_bp.get("/")
async def account():
from shared.browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(login_url("/"))
if not is_htmx_request():
html = await render_template("_types/auth/index.html")
else:
html = await render_template("_types/auth/_oob_elements.html")
return await make_response(html)
@account_bp.get("/newsletters/")
async def newsletters():
from shared.browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(login_url("/newsletters/"))
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
if not is_htmx_request():
html = await render_template(
"_types/auth/index.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
else:
html = await render_template(
"_types/auth/_oob_elements.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
return await make_response(html)
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):
return "", 401
result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
UserNewsletter.newsletter_id == newsletter_id,
)
)
un = result.scalar_one_or_none()
if un:
un.subscribed = not un.subscribed
else:
un = UserNewsletter(
user_id=g.user.id,
newsletter_id=newsletter_id,
subscribed=True,
)
g.s.add(un)
await g.s.flush()
return await render_template(
"_types/auth/_newsletter_toggle.html",
un=un,
)
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
async def fragment_page(slug):
from shared.browser.app.utils.htmx import is_htmx_request
from quart import abort
if not g.get("user"):
return redirect(login_url(f"/{slug}/"))
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"}
if not is_htmx_request():
html = await render_template(
"_types/auth/index.html",
oob=w_oob,
page_fragment_html=fragment_html,
)
else:
html = await render_template(
"_types/auth/_oob_elements.html",
oob=w_oob,
page_fragment_html=fragment_html,
)
return await make_response(html)
return account_bp

View File

486
account/bp/auth/routes.py Normal file
View File

@@ -0,0 +1,486 @@
"""Authentication routes for the account app.
Account is the OAuth authorization server. Owns magic link login/logout,
OAuth2 authorize endpoint, grant verification, and SSO logout.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone, timedelta
from quart import (
Blueprint,
request,
render_template,
redirect,
url_for,
session as qsession,
g,
current_app,
jsonify,
)
from sqlalchemy import select, update
from sqlalchemy.exc import SQLAlchemyError
from shared.db.session import get_session
from shared.models import User
from shared.models.oauth_code import OAuthCode
from shared.models.oauth_grant import OAuthGrant
from shared.infrastructure.urls import account_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity
from shared.events import emit_activity
from .services import (
pop_login_redirect_target,
store_login_redirect_target,
send_magic_email,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid"
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "artdag"}
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
# --- OAuth2 authorize endpoint -------------------------------------------
@auth_bp.get("/oauth/authorize")
@auth_bp.get("/oauth/authorize/")
async def oauth_authorize():
client_id = request.args.get("client_id", "")
redirect_uri = request.args.get("redirect_uri", "")
state = request.args.get("state", "")
device_id = request.args.get("device_id", "")
prompt = request.args.get("prompt", "")
if client_id not in ALLOWED_CLIENTS:
return "Invalid client_id", 400
expected_redirect = app_url(client_id, "/auth/callback")
if redirect_uri != expected_redirect:
return "Invalid redirect_uri", 400
# Account's own device id — always available via factory hook
account_did = g.device_id
# Not logged in
if not g.get("user"):
if prompt == "none":
# Silent check — pass account_did so client can watch for future logins
sep = "&" if "?" in redirect_uri else "?"
return redirect(
f"{redirect_uri}{sep}error=login_required"
f"&state={state}&account_did={account_did}"
)
authorize_path = request.full_path
store_login_redirect_target()
return redirect(url_for("auth.login_form", next=authorize_path))
# Logged in — create grant + authorization code
account_sid = qsession.get(ACCOUNT_SESSION_KEY)
if not account_sid:
account_sid = secrets.token_urlsafe(32)
qsession[ACCOUNT_SESSION_KEY] = account_sid
grant_token = secrets.token_urlsafe(48)
code = secrets.token_urlsafe(48)
now = datetime.now(timezone.utc)
expires = now + timedelta(minutes=5)
async with get_session() as s:
async with s.begin():
grant = OAuthGrant(
token=grant_token,
user_id=g.user.id,
client_id=client_id,
issuer_session=account_sid,
device_id=device_id or None,
)
s.add(grant)
oauth_code = OAuthCode(
code=code,
user_id=g.user.id,
client_id=client_id,
redirect_uri=redirect_uri,
expires_at=expires,
grant_token=grant_token,
)
s.add(oauth_code)
sep = "&" if "?" in redirect_uri else "?"
return redirect(
f"{redirect_uri}{sep}code={code}&state={state}"
f"&account_did={account_did}"
)
# --- OAuth2 token exchange (for external clients like artdag) -------------
from shared.browser.app.csrf import csrf_exempt
@csrf_exempt
@auth_bp.post("/oauth/token")
@auth_bp.post("/oauth/token/")
async def oauth_token():
"""Exchange an authorization code for user info + grant token.
Used by clients that don't share the coop database (e.g. artdag).
Accepts JSON: {code, client_id, redirect_uri}
Returns JSON: {user_id, username, display_name, grant_token}
"""
data = await request.get_json()
if not data:
return jsonify({"error": "invalid_request"}), 400
code = data.get("code", "")
client_id = data.get("client_id", "")
redirect_uri = data.get("redirect_uri", "")
if client_id not in ALLOWED_CLIENTS:
return jsonify({"error": "invalid_client"}), 400
now = datetime.now(timezone.utc)
async with get_session() as s:
async with s.begin():
result = await s.execute(
select(OAuthCode)
.where(OAuthCode.code == code)
.with_for_update()
)
oauth_code = result.scalar_one_or_none()
if not oauth_code:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.used_at is not None:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.expires_at < now:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.client_id != client_id:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.redirect_uri != redirect_uri:
return jsonify({"error": "invalid_grant"}), 400
oauth_code.used_at = now
user_id = oauth_code.user_id
grant_token = oauth_code.grant_token
user = await s.get(User, user_id)
if not user:
return jsonify({"error": "invalid_grant"}), 400
return jsonify({
"user_id": user_id,
"username": user.email or "",
"display_name": user.name or "",
"grant_token": grant_token,
})
# --- Grant verification (internal endpoint) ------------------------------
@auth_bp.get("/internal/verify-grant")
async def verify_grant():
"""Called by client apps to check if a grant is still valid."""
token = request.args.get("token", "")
if not token:
return jsonify({"valid": False}), 200
async with get_session() as s:
grant = await s.scalar(
select(OAuthGrant).where(OAuthGrant.token == token)
)
if not grant or grant.revoked_at is not None:
return jsonify({"valid": False}), 200
return jsonify({"valid": True}), 200
@auth_bp.get("/internal/check-device")
async def check_device():
"""Called by client apps to check if a device has an active auth.
Looks up the most recent grant for (device_id, client_id).
If the grant is active → {active: true}.
If revoked but user has logged in since → {active: true} (re-auth needed).
Otherwise → {active: false}.
"""
device_id = request.args.get("device_id", "")
app_name = request.args.get("app", "")
if not device_id or not app_name:
return jsonify({"active": False}), 200
async with get_session() as s:
# Find the most recent grant for this device + app
result = await s.execute(
select(OAuthGrant)
.where(OAuthGrant.device_id == device_id)
.where(OAuthGrant.client_id == app_name)
.order_by(OAuthGrant.created_at.desc())
.limit(1)
)
grant = result.scalar_one_or_none()
if not grant:
return jsonify({"active": False}), 200
# Grant still active
if grant.revoked_at is None:
return jsonify({"active": True}), 200
# Grant revoked — check if user logged in since
user = await s.get(User, grant.user_id)
if user and user.last_login_at and user.last_login_at > grant.revoked_at:
return jsonify({"active": True}), 200
return jsonify({"active": False}), 200
# --- Magic link login flow -----------------------------------------------
@auth_bp.get("/login/")
async def login_form():
store_login_redirect_target()
cross_cart_sid = request.args.get("cart_sid")
if cross_cart_sid:
qsession["cart_sid"] = cross_cart_sid
if g.get("user"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
return await render_template("auth/login.html")
@auth_bp.post("/start/")
async def start_login():
form = await request.form
email_input = form.get("email") or ""
is_valid, email = validate_email(email_input)
if not is_valid:
return (
await render_template(
"auth/login.html",
error="Please enter a valid email address.",
email=email_input,
),
400,
)
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
from shared.utils import host_url
magic_url = host_url(url_for("auth.magic", token=token))
email_error = None
try:
await send_magic_email(email, magic_url)
except Exception as e:
current_app.logger.error("EMAIL SEND FAILED: %r", e)
email_error = (
"We couldn't send the email automatically. "
"Please try again in a moment."
)
return await render_template(
"auth/check_email.html",
email=email,
email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
now = datetime.now(timezone.utc)
user_id: int | None = None
try:
async with get_session() as s:
async with s.begin():
user, error = await validate_magic_link(s, token)
if error:
return (
await render_template("auth/login.html", error=error),
400,
)
user_id = user.id
except Exception:
return (
await render_template(
"auth/login.html",
error="Could not sign you in right now. Please try again.",
),
502,
)
assert user_id is not None
ident = current_cart_identity()
anon_session_id = ident.get("session_id")
try:
async with get_session() as s:
async with s.begin():
u2 = await s.get(User, user_id)
if u2:
u2.last_login_at = now
if anon_session_id:
await emit_activity(
s,
activity_type="rose:Login",
actor_uri="internal:system",
object_type="Person",
object_data={
"user_id": user_id,
"session_id": anon_session_id,
},
)
# Notify external services of device login
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "login",
},
)
except SQLAlchemyError:
current_app.logger.exception(
"[auth] non-fatal DB update for user_id=%s", user_id
)
qsession[SESSION_USER_KEY] = user_id
# Fresh account session ID for grant tracking
qsession[ACCOUNT_SESSION_KEY] = secrets.token_urlsafe(32)
# Signal login for this device so client apps can detect it
try:
from shared.browser.app.redis_cacher import get_redis
import time as _time
_redis = get_redis()
if _redis:
await _redis.set(
f"did_auth:{g.device_id}",
str(_time.time()).encode(),
ex=30 * 24 * 3600,
)
except Exception:
current_app.logger.exception("[auth] failed to set did_auth in Redis")
redirect_url = pop_login_redirect_target()
return redirect(redirect_url, 303)
@auth_bp.post("/logout/")
async def logout():
# Revoke all grants issued by this account session
account_sid = qsession.get(ACCOUNT_SESSION_KEY)
if account_sid:
try:
async with get_session() as s:
async with s.begin():
await s.execute(
update(OAuthGrant)
.where(OAuthGrant.issuer_session == account_sid)
.where(OAuthGrant.revoked_at.is_(None))
.values(revoked_at=datetime.now(timezone.utc))
)
except SQLAlchemyError:
current_app.logger.exception("[auth] failed to revoke grants")
# Clear login signal for this device
try:
from shared.browser.app.redis_cacher import get_redis
_redis = get_redis()
if _redis:
await _redis.delete(f"did_auth:{g.device_id}")
except Exception:
pass
# Notify external services of device logout
try:
async with get_session() as s:
async with s.begin():
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "logout",
},
)
except Exception:
current_app.logger.exception("[auth] failed to emit DeviceAuth logout")
qsession.pop(SESSION_USER_KEY, None)
qsession.pop(ACCOUNT_SESSION_KEY, None)
from shared.infrastructure.urls import blog_url
return redirect(blog_url("/"))
@auth_bp.get("/sso-logout/")
async def sso_logout():
"""SSO logout called by client apps: revoke grants, clear session."""
account_sid = qsession.get(ACCOUNT_SESSION_KEY)
if account_sid:
try:
async with get_session() as s:
async with s.begin():
await s.execute(
update(OAuthGrant)
.where(OAuthGrant.issuer_session == account_sid)
.where(OAuthGrant.revoked_at.is_(None))
.values(revoked_at=datetime.now(timezone.utc))
)
except SQLAlchemyError:
current_app.logger.exception("[auth] failed to revoke grants")
# Clear login signal for this device
try:
from shared.browser.app.redis_cacher import get_redis
_redis = get_redis()
if _redis:
await _redis.delete(f"did_auth:{g.device_id}")
except Exception:
pass
# Notify external services of device logout
try:
async with get_session() as s:
async with s.begin():
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "logout",
},
)
except Exception:
current_app.logger.exception("[auth] failed to emit DeviceAuth logout")
qsession.pop(SESSION_USER_KEY, None)
qsession.pop(ACCOUNT_SESSION_KEY, None)
from shared.infrastructure.urls import blog_url
return redirect(blog_url("/"))
@auth_bp.get("/clear/")
async def clear():
"""One-time migration helper: clear all session cookies."""
qsession.clear()
resp = redirect(account_url("/"))
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
return resp
return auth_bp

View File

@@ -0,0 +1,24 @@
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
from .auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
__all__ = [
"pop_login_redirect_target",
"store_login_redirect_target",
"get_app_host",
"get_app_root",
"send_magic_email",
"load_user_by_id",
"find_or_create_user",
"create_magic_link",
"validate_magic_link",
"validate_email",
]

View File

@@ -0,0 +1,156 @@
"""Auth operations for the account app.
Owns magic-link login. Shared models, shared config.
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from quart import current_app, render_template, request, g
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models import User, MagicLink
from shared.config import config
def get_app_host() -> str:
host = (
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
).rstrip("/")
return host
def get_app_root() -> str:
root = (g.root).rstrip("/")
return root
async def send_magic_email(to_email: str, link_url: str) -> None:
host = os.getenv("SMTP_HOST")
port = int(os.getenv("SMTP_PORT") or "587")
username = os.getenv("SMTP_USER")
password = os.getenv("SMTP_PASS")
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
site_name = config().get("title", "Rose Ash")
subject = f"Your sign-in link \u2014 {site_name}"
tpl_vars = dict(site_name=site_name, link_url=link_url)
text_body = await render_template("_email/magic_link.txt", **tpl_vars)
html_body = await render_template("_email/magic_link.html", **tpl_vars)
if not host or not username or not password:
current_app.logger.warning(
"SMTP not configured. Printing magic link to console for %s: %s",
to_email,
link_url,
)
print(f"[DEV] Magic link for {to_email}: {link_url}")
return
import aiosmtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = mail_from
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
is_secure = port == 465
if is_secure:
smtp = aiosmtplib.SMTP(
hostname=host, port=port, use_tls=True,
username=username, password=password,
)
else:
smtp = aiosmtplib.SMTP(
hostname=host, port=port, start_tls=True,
username=username, password=password,
)
async with smtp:
await smtp.send_message(msg)
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
stmt = (
select(User)
.options(selectinload(User.labels))
.where(User.id == user_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_or_create_user(session: AsyncSession, email: str) -> User:
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
user = User(email=email)
session.add(user)
await session.flush()
return user
async def create_magic_link(
session: AsyncSession,
user_id: int,
purpose: str = "signin",
expires_minutes: int = 15,
) -> Tuple[str, datetime]:
token = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
ml = MagicLink(
token=token,
user_id=user_id,
purpose=purpose,
expires_at=expires,
ip=request.headers.get("x-forwarded-for", request.remote_addr),
user_agent=request.headers.get("user-agent"),
)
session.add(ml)
return token, expires
async def validate_magic_link(
session: AsyncSession,
token: str,
) -> Tuple[Optional[User], Optional[str]]:
now = datetime.now(timezone.utc)
ml = await session.scalar(
select(MagicLink)
.where(MagicLink.token == token)
.with_for_update()
)
if not ml or ml.purpose != "signin":
return None, "Invalid or expired link."
if ml.used_at or ml.expires_at < now:
return None, "This link has expired. Please request a new one."
user = await session.get(User, ml.user_id)
if not user:
return None, "User not found."
ml.used_at = now
return user, None
def validate_email(email: str) -> Tuple[bool, str]:
email = email.strip().lower()
if not email or "@" not in email:
return False, email
return True, email

View File

@@ -0,0 +1,45 @@
from urllib.parse import urlparse
from quart import session
from shared.infrastructure.urls import account_url
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
def store_login_redirect_target() -> None:
from quart import request
target = request.args.get("next")
if not target:
ref = request.referrer or ""
try:
parsed = urlparse(ref)
target = parsed.path or ""
except Exception:
target = ""
if not target:
return
# Accept both relative paths and absolute URLs (cross-app redirects)
if target.startswith("http://") or target.startswith("https://"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
elif target.startswith("/") and not target.startswith("//"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
def pop_login_redirect_target() -> str:
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
if not path or not isinstance(path, str):
return account_url("/")
# Absolute URL: return as-is (cross-app redirect)
if path.startswith("http://") or path.startswith("https://"):
return path
# Relative path: must start with / and not //
if path.startswith("/") and not path.startswith("//"):
return account_url(path)
return account_url("/")

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View File

@@ -0,0 +1,52 @@
"""Account app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
auth-menu Desktop + mobile auth menu (sign-in or user link)
"""
from __future__ import annotations
from quart import Blueprint, Response, request, render_template
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# ---------------------------------------------------------------
async def _auth_menu():
user_email = request.args.get("email", "")
return await render_template(
"fragments/auth_menu.html",
user_email=user_email,
)
_handlers = {
"auth-menu": _auth_menu,
}
# ---------------------------------------------------------------
# Routing
# ---------------------------------------------------------------
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return bp

26
account/entrypoint.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushall()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}

View File

9
account/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

View File

@@ -0,0 +1,27 @@
"""Account app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the account app.
Account needs all domain services since widgets (tickets, bookings)
pull data from blog, calendar, market, cart, and federation.
"""
from shared.services.registry import services
from shared.services.federation_impl import SqlFederationService
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
if not services.has("federation"):
services.federation = SqlFederationService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Hello,
Click this link to sign in:
{{ link_url }}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1 @@
{{ page_fragment_html | safe }}

View File

@@ -0,0 +1,49 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="/auth/logout/" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
>
<i class="fa-solid fa-right-from-bracket text-xs"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% if account_nav_html %}
{{ account_nav_html | safe }}
{% endif %}

View File

@@ -0,0 +1,17 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -0,0 +1,46 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"
>
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,33 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ blog_url('/auth/login/') }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(account_url('/'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ blog_url('/auth/start/') }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Check your email — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto text-center">
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
<p class="text-stone-600 mb-2">
We sent a sign-in link to <strong>{{ email }}</strong>.
</p>
<p class="text-stone-500 text-sm">
Click the link in the email to sign in. The link expires in 15 minutes.
</p>
{% if email_error %}
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
{{ email_error }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Login — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
{% if error %}
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
<input
type="email"
name="email"
id="email"
value="{{ email | default('') }}"
required
autofocus
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
>
</div>
<button
type="submit"
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
>
Send magic link
</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{# Desktop auth menu #}
<span id="auth-menu-desktop" class="hidden md:inline-flex">
{% if user_email %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>
{# Mobile auth menu #}
<span id="auth-menu-mobile" class="block md:hidden text-md font-bold">
{% if user_email %}
<a href="{{ account_url('/') }}" data-close-details>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a href="{{ account_url('/') }}">
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>

9
blog/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
node_modules/
*.egg-info/
dist/
build/
.venv/

61
blog/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1
# ---------- Stage 1: Build editor JS/CSS ----------
FROM node:20-slim AS editor-build
WORKDIR /build
COPY shared/editor/package.json shared/editor/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY shared/editor/ ./
RUN NODE_ENV=production node build.mjs
# ---------- Stage 2: Python runtime ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
# Install system deps + psql client
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY blog/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
# Copy built editor assets from stage 1
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
# ---------- Runtime setup ----------
COPY blog/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

60
blog/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Blog App (Coop)
Blog, authentication, and content management service for the Rose Ash cooperative platform. Handles Ghost CMS integration, user auth, and admin settings.
## Architecture
One of five Quart microservices sharing a single PostgreSQL database:
| App | Port | Domain |
|-----|------|--------|
| **blog (coop)** | 8000 | Auth, blog, admin, menus, snippets |
| market | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Structure
```
app.py # Application factory (create_base_app + blueprints)
path_setup.py # Adds project root + app dir to sys.path
config/app-config.yaml # App URLs, feature flags, SumUp config
models/ # Blog-domain models (+ re-export stubs for shared models)
bp/ # Blueprints
auth/ # Magic link login, account, newsletters
blog/ # Post listing, Ghost CMS sync
post/ # Single post view and admin
admin/ # Settings admin interface
menu_items/ # Navigation menu management
snippets/ # Reusable content snippets
templates/ # Jinja2 templates
services/ # register_domain_services() — wires blog + calendar + market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## Cross-Domain Communication
All inter-app communication uses typed service contracts (no HTTP APIs):
- `services.calendar.*` — calendar/entry queries via CalendarService protocol
- `services.market.*` — marketplace queries via MarketService protocol
- `services.cart.*` — cart summary via CartService protocol
- `services.federation.*` — AP publishing via FederationService protocol
- `shared.services.navigation` — site navigation tree
## Domain Events
- `auth/routes.py` emits `user.logged_in` via `shared.events.emit_event`
- Ghost sync emits `post.published` / `post.updated` for federation
## Running
```bash
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
alembic -c shared/alembic.ini upgrade head
hypercorn app:app --bind 0.0.0.0:8000
```

0
blog/__init__.py Normal file
View File

138
blog/app.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from shared.models import KV
from bp import (
register_blog_bp,
register_admin,
register_menu_items,
register_snippets,
register_fragments,
)
async def blog_context() -> dict:
"""
Blog app context processor.
- cart_count/cart_total: via cart service (shared DB)
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data via service (replaces cross-app HTTP API)
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
# Pre-fetch cross-app HTML fragments concurrently
# (fetch_fragment auto-skips when inside a fragment request to prevent circular deps)
user = getattr(g, "user", None)
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
auth_params = {"email": user.email} if user else {}
nav_params = {"app_name": "blog", "path": request.path}
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
return ctx
def create_app() -> "Quart":
from services import register_domain_services
app = create_base_app(
"blog",
context_fn=blog_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
# --- blueprints ---
app.register_blueprint(
register_blog_bp(
url_prefix=config()["blog_root"],
title=config()["blog_title"],
),
url_prefix=config()["blog_root"],
)
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
# --- KV admin endpoints ---
@app.get("/settings/kv/<key>")
async def kv_get(key: str):
row = (
await g.s.execute(select(KV).where(KV.key == key))
).scalar_one_or_none()
return {"key": key, "value": (row.value if row else None)}
@app.post("/settings/kv/<key>")
async def kv_set(key: str):
data = await request.get_json() or {}
val = data.get("value", "")
obj = await g.s.get(KV, key)
if obj is None:
obj = KV(key=key, value=val)
g.s.add(obj)
else:
obj.value = val
return {"ok": True, "key": key, "value": val}
# --- debug: url rules ---
@app.get("/__rules")
async def dump_rules():
rules = []
for r in app.url_map.iter_rules():
rules.append({
"endpoint": r.endpoint,
"rule": repr(r.rule),
"methods": sorted(r.methods - {"HEAD", "OPTIONS"}),
"strict_slashes": r.strict_slashes,
})
return {"rules": rules}
return app
app = create_app()

5
blog/bp/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments

67
blog/bp/admin/routes.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
from datetime import datetime
def register(url_prefix):
bp = Blueprint("settings", __name__, url_prefix = url_prefix)
@bp.context_processor
async def inject_root():
return {
"base_title": f"{config()['title']} settings",
}
@bp.get("/")
@require_admin
async def home():
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/root/settings/index.html",
)
else:
html = await render_template("_types/root/settings/_oob_elements.html")
return await make_response(html)
@bp.get("/cache/")
@require_admin
async def cache():
if not is_htmx_request():
html = await render_template("_types/root/settings/cache/index.html")
else:
html = await render_template("_types/root/settings/cache/_oob_elements.html")
return await make_response(html)
@bp.post("/cache_clear/")
@require_admin
async def cache_clear():
await clear_all_cache()
if is_htmx_request():
now = datetime.now()
html = f'<span class="text-green-600 font-bold">Cache cleared at {now.strftime("%H:%M:%S")}</span>'
return html
return redirect(url_for("settings.cache"))
return bp

7
blog/bp/blog/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# create the blueprint at package import time
from .routes import register # = Blueprint("browse_bp", __name__)
# import routes AFTER browse_bp is defined so routes can attach to it
from . import routes # noqa: F401

View File

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
g,
)
from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
def _slugify(name: str) -> str:
s = name.strip().lower()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_]+", "-", s)
return s.strip("-")
async def _unassigned_tags(session):
"""Return public, non-deleted tags not assigned to any group."""
assigned_sq = select(TagGroupTag.tag_id).subquery()
q = (
select(Tag)
.where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
Tag.id.notin_(select(assigned_sq)),
)
.order_by(Tag.name)
)
return list((await session.execute(q)).scalars())
def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.get("/")
@require_admin
async def index():
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
ctx = {"groups": groups, "unassigned_tags": unassigned}
if not is_htmx_request():
return await render_template("_types/blog/admin/tag_groups/index.html", **ctx)
else:
return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx)
@bp.post("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.index"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
colour = (form.get("colour") or "").strip() or None
sort_order = int(form.get("sort_order") or 0)
tg = TagGroup(
name=name, slug=slug,
feature_image=feature_image, colour=colour,
sort_order=sort_order,
)
g.s.add(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.index"))
@bp.get("/<int:id>/")
@require_admin
async def edit(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.index"))
# Assigned tag IDs for this group
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_tag_ids = set(assigned_rows)
# All public, non-deleted tags
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
ctx = {
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": assigned_tag_ids,
}
if not is_htmx_request():
return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx)
else:
return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx)
@bp.post("/<int:id>/")
@require_admin
async def save(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.index"))
form = await request.form
name = (form.get("name") or "").strip()
if name:
tg.name = name
tg.slug = _slugify(name)
tg.feature_image = (form.get("feature_image") or "").strip() or None
tg.colour = (form.get("colour") or "").strip() or None
tg.sort_order = int(form.get("sort_order") or 0)
# Update tag assignments
selected_tag_ids = set()
for val in form.getlist("tag_ids"):
try:
selected_tag_ids.add(int(val))
except (ValueError, TypeError):
pass
# Remove old assignments
await g.s.execute(
delete(TagGroupTag).where(TagGroupTag.tag_group_id == id)
)
await g.s.flush()
# Add new assignments
for tid in selected_tag_ids:
g.s.add(TagGroupTag(tag_group_id=id, tag_id=tid))
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
async def delete_group(id: int):
tg = await g.s.get(TagGroup, id)
if tg:
await g.s.delete(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.index"))
return bp

120
blog/bp/blog/filters/qs.py Normal file
View File

@@ -0,0 +1,120 @@
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from shared.browser.app.filters.query_types import BlogQuery
def decode() -> BlogQuery:
page = int(request.args.get("page", 1))
search = request.args.get("search")
sort = request.args.get("sort")
liked = request.args.get("liked")
drafts = request.args.get("drafts")
selected_tags = tuple(s.strip() for s in request.args.getlist("tag") if s.strip())[:1]
selected_authors = tuple(s.strip().lower() for s in request.args.getlist("author") if s.strip())[:1]
selected_groups = tuple(s.strip() for s in request.args.getlist("group") if s.strip())[:1]
view = request.args.get("view") or None
return BlogQuery(page, search, sort, selected_tags, selected_authors, liked, view, drafts, selected_groups)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Auto-resets page to 1 when filters change unless you pass page explicitly.
"""
q = decode()
base_tags = [s for s in q.selected_tags if (s or "").strip()]
base_authors = [s for s in q.selected_authors if (s or "").strip()]
base_groups = [s for s in q.selected_groups if (s or "").strip()]
base_search = q.search or None
base_liked = q.liked or None
base_sort = q.sort or None
base_page = int(q.page or 1)
base_view = q.view or None
base_drafts = q.drafts or None
def makeqs(
*,
clear_filters: bool = False,
add_tag: Union[str, Iterable[str], None] = None,
remove_tag: Union[str, Iterable[str], None] = None,
add_author: Union[str, Iterable[str], None] = None,
remove_author: Union[str, Iterable[str], None] = None,
add_group: Union[str, Iterable[str], None] = None,
remove_group: Union[str, Iterable[str], None] = None,
search: Union[str, None, object] = KEEP,
sort: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
liked: Union[bool, None, object] = KEEP,
view: Union[str, None, object] = KEEP,
drafts: Union[str, None, object] = KEEP,
) -> str:
groups = make_filter_set(base_groups, add_group, remove_group, clear_filters, single_select=True)
tags = make_filter_set(base_tags, add_tag, remove_tag, clear_filters, single_select=True)
authors = make_filter_set(base_authors, add_author, remove_author, clear_filters, single_select=True)
# Mutual exclusion: selecting a group clears tags, selecting a tag clears groups
if add_group is not None:
tags = []
if add_tag is not None:
groups = []
final_search = None if clear_filters else base_search if search is KEEP else ((search or "").strip() or None)
final_sort = base_sort if sort is KEEP else (sort or None)
final_liked = None if clear_filters else base_liked if liked is KEEP else liked
final_view = base_view if view is KEEP else (view or None)
final_drafts = None if clear_filters else base_drafts if drafts is KEEP else (drafts or None)
# Did filters change?
filters_changed = (
set(map(_norm, tags)) != set(map(_norm, base_tags))
or set(map(_norm, authors)) != set(map(_norm, base_authors))
or set(map(_norm, groups)) != set(map(_norm, base_groups))
or final_search != base_search
or final_sort != base_sort
or final_liked != base_liked
or final_drafts != base_drafts
)
# Page logic
if page is KEEP:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# Build params
params = []
for s in groups:
params.append(("group", s))
for s in tags:
params.append(("tag", s))
for s in authors:
params.append(("author", s))
if final_search:
params.append(("search", final_search))
if final_liked is not None:
params.append(("liked", final_liked))
if final_sort:
params.append(("sort", final_sort))
if final_view:
params.append(("view", final_view))
if final_drafts:
params.append(("drafts", final_drafts))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

View File

@@ -0,0 +1,256 @@
"""
Editor API proxy image/media/file uploads and oembed.
Forwards requests to the Ghost Admin API with JWT auth so the browser
never needs direct Ghost access.
"""
from __future__ import annotations
import logging
import os
import httpx
from quart import Blueprint, request, jsonify, g
from sqlalchemy import select, or_
from shared.browser.app.authz import require_admin, require_login
from models import Snippet
from .ghost_admin_token import make_ghost_admin_jwt
log = logging.getLogger(__name__)
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
MAX_MEDIA_SIZE = 100 * 1024 * 1024 # 100 MB
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
ALLOWED_IMAGE_MIMETYPES = frozenset({
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
})
ALLOWED_MEDIA_MIMETYPES = frozenset({
"audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/aac",
"video/mp4", "video/webm", "video/ogg",
})
editor_api_bp = Blueprint("editor_api", __name__, url_prefix="/editor-api")
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
@editor_api_bp.post("/images/upload/")
@require_admin
async def upload_image():
"""Proxy image upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_IMAGE_SIZE:
return jsonify({"errors": [{"message": "File too large (max 10 MB)"}]}), 413
if uploaded.content_type not in ALLOWED_IMAGE_MIMETYPES:
return jsonify({"errors": [{"message": f"Unsupported file type: {uploaded.content_type}"}]}), 415
url = f"{GHOST_ADMIN_API_URL}/images/upload/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
url,
headers=_auth_header(),
files={"file": (uploaded.filename, content, uploaded.content_type)},
)
if not resp.is_success:
log.error("Ghost image upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.post("/media/upload/")
@require_admin
async def upload_media():
"""Proxy audio/video upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_MEDIA_SIZE:
return jsonify({"errors": [{"message": "File too large (max 100 MB)"}]}), 413
if uploaded.content_type not in ALLOWED_MEDIA_MIMETYPES:
return jsonify({"errors": [{"message": f"Unsupported media type: {uploaded.content_type}"}]}), 415
ghost_files = {"file": (uploaded.filename, content, uploaded.content_type)}
# Optional video thumbnail
thumbnail = files.get("thumbnail")
if thumbnail:
thumb_content = thumbnail.read()
ghost_files["thumbnail"] = (thumbnail.filename, thumb_content, thumbnail.content_type)
url = f"{GHOST_ADMIN_API_URL}/media/upload/"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=_auth_header(), files=ghost_files)
if not resp.is_success:
log.error("Ghost media upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.post("/files/upload/")
@require_admin
async def upload_file():
"""Proxy file upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_FILE_SIZE:
return jsonify({"errors": [{"message": "File too large (max 50 MB)"}]}), 413
url = f"{GHOST_ADMIN_API_URL}/files/upload/"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
url,
headers=_auth_header(),
files={"file": (uploaded.filename, content, uploaded.content_type)},
)
if not resp.is_success:
log.error("Ghost file upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.get("/oembed/")
@require_admin
async def oembed_proxy():
"""Proxy oembed lookups to Ghost Admin API."""
params = dict(request.args)
if not params.get("url"):
return jsonify({"errors": [{"message": "url parameter required"}]}), 400
url = f"{GHOST_ADMIN_API_URL}/oembed/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url, headers=_auth_header(), params=params)
if not resp.is_success:
log.error("Ghost oembed failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
# ── Snippets ────────────────────────────────────────────────────────
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
@editor_api_bp.get("/snippets/")
@require_login
async def list_snippets():
"""Return snippets visible to the current user."""
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return jsonify([
{"id": s.id, "name": s.name, "value": s.value, "visibility": s.visibility}
for s in rows
])
@editor_api_bp.post("/snippets/")
@require_login
async def create_snippet():
"""Create or upsert a snippet by (user_id, name)."""
data = await request.get_json(force=True)
name = (data.get("name") or "").strip()
value = data.get("value")
visibility = data.get("visibility", "private")
if not name or value is None:
return jsonify({"error": "name and value are required"}), 400
if visibility not in VALID_VISIBILITY:
return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400
if visibility != "private" and not g.rights.get("admin"):
visibility = "private"
uid = g.user.id
existing = (await g.s.execute(
select(Snippet).where(Snippet.user_id == uid, Snippet.name == name)
)).scalar_one_or_none()
if existing:
existing.value = value
existing.visibility = visibility
snippet = existing
else:
snippet = Snippet(user_id=uid, name=name, value=value, visibility=visibility)
g.s.add(snippet)
await g.s.flush()
return jsonify({
"id": snippet.id, "name": snippet.name,
"value": snippet.value, "visibility": snippet.visibility,
}), 200 if existing else 201
@editor_api_bp.patch("/snippets/<int:snippet_id>/")
@require_login
async def patch_snippet(snippet_id: int):
"""Update snippet visibility. Only admins may set shared/admin."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
return jsonify({"error": "not found"}), 404
is_admin = g.rights.get("admin")
if snippet.user_id != g.user.id and not is_admin:
return jsonify({"error": "forbidden"}), 403
data = await request.get_json(force=True)
visibility = data.get("visibility")
if visibility is not None:
if visibility not in VALID_VISIBILITY:
return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400
if visibility != "private" and not is_admin:
return jsonify({"error": "only admins may set shared/admin visibility"}), 403
snippet.visibility = visibility
await g.s.flush()
return jsonify({
"id": snippet.id, "name": snippet.name,
"value": snippet.value, "visibility": snippet.visibility,
})
@editor_api_bp.delete("/snippets/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
"""Delete a snippet. Owners can delete their own; admins can delete any."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
return jsonify({"error": "not found"}), 404
if snippet.user_id != g.user.id and not g.rights.get("admin"):
return jsonify({"error": "forbidden"}), 403
await g.s.delete(snippet)
await g.s.flush()
return jsonify({"ok": True})

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

@@ -0,0 +1,204 @@
"""
Ghost Admin API post CRUD.
Uses the same JWT auth and httpx patterns as ghost_sync.py.
"""
from __future__ import annotations
import logging
import os
import httpx
from .ghost_admin_token import make_ghost_admin_jwt
log = logging.getLogger(__name__)
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
def _check(resp: httpx.Response) -> None:
"""Raise with the Ghost error body so callers see what went wrong."""
if resp.is_success:
return
body = resp.text[:2000]
log.error("Ghost API %s %s%s: %s", resp.request.method, resp.request.url, resp.status_code, body)
resp.raise_for_status()
async def get_post_for_edit(ghost_id: str, *, is_page: bool = False) -> dict | None:
"""Fetch a single post/page by Ghost ID, including lexical source."""
resource = "pages" if is_page else "posts"
url = (
f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
"?formats=lexical,html,mobiledoc&include=newsletters"
)
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url, headers=_auth_header())
if resp.status_code == 404:
return None
_check(resp)
return resp.json()[resource][0]
async def create_post(
title: str,
lexical_json: str,
status: str = "draft",
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
) -> dict:
"""Create a new post in Ghost. Returns the created post dict."""
post_body: dict = {
"title": title,
"lexical": lexical_json,
"mobiledoc": None,
"status": status,
}
if feature_image:
post_body["feature_image"] = feature_image
if custom_excerpt:
post_body["custom_excerpt"] = custom_excerpt
if feature_image_caption is not None:
post_body["feature_image_caption"] = feature_image_caption
payload = {"posts": [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()["posts"][0]
async def create_page(
title: str,
lexical_json: str,
status: str = "draft",
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
) -> dict:
"""Create a new page in Ghost (via /pages/ endpoint). Returns the created page dict."""
page_body: dict = {
"title": title,
"lexical": lexical_json,
"mobiledoc": None,
"status": status,
}
if feature_image:
page_body["feature_image"] = feature_image
if custom_excerpt:
page_body["custom_excerpt"] = custom_excerpt
if feature_image_caption is not None:
page_body["feature_image_caption"] = feature_image_caption
payload = {"pages": [page_body]}
url = f"{GHOST_ADMIN_API_URL}/pages/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()["pages"][0]
async def update_post(
ghost_id: str,
lexical_json: str,
title: str | None,
updated_at: str,
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
status: str | None = None,
newsletter_slug: str | None = None,
email_segment: str | None = None,
email_only: bool | None = None,
is_page: bool = False,
) -> dict:
"""Update an existing Ghost post. Returns the updated post dict.
``updated_at`` is Ghost's optimistic-locking token pass the value
you received from ``get_post_for_edit``.
When ``newsletter_slug`` is set the publish request also triggers an
email send via Ghost's query-parameter API:
``?newsletter={slug}&email_segment={segment}``.
"""
post_body: dict = {
"lexical": lexical_json,
"mobiledoc": None,
"updated_at": updated_at,
}
if title is not None:
post_body["title"] = title
if feature_image is not None:
post_body["feature_image"] = feature_image or None
if custom_excerpt is not None:
post_body["custom_excerpt"] = custom_excerpt or None
if feature_image_caption is not None:
post_body["feature_image_caption"] = feature_image_caption
if status is not None:
post_body["status"] = status
if email_only:
post_body["email_only"] = True
resource = "pages" if is_page else "posts"
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
if newsletter_slug:
url += f"?newsletter={newsletter_slug}"
if email_segment:
url += f"&email_segment={email_segment}"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()[resource][0]
_SETTINGS_FIELDS = (
"slug",
"published_at",
"featured",
"visibility",
"email_only",
"custom_template",
"meta_title",
"meta_description",
"canonical_url",
"og_image",
"og_title",
"og_description",
"twitter_image",
"twitter_title",
"twitter_description",
"tags",
"feature_image_alt",
)
async def update_post_settings(
ghost_id: str,
updated_at: str,
is_page: bool = False,
**kwargs,
) -> dict:
"""Update Ghost post/page settings (slug, tags, SEO, social, etc.).
Only non-None keyword args are included in the PUT payload.
Accepts any key from ``_SETTINGS_FIELDS``.
"""
resource = "pages" if is_page else "posts"
post_body: dict = {"updated_at": updated_at}
for key in _SETTINGS_FIELDS:
val = kwargs.get(key)
if val is not None:
post_body[key] = val
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()[resource][0]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,668 @@
"""
Lexical JSON → HTML renderer.
Produces HTML matching Ghost's ``kg-*`` class conventions so the existing
``cards.css`` stylesheet works unchanged.
Public API
----------
render_lexical(doc) Lexical JSON (dict or string) → HTML string
"""
from __future__ import annotations
import html
import json
from typing import Callable
import mistune
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_RENDERERS: dict[str, Callable[[dict], str]] = {}
def _renderer(node_type: str):
"""Decorator — register a function as the renderer for *node_type*."""
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
_RENDERERS[node_type] = fn
return fn
return decorator
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def render_lexical(doc: dict | str) -> str:
"""Render a Lexical JSON document to an HTML string."""
if isinstance(doc, str):
doc = json.loads(doc)
root = doc.get("root", doc)
return _render_children(root.get("children", []))
# ---------------------------------------------------------------------------
# Core dispatch
# ---------------------------------------------------------------------------
def _render_node(node: dict) -> str:
node_type = node.get("type", "")
renderer = _RENDERERS.get(node_type)
if renderer:
return renderer(node)
return ""
def _render_children(children: list[dict]) -> str:
return "".join(_render_node(c) for c in children)
# ---------------------------------------------------------------------------
# Text formatting
# ---------------------------------------------------------------------------
# Lexical format bitmask
_FORMAT_BOLD = 1
_FORMAT_ITALIC = 2
_FORMAT_STRIKETHROUGH = 4
_FORMAT_UNDERLINE = 8
_FORMAT_CODE = 16
_FORMAT_SUBSCRIPT = 32
_FORMAT_SUPERSCRIPT = 64
_FORMAT_HIGHLIGHT = 128
_FORMAT_TAGS: list[tuple[int, str, str]] = [
(_FORMAT_BOLD, "<strong>", "</strong>"),
(_FORMAT_ITALIC, "<em>", "</em>"),
(_FORMAT_STRIKETHROUGH, "<s>", "</s>"),
(_FORMAT_UNDERLINE, "<u>", "</u>"),
(_FORMAT_CODE, "<code>", "</code>"),
(_FORMAT_SUBSCRIPT, "<sub>", "</sub>"),
(_FORMAT_SUPERSCRIPT, "<sup>", "</sup>"),
(_FORMAT_HIGHLIGHT, "<mark>", "</mark>"),
]
# Element-level alignment from ``format`` field
_ALIGN_MAP = {
1: "text-align: left",
2: "text-align: center",
3: "text-align: right",
4: "text-align: justify",
}
def _align_style(node: dict) -> str:
fmt = node.get("format")
if isinstance(fmt, int) and fmt in _ALIGN_MAP:
return f' style="{_ALIGN_MAP[fmt]}"'
if isinstance(fmt, str) and fmt:
return f' style="text-align: {fmt}"'
return ""
def _wrap_format(text: str, fmt: int) -> str:
for mask, open_tag, close_tag in _FORMAT_TAGS:
if fmt & mask:
text = f"{open_tag}{text}{close_tag}"
return text
# ---------------------------------------------------------------------------
# Tier 1 — text nodes
# ---------------------------------------------------------------------------
@_renderer("text")
def _text(node: dict) -> str:
text = html.escape(node.get("text", ""))
fmt = node.get("format", 0)
if isinstance(fmt, int) and fmt:
text = _wrap_format(text, fmt)
return text
@_renderer("linebreak")
def _linebreak(_node: dict) -> str:
return "<br>"
@_renderer("tab")
def _tab(_node: dict) -> str:
return "\t"
@_renderer("paragraph")
def _paragraph(node: dict) -> str:
inner = _render_children(node.get("children", []))
if not inner:
inner = "<br>"
style = _align_style(node)
return f"<p{style}>{inner}</p>"
@_renderer("extended-text")
def _extended_text(node: dict) -> str:
return _paragraph(node)
@_renderer("heading")
def _heading(node: dict) -> str:
tag = node.get("tag", "h2")
inner = _render_children(node.get("children", []))
style = _align_style(node)
return f"<{tag}{style}>{inner}</{tag}>"
@_renderer("extended-heading")
def _extended_heading(node: dict) -> str:
return _heading(node)
@_renderer("quote")
def _quote(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<blockquote>{inner}</blockquote>"
@_renderer("extended-quote")
def _extended_quote(node: dict) -> str:
return _quote(node)
@_renderer("aside")
def _aside(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<aside>{inner}</aside>"
@_renderer("link")
def _link(node: dict) -> str:
href = html.escape(node.get("url", ""), quote=True)
target = node.get("target", "")
rel = node.get("rel", "")
inner = _render_children(node.get("children", []))
attrs = f' href="{href}"'
if target:
attrs += f' target="{html.escape(target, quote=True)}"'
if rel:
attrs += f' rel="{html.escape(rel, quote=True)}"'
return f"<a{attrs}>{inner}</a>"
@_renderer("autolink")
def _autolink(node: dict) -> str:
return _link(node)
@_renderer("at-link")
def _at_link(node: dict) -> str:
return _link(node)
@_renderer("list")
def _list(node: dict) -> str:
tag = "ol" if node.get("listType") == "number" else "ul"
start = node.get("start")
inner = _render_children(node.get("children", []))
attrs = ""
if tag == "ol" and start and start != 1:
attrs = f' start="{start}"'
return f"<{tag}{attrs}>{inner}</{tag}>"
@_renderer("listitem")
def _listitem(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<li>{inner}</li>"
@_renderer("horizontalrule")
def _horizontalrule(_node: dict) -> str:
return "<hr>"
@_renderer("code")
def _code(node: dict) -> str:
# Inline code nodes from Lexical — just render inner text
inner = _render_children(node.get("children", []))
return f"<code>{inner}</code>"
@_renderer("codeblock")
def _codeblock(node: dict) -> str:
lang = node.get("language", "")
code = html.escape(node.get("code", ""))
cls = f' class="language-{html.escape(lang)}"' if lang else ""
return f'<pre><code{cls}>{code}</code></pre>'
@_renderer("code-highlight")
def _code_highlight(node: dict) -> str:
text = html.escape(node.get("text", ""))
highlight_type = node.get("highlightType", "")
if highlight_type:
return f'<span class="token {html.escape(highlight_type)}">{text}</span>'
return text
# ---------------------------------------------------------------------------
# Tier 2 — common cards
# ---------------------------------------------------------------------------
@_renderer("image")
def _image(node: dict) -> str:
src = node.get("src", "")
alt = node.get("alt", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "") or node.get("width", "")
href = node.get("href", "")
width_class = ""
if width == "wide":
width_class = " kg-width-wide"
elif width == "full":
width_class = " kg-width-full"
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
if href:
img_tag = f'<a href="{html.escape(href, quote=True)}">{img_tag}</a>'
parts = [f'<figure class="kg-card kg-image-card{width_class}">']
parts.append(img_tag)
if caption:
parts.append(f"<figcaption>{caption}</figcaption>")
parts.append("</figure>")
return "".join(parts)
@_renderer("gallery")
def _gallery(node: dict) -> str:
images = node.get("images", [])
if not images:
return ""
rows = []
for i in range(0, len(images), 3):
row_imgs = images[i:i + 3]
row_cls = f"kg-gallery-row" if len(row_imgs) <= 3 else "kg-gallery-row"
imgs_html = []
for img in row_imgs:
src = img.get("src", "")
alt = img.get("alt", "")
caption = img.get("caption", "")
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
fig = f'<figure class="kg-gallery-image">{img_tag}'
if caption:
fig += f"<figcaption>{caption}</figcaption>"
fig += "</figure>"
imgs_html.append(fig)
rows.append(f'<div class="{row_cls}">{"".join(imgs_html)}</div>')
caption = node.get("caption", "")
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-gallery-card kg-width-wide">'
f'<div class="kg-gallery-container">{"".join(rows)}</div>'
f"{caption_html}</figure>"
)
@_renderer("html")
def _html_card(node: dict) -> str:
raw = node.get("html", "")
return f"<!--kg-card-begin: html-->{raw}<!--kg-card-end: html-->"
@_renderer("markdown")
def _markdown(node: dict) -> str:
md_text = node.get("markdown", "")
rendered = mistune.html(md_text)
return f"<!--kg-card-begin: markdown-->{rendered}<!--kg-card-end: markdown-->"
@_renderer("embed")
def _embed(node: dict) -> str:
embed_html = node.get("html", "")
caption = node.get("caption", "")
url = node.get("url", "")
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-embed-card">'
f"{embed_html}{caption_html}</figure>"
)
@_renderer("bookmark")
def _bookmark(node: dict) -> str:
url = node.get("url", "")
title = html.escape(node.get("metadata", {}).get("title", "") or node.get("title", ""))
description = html.escape(node.get("metadata", {}).get("description", "") or node.get("description", ""))
icon = node.get("metadata", {}).get("icon", "") or node.get("icon", "")
author = html.escape(node.get("metadata", {}).get("author", "") or node.get("author", ""))
publisher = html.escape(node.get("metadata", {}).get("publisher", "") or node.get("publisher", ""))
thumbnail = node.get("metadata", {}).get("thumbnail", "") or node.get("thumbnail", "")
caption = node.get("caption", "")
icon_html = f'<img class="kg-bookmark-icon" src="{html.escape(icon, quote=True)}" alt="">' if icon else ""
thumbnail_html = (
f'<div class="kg-bookmark-thumbnail">'
f'<img src="{html.escape(thumbnail, quote=True)}" alt=""></div>'
) if thumbnail else ""
meta_parts = []
if icon_html:
meta_parts.append(icon_html)
if author:
meta_parts.append(f'<span class="kg-bookmark-author">{author}</span>')
if publisher:
meta_parts.append(f'<span class="kg-bookmark-publisher">{publisher}</span>')
metadata_html = f'<span class="kg-bookmark-metadata">{"".join(meta_parts)}</span>' if meta_parts else ""
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-bookmark-card">'
f'<a class="kg-bookmark-container" href="{html.escape(url, quote=True)}">'
f'<div class="kg-bookmark-content">'
f'<div class="kg-bookmark-title">{title}</div>'
f'<div class="kg-bookmark-description">{description}</div>'
f'{metadata_html}'
f'</div>'
f'{thumbnail_html}'
f'</a>'
f'{caption_html}'
f'</figure>'
)
@_renderer("callout")
def _callout(node: dict) -> str:
color = node.get("backgroundColor", "grey")
emoji = node.get("calloutEmoji", "")
inner = _render_children(node.get("children", []))
emoji_html = f'<div class="kg-callout-emoji">{emoji}</div>' if emoji else ""
return (
f'<div class="kg-card kg-callout-card kg-callout-card-{html.escape(color)}">'
f'{emoji_html}'
f'<div class="kg-callout-text">{inner}</div>'
f'</div>'
)
@_renderer("button")
def _button(node: dict) -> str:
text = html.escape(node.get("buttonText", ""))
url = html.escape(node.get("buttonUrl", ""), quote=True)
alignment = node.get("alignment", "center")
return (
f'<div class="kg-card kg-button-card kg-align-{alignment}">'
f'<a href="{url}" class="kg-btn kg-btn-accent">{text}</a>'
f'</div>'
)
@_renderer("toggle")
def _toggle(node: dict) -> str:
heading = node.get("heading", "")
# Toggle content is in children
inner = _render_children(node.get("children", []))
return (
f'<div class="kg-card kg-toggle-card" data-kg-toggle-state="close">'
f'<div class="kg-toggle-heading">'
f'<h4 class="kg-toggle-heading-text">{heading}</h4>'
f'<button class="kg-toggle-card-icon">'
f'<svg viewBox="0 0 14 14"><path d="M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z" fill="currentColor"/></svg>'
f'</button>'
f'</div>'
f'<div class="kg-toggle-content">{inner}</div>'
f'</div>'
)
# ---------------------------------------------------------------------------
# Tier 3 — media & remaining cards
# ---------------------------------------------------------------------------
@_renderer("audio")
def _audio(node: dict) -> str:
src = node.get("src", "")
title = html.escape(node.get("title", ""))
duration = node.get("duration", 0)
thumbnail = node.get("thumbnailSrc", "")
duration_min = int(duration) // 60
duration_sec = int(duration) % 60
duration_str = f"{duration_min}:{duration_sec:02d}"
if thumbnail:
thumb_html = (
f'<img src="{html.escape(thumbnail, quote=True)}" alt="audio-thumbnail" '
f'class="kg-audio-thumbnail">'
)
else:
thumb_html = (
'<div class="kg-audio-thumbnail placeholder">'
'<svg viewBox="0 0 24 24"><path d="M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z" fill="currentColor"/></svg>'
'</div>'
)
return (
f'<div class="kg-card kg-audio-card">'
f'{thumb_html}'
f'<div class="kg-audio-player-container">'
f'<div class="kg-audio-title">{title}</div>'
f'<div class="kg-audio-player">'
f'<button class="kg-audio-play-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg></button>'
f'<div class="kg-audio-current-time">0:00</div>'
f'<div class="kg-audio-time">/ {duration_str}</div>'
f'<input type="range" class="kg-audio-seek-slider" max="100" value="0">'
f'<button class="kg-audio-playback-rate">1&#215;</button>'
f'<button class="kg-audio-unmute-icon"><svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/></svg></button>'
f'<input type="range" class="kg-audio-volume-slider" max="100" value="100">'
f'</div>'
f'</div>'
f'<audio src="{html.escape(src, quote=True)}" preload="metadata"></audio>'
f'</div>'
)
@_renderer("video")
def _video(node: dict) -> str:
src = node.get("src", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "")
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
loop = node.get("loop", False)
width_class = ""
if width == "wide":
width_class = " kg-width-wide"
elif width == "full":
width_class = " kg-width-full"
loop_attr = " loop" if loop else ""
poster_attr = f' poster="{html.escape(thumbnail, quote=True)}"' if thumbnail else ""
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-video-card{width_class}">'
f'<div class="kg-video-container">'
f'<video src="{html.escape(src, quote=True)}" controls preload="metadata"{poster_attr}{loop_attr}></video>'
f'</div>'
f'{caption_html}'
f'</figure>'
)
@_renderer("file")
def _file(node: dict) -> str:
src = node.get("src", "")
title = html.escape(node.get("fileName", "") or node.get("title", ""))
caption = node.get("caption", "")
file_size = node.get("fileSize", 0)
file_name = html.escape(node.get("fileName", ""))
# Format size
if file_size:
kb = file_size / 1024
if kb < 1024:
size_str = f"{kb:.0f} KB"
else:
size_str = f"{kb / 1024:.1f} MB"
else:
size_str = ""
caption_html = f'<div class="kg-file-card-caption">{caption}</div>' if caption else ""
size_html = f'<div class="kg-file-card-filesize">{size_str}</div>' if size_str else ""
return (
f'<div class="kg-card kg-file-card">'
f'<a class="kg-file-card-container" href="{html.escape(src, quote=True)}" download="{file_name}">'
f'<div class="kg-file-card-contents">'
f'<div class="kg-file-card-title">{title}</div>'
f'{size_html}'
f'</div>'
f'<div class="kg-file-card-icon">'
f'<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor"/></svg>'
f'</div>'
f'</a>'
f'{caption_html}'
f'</div>'
)
@_renderer("paywall")
def _paywall(_node: dict) -> str:
return "<!--members-only-->"
@_renderer("header")
def _header(node: dict) -> str:
heading = node.get("heading", "")
subheading = node.get("subheading", "")
size = node.get("size", "small")
style = node.get("style", "dark")
bg_image = node.get("backgroundImageSrc", "")
button_text = node.get("buttonText", "")
button_url = node.get("buttonUrl", "")
bg_style = f' style="background-image: url({html.escape(bg_image, quote=True)})"' if bg_image else ""
heading_html = f"<h2>{heading}</h2>" if heading else ""
subheading_html = f"<p>{subheading}</p>" if subheading else ""
button_html = (
f'<a class="kg-header-card-button" href="{html.escape(button_url, quote=True)}">{html.escape(button_text)}</a>'
if button_text and button_url else ""
)
return (
f'<div class="kg-card kg-header-card kg-style-{html.escape(style)} kg-size-{html.escape(size)}"{bg_style}>'
f'{heading_html}{subheading_html}{button_html}'
f'</div>'
)
@_renderer("signup")
def _signup(node: dict) -> str:
heading = node.get("heading", "")
subheading = node.get("subheading", "")
disclaimer = node.get("disclaimer", "")
button_text = html.escape(node.get("buttonText", "Subscribe"))
button_color = node.get("buttonColor", "")
bg_color = node.get("backgroundColor", "")
bg_image = node.get("backgroundImageSrc", "")
style = node.get("style", "dark")
bg_style_parts = []
if bg_color:
bg_style_parts.append(f"background-color: {bg_color}")
if bg_image:
bg_style_parts.append(f"background-image: url({html.escape(bg_image, quote=True)})")
style_attr = f' style="{"; ".join(bg_style_parts)}"' if bg_style_parts else ""
heading_html = f"<h2>{heading}</h2>" if heading else ""
subheading_html = f"<p>{subheading}</p>" if subheading else ""
disclaimer_html = f'<p class="kg-signup-card-disclaimer">{disclaimer}</p>' if disclaimer else ""
btn_style = f' style="background-color: {button_color}"' if button_color else ""
return (
f'<div class="kg-card kg-signup-card kg-style-{html.escape(style)}"{style_attr}>'
f'{heading_html}{subheading_html}'
f'<form class="kg-signup-card-form" data-members-form>'
f'<input type="email" placeholder="Your email" required>'
f'<button type="submit" class="kg-signup-card-button"{btn_style}>{button_text}</button>'
f'</form>'
f'{disclaimer_html}'
f'</div>'
)
@_renderer("product")
def _product(node: dict) -> str:
title = html.escape(node.get("productTitle", "") or node.get("title", ""))
description = node.get("productDescription", "") or node.get("description", "")
img_src = node.get("productImageSrc", "")
button_text = html.escape(node.get("buttonText", ""))
button_url = node.get("buttonUrl", "")
rating = node.get("rating", 0)
img_html = (
f'<img class="kg-product-card-image" src="{html.escape(img_src, quote=True)}" alt="">'
if img_src else ""
)
button_html = (
f'<a class="kg-product-card-button kg-btn kg-btn-accent" href="{html.escape(button_url, quote=True)}">{button_text}</a>'
if button_text and button_url else ""
)
stars = ""
if rating:
active = int(rating)
stars_html = []
for i in range(5):
cls = "kg-product-card-rating-active" if i < active else ""
stars_html.append(
f'<svg class="kg-product-card-rating-star {cls}" viewBox="0 0 24 24">'
f'<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279L0 9.306l8.332-1.151z" fill="currentColor"/>'
f'</svg>'
)
stars = f'<div class="kg-product-card-rating">{"".join(stars_html)}</div>'
return (
f'<div class="kg-card kg-product-card">'
f'{img_html}'
f'<div class="kg-product-card-container">'
f'<h4 class="kg-product-card-title">{title}</h4>'
f'{stars}'
f'<div class="kg-product-card-description">{description}</div>'
f'{button_html}'
f'</div>'
f'</div>'
)
@_renderer("email")
def _email(node: dict) -> str:
raw_html = node.get("html", "")
return f"<!--kg-card-begin: email-->{raw_html}<!--kg-card-end: email-->"
@_renderer("email-cta")
def _email_cta(node: dict) -> str:
raw_html = node.get("html", "")
return f"<!--kg-card-begin: email-cta-->{raw_html}<!--kg-card-end: email-cta-->"
@_renderer("call-to-action")
def _call_to_action(node: dict) -> str:
raw_html = node.get("html", "")
sponsor_label = node.get("sponsorLabel", "")
label_html = (
f'<span class="kg-cta-sponsor-label">{html.escape(sponsor_label)}</span>'
if sponsor_label else ""
)
return (
f'<div class="kg-card kg-cta-card">'
f'{label_html}{raw_html}'
f'</div>'
)

View File

@@ -0,0 +1,86 @@
"""
Server-side validation for Lexical editor JSON.
Walk the document tree and reject any node whose ``type`` is not in
ALLOWED_NODE_TYPES. This is a belt-and-braces check: the Lexical
client already restricts which nodes can be created, but we validate
server-side too.
"""
from __future__ import annotations
ALLOWED_NODE_TYPES: frozenset[str] = frozenset(
{
# Standard Lexical nodes
"root",
"paragraph",
"heading",
"quote",
"list",
"listitem",
"link",
"autolink",
"code",
"code-highlight",
"linebreak",
"text",
"horizontalrule",
"image",
"tab",
# Ghost "extended-*" variants
"extended-text",
"extended-heading",
"extended-quote",
# Ghost card types
"html",
"gallery",
"embed",
"bookmark",
"markdown",
"email",
"email-cta",
"button",
"callout",
"toggle",
"video",
"audio",
"file",
"product",
"header",
"signup",
"aside",
"codeblock",
"call-to-action",
"at-link",
"paywall",
}
)
def validate_lexical(doc: dict) -> tuple[bool, str | None]:
"""Recursively validate a Lexical JSON document.
Returns ``(True, None)`` when the document is valid, or
``(False, reason)`` when an unknown node type is found.
"""
if not isinstance(doc, dict):
return False, "Document must be a JSON object"
root = doc.get("root")
if not isinstance(root, dict):
return False, "Document must contain a 'root' object"
return _walk(root)
def _walk(node: dict) -> tuple[bool, str | None]:
node_type = node.get("type")
if node_type is not None and node_type not in ALLOWED_NODE_TYPES:
return False, f"Disallowed node type: {node_type}"
for child in node.get("children", []):
if isinstance(child, dict):
ok, reason = _walk(child)
if not ok:
return False, reason
return True, None

632
blog/bp/blog/ghost_db.py Normal file
View File

@@ -0,0 +1,632 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select, func, asc, desc, and_, or_
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
class DBAPIError(Exception):
"""Raised when our local DB returns something unexpected."""
def _author_to_public(a: Optional[Author]) -> Optional[Dict[str, Any]]:
if a is None:
return None
if a.deleted_at is not None:
# treat deleted authors as missing
return None
return {
"id": a.ghost_id,
"slug": a.slug,
"name": a.name,
"profile_image": a.profile_image,
"cover_image": a.cover_image,
# expose more (bio, etc.) if needed
}
def _tag_to_public(t: Tag) -> Dict[str, Any]:
return {
"id": t.ghost_id,
"slug": t.slug,
"name": t.name,
"description": t.description,
"feature_image": t.feature_image, # fixed key
"visibility": t.visibility,
"deleted_at": t.deleted_at,
}
def _post_to_public(p: Post) -> Dict[str, Any]:
"""
Shape a Post to the public JSON used by the app, mirroring GhostClient._normalise_post.
"""
# Primary author: explicit or first available
primary_author = p.primary_author or (p.authors[0] if p.authors else None)
# Primary tag: prefer explicit relationship, otherwise first public/non-deleted tag
primary_tag = getattr(p, "primary_tag", None)
if primary_tag is None:
public_tags = [
t for t in (p.tags or [])
if t.deleted_at is None and (t.visibility or "public") == "public"
]
primary_tag = public_tags[0] if public_tags else None
return {
"id": p.id,
"ghost_id": p.ghost_id,
"slug": p.slug,
"title": p.title,
"html": p.html,
"is_page": p.is_page,
"excerpt": p.custom_excerpt or p.excerpt,
"custom_excerpt": p.custom_excerpt,
"published_at": p.published_at,
"updated_at": p.updated_at,
"visibility": p.visibility,
"status": p.status,
"deleted_at": p.deleted_at,
"feature_image": p.feature_image,
"user_id": p.user_id,
"publish_requested": p.publish_requested,
"primary_author": _author_to_public(primary_author),
"primary_tag": _tag_to_public(primary_tag) if primary_tag else None,
"tags": [
_tag_to_public(t)
for t in (p.tags or [])
if t.deleted_at is None and (t.visibility or "public") == "public"
],
"authors": [
_author_to_public(a)
for a in (p.authors or [])
if a and a.deleted_at is None
],
}
class DBClient:
"""
Drop-in replacement for GhostClient, but served from our mirrored tables.
Call methods with an AsyncSession.
"""
def __init__(self, session: AsyncSession):
self.sess = session
async def list_posts(
self,
limit: int = 10,
page: int = 1,
selected_tags: Optional[Sequence[str]] = None,
selected_authors: Optional[Sequence[str]] = None,
search: Optional[str] = None,
drafts: bool = False,
drafts_user_id: Optional[int] = None,
exclude_covered_tag_ids: Optional[Sequence[int]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
List published posts, optionally filtered by tags/authors and a search term.
When drafts=True, lists draft posts instead (filtered by drafts_user_id if given).
Returns (posts, pagination).
"""
# ---- base visibility filters
if drafts:
base_filters = [
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
]
if drafts_user_id is not None:
base_filters.append(Post.user_id == drafts_user_id)
else:
base_filters = [
Post.deleted_at.is_(None),
Post.status == "published",
Post.is_page.is_(False),
]
q = select(Post).where(*base_filters)
# ---- TAG FILTER (matches any tag on the post)
if selected_tags:
tag_slugs = list(selected_tags)
q = q.where(
Post.tags.any(
and_(
Tag.slug.in_(tag_slugs),
Tag.deleted_at.is_(None),
)
)
)
# ---- EXCLUDE-COVERED FILTER ("etc" mode: posts NOT covered by any group)
if exclude_covered_tag_ids:
covered_sq = (
select(PostTag.post_id)
.join(Tag, Tag.id == PostTag.tag_id)
.where(
Tag.id.in_(list(exclude_covered_tag_ids)),
Tag.deleted_at.is_(None),
)
)
q = q.where(Post.id.notin_(covered_sq))
# ---- AUTHOR FILTER (matches primary or any author)
if selected_authors:
author_slugs = list(selected_authors)
q = q.where(
or_(
Post.primary_author.has(
and_(
Author.slug.in_(author_slugs),
Author.deleted_at.is_(None),
)
),
Post.authors.any(
and_(
Author.slug.in_(author_slugs),
Author.deleted_at.is_(None),
)
),
)
)
# ---- SEARCH FILTER (title OR excerpt OR plaintext contains)
if search:
term = f"%{search.strip().lower()}%"
q = q.where(
or_(
func.lower(func.coalesce(Post.title, "")).like(term),
func.lower(func.coalesce(Post.excerpt, "")).like(term),
func.lower(func.coalesce(Post.plaintext,"")).like(term),
)
)
# ---- ordering
if drafts:
q = q.order_by(desc(Post.updated_at))
else:
q = q.order_by(desc(Post.published_at))
# ---- pagination math
if page < 1:
page = 1
offset_val = (page - 1) * limit
# ---- total count with SAME filters (including tag/author/search)
q_no_limit = q.with_only_columns(Post.id).order_by(None)
count_q = select(func.count()).select_from(q_no_limit.subquery())
total = int((await self.sess.execute(count_q)).scalar() or 0)
# ---- eager load relationships to avoid N+1 / greenlet issues
q = (
q.options(
joinedload(Post.primary_author),
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
)
.limit(limit)
.offset(offset_val)
)
rows: List[Post] = list((await self.sess.execute(q)).scalars())
posts = [_post_to_public(p) for p in rows]
# ---- search_count: reflect same filters + search (i.e., equals total once filters applied)
search_count = total
pages_total = (total + limit - 1) // limit if limit else 1
pagination = {
"page": page,
"limit": limit,
"pages": pages_total,
"total": total,
"search_count": search_count,
"next": page + 1 if page < pages_total else None,
"prev": page - 1 if page > 1 else None,
}
return posts, pagination
async def list_pages(
self,
limit: int = 10,
page: int = 1,
search: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
List published pages (is_page=True) with their PageConfig eagerly loaded.
Returns (pages, pagination).
"""
base_filters = [
Post.deleted_at.is_(None),
Post.status == "published",
Post.is_page.is_(True),
]
q = select(Post).where(*base_filters)
if search:
term = f"%{search.strip().lower()}%"
q = q.where(
or_(
func.lower(func.coalesce(Post.title, "")).like(term),
func.lower(func.coalesce(Post.excerpt, "")).like(term),
func.lower(func.coalesce(Post.plaintext, "")).like(term),
)
)
q = q.order_by(desc(Post.published_at))
if page < 1:
page = 1
offset_val = (page - 1) * limit
q_no_limit = q.with_only_columns(Post.id).order_by(None)
count_q = select(func.count()).select_from(q_no_limit.subquery())
total = int((await self.sess.execute(count_q)).scalar() or 0)
q = (
q.options(
joinedload(Post.primary_author),
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
joinedload(Post.page_config),
)
.limit(limit)
.offset(offset_val)
)
rows: List[Post] = list((await self.sess.execute(q)).scalars())
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 {}
return d
pages_list = [_page_to_public(p) for p in rows]
pages_total = (total + limit - 1) // limit if limit else 1
pagination = {
"page": page,
"limit": limit,
"pages": pages_total,
"total": total,
"next": page + 1 if page < pages_total else None,
"prev": page - 1 if page > 1 else None,
}
return pages_list, pagination
async def posts_by_slug(
self,
slug: str,
include: Sequence[str] = ("tags", "authors"),
fields: Sequence[str] = (
"id",
"slug",
"title",
"html",
"excerpt",
"custom_excerpt",
"published_at",
"feature_image",
),
include_drafts: bool = False,
) -> List[Dict[str, Any]]:
"""
Return posts (usually 1) matching this slug.
Only returns published, non-deleted posts by default.
When include_drafts=True, also returns draft posts (for admin access).
Eager-load related objects via selectinload/joinedload so we don't N+1 when
serializing in _post_to_public().
"""
# Build .options(...) dynamically based on `include`
load_options = []
# Tags
if "tags" in include:
load_options.append(selectinload(Post.tags))
if hasattr(Post, "primary_tag"):
# joinedload is fine too; selectin keeps a single extra roundtrip
load_options.append(selectinload(Post.primary_tag))
# Authors
if "authors" in include:
if hasattr(Post, "primary_author"):
load_options.append(selectinload(Post.primary_author))
if hasattr(Post, "authors"):
load_options.append(selectinload(Post.authors))
filters = [Post.deleted_at.is_(None), Post.slug == slug]
if not include_drafts:
filters.append(Post.status == "published")
q = (
select(Post)
.where(*filters)
.order_by(desc(Post.published_at))
.options(*load_options)
)
result = await self.sess.execute(q)
rows: List[Post] = list(result.scalars())
return [(_post_to_public(p), p) for p in rows]
async def list_tags(
self,
limit: int = 5000,
page: int = 1,
is_page=False,
) -> List[Dict[str, Any]]:
"""
Return public, not-soft-deleted tags.
Include published_post_count = number of published (not deleted) posts using that tag.
"""
if page < 1:
page = 1
offset_val = (page - 1) * limit
# Subquery: count published posts per tag
tag_post_counts_sq = (
select(
PostTag.tag_id.label("tag_id"),
func.count().label("published_post_count"),
)
.select_from(PostTag)
.join(Post, Post.id == PostTag.post_id)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(is_page),
)
.group_by(PostTag.tag_id)
.subquery()
)
q = (
select(
Tag,
func.coalesce(tag_post_counts_sq.c.published_post_count, 0).label(
"published_post_count"
),
)
.outerjoin(
tag_post_counts_sq,
tag_post_counts_sq.c.tag_id == Tag.id,
)
.where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
func.coalesce(tag_post_counts_sq.c.published_post_count, 0) > 0,
)
.order_by(desc(func.coalesce(tag_post_counts_sq.c.published_post_count, 0)), asc(Tag.name))
.limit(limit)
.offset(offset_val)
)
result = await self.sess.execute(q)
# result will return rows like (Tag, published_post_count)
rows = list(result.all())
tags = [
{
"id": tag.ghost_id,
"slug": tag.slug,
"name": tag.name,
"description": tag.description,
"feature_image": tag.feature_image,
"visibility": tag.visibility,
"published_post_count": count,
}
for (tag, count) in rows
]
return tags
async def list_authors(
self,
limit: int = 5000,
page: int = 1,
is_page=False,
) -> List[Dict[str, Any]]:
"""
Return non-deleted authors.
Include published_post_count = number of published (not deleted) posts by that author
(counted via Post.primary_author_id).
"""
if page < 1:
page = 1
offset_val = (page - 1) * limit
# Subquery: count published posts per primary author
author_post_counts_sq = (
select(
Post.primary_author_id.label("author_id"),
func.count().label("published_post_count"),
)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(is_page),
)
.group_by(Post.primary_author_id)
.subquery()
)
q = (
select(
Author,
func.coalesce(author_post_counts_sq.c.published_post_count, 0).label(
"published_post_count"
),
)
.outerjoin(
author_post_counts_sq,
author_post_counts_sq.c.author_id == Author.id,
)
.where(
Author.deleted_at.is_(None),
)
.order_by(asc(Author.name))
.limit(limit)
.offset(offset_val)
)
result = await self.sess.execute(q)
rows = list(result.all())
authors = [
{
"id": a.ghost_id,
"slug": a.slug,
"name": a.name,
"bio": a.bio,
"profile_image": a.profile_image,
"cover_image": a.cover_image,
"website": a.website,
"location": a.location,
"facebook": a.facebook,
"twitter": a.twitter,
"published_post_count": count,
}
for (a, count) in rows
]
return authors
async def count_drafts(self, user_id: Optional[int] = None) -> int:
"""Count draft (non-page, non-deleted) posts, optionally for a single user."""
q = select(func.count()).select_from(Post).where(
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
)
if user_id is not None:
q = q.where(Post.user_id == user_id)
return int((await self.sess.execute(q)).scalar() or 0)
async def list_tag_groups_with_counts(self) -> List[Dict[str, Any]]:
"""
Return all tag groups with aggregated published post counts.
Each group dict includes a `tag_slugs` list and `tag_ids` list.
Count = distinct published posts having ANY member tag.
Ordered by sort_order, name.
"""
# Subquery: distinct published post IDs per tag group
post_count_sq = (
select(
TagGroupTag.tag_group_id.label("group_id"),
func.count(func.distinct(PostTag.post_id)).label("post_count"),
)
.select_from(TagGroupTag)
.join(PostTag, PostTag.tag_id == TagGroupTag.tag_id)
.join(Post, Post.id == PostTag.post_id)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(False),
)
.group_by(TagGroupTag.tag_group_id)
.subquery()
)
q = (
select(
TagGroup,
func.coalesce(post_count_sq.c.post_count, 0).label("post_count"),
)
.outerjoin(post_count_sq, post_count_sq.c.group_id == TagGroup.id)
.order_by(asc(TagGroup.sort_order), asc(TagGroup.name))
)
rows = list((await self.sess.execute(q)).all())
groups = []
for tg, count in rows:
# Fetch member tag slugs + ids for this group
tag_rows = list(
(await self.sess.execute(
select(Tag.slug, Tag.id)
.join(TagGroupTag, TagGroupTag.tag_id == Tag.id)
.where(
TagGroupTag.tag_group_id == tg.id,
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
)
)).all()
)
groups.append({
"id": tg.id,
"name": tg.name,
"slug": tg.slug,
"feature_image": tg.feature_image,
"colour": tg.colour,
"sort_order": tg.sort_order,
"post_count": count,
"tag_slugs": [r[0] for r in tag_rows],
"tag_ids": [r[1] for r in tag_rows],
})
return groups
async def count_etc_posts(self, assigned_tag_ids: List[int]) -> int:
"""
Count published posts not covered by any tag group.
Includes posts with no tags and posts whose tags are all unassigned.
"""
base = [
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(False),
]
if assigned_tag_ids:
covered_sq = (
select(PostTag.post_id)
.join(Tag, Tag.id == PostTag.tag_id)
.where(
Tag.id.in_(assigned_tag_ids),
Tag.deleted_at.is_(None),
)
)
base.append(Post.id.notin_(covered_sq))
q = select(func.count()).select_from(Post).where(*base)
return int((await self.sess.execute(q)).scalar() or 0)
async def list_drafts(self) -> List[Dict[str, Any]]:
"""Return all draft (non-page, non-deleted) posts, newest-updated first."""
q = (
select(Post)
.where(
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
)
.order_by(desc(Post.updated_at))
.options(
joinedload(Post.primary_author),
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
)
)
rows: List[Post] = list((await self.sess.execute(q)).scalars())
return [_post_to_public(p) for p in rows]

369
blog/bp/blog/routes.py Normal file
View File

@@ -0,0 +1,369 @@
from __future__ import annotations
#from quart import Blueprint, g
import json
import os
from quart import (
request,
render_template,
make_response,
g,
Blueprint,
redirect,
url_for,
)
from .ghost_db import DBClient # adjust import path
from shared.db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.utils import host_url
def register(url_prefix, title):
blogs_bp = Blueprint("blog", __name__, url_prefix)
from .web_hooks.routes import ghost_webhooks
blogs_bp.register_blueprint(ghost_webhooks)
from .ghost.editor_api import editor_api_bp
blogs_bp.register_blueprint(editor_api_bp)
from ..post.routes import register as register_blog
blogs_bp.register_blueprint(
register_blog(),
)
from .admin.routes import register as register_tag_groups_admin
blogs_bp.register_blueprint(register_tag_groups_admin())
@blogs_bp.before_app_serving
async def init():
from .ghost.ghost_sync import (
sync_all_content_from_ghost,
sync_all_membership_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
def route():
g.makeqs_factory = makeqs_factory
@blogs_bp.context_processor
async def inject_root():
return {
"blog_title": title,
"qs": makeqs_factory()(),
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
SORT_MAP = {
"newest": "published_at DESC",
"oldest": "published_at ASC",
"az": "title ASC",
"za": "title DESC",
"featured": "featured DESC, published_at DESC",
}
@blogs_bp.get("/")
async def home():
"""Render the Ghost page with slug 'home' as the site homepage."""
from ..post.services.post_data import post_data as _post_data
from shared.config import config as get_config
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services as svc
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
p_data = await _post_data("home", g.s, include_drafts=False)
if not p_data:
# Fall back to blog index if "home" page doesn't exist yet
return redirect(host_url(url_for("blog.index")))
g.post_data = p_data
# Build the same context the post blueprint's context_processor provides
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{get_config()['title']} {p_data['post']['title']}",
"container_nav_html": container_nav_html,
}
# Page cart badge
if p_data["post"].get("is_page"):
ident = current_cart_identity()
page_summary = await svc.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_slug,
)
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
if not is_htmx_request():
html = await render_template("_types/home/index.html", **ctx)
else:
html = await render_template("_types/home/_oob_elements.html", **ctx)
return await make_response(html)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
async def index():
"""Blog listing — moved from / to /index."""
q = decode()
content_type = request.args.get("type", "posts")
if content_type == "pages":
data = await pages_data(g.s, q.page, q.search)
context = {
**data,
"content_type": "pages",
"search": q.search,
"selected_tags": (),
"selected_authors": (),
"selected_groups": (),
"sort": None,
"view": None,
"drafts": None,
"draft_count": 0,
"tags": [],
"authors": [],
"tag_groups": [],
"posts": data.get("pages", []),
}
if not is_htmx_request():
html = await render_template("_types/blog/index.html", **context)
elif q.page > 1:
html = await render_template("_types/blog/_page_cards.html", **context)
else:
html = await render_template("_types/blog/_oob_elements.html", **context)
return await make_response(html)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
show_drafts = bool(q.drafts and g.user)
is_admin = bool((g.get("rights") or {}).get("admin"))
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
# For the draft count badge: admin sees all drafts, non-admin sees own
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
data = await posts_data(
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
)
context = {
**data,
"content_type": "posts",
"selected_tags": q.selected_tags,
"selected_authors": q.selected_authors,
"selected_groups": q.selected_groups,
"sort": q.sort,
"search": q.search,
"view": q.view,
"drafts": q.drafts if show_drafts else None,
}
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/blog/index.html", **context)
elif q.page > 1:
# HTMX pagination: just blog cards + sentinel
html = await render_template("_types/blog/_cards.html", **context)
else:
# HTMX navigation (page 1): main panel + OOB elements
#main_panel = await render_template("_types/blog/_main_panel.html", **context)
html = await render_template("_types/blog/_oob_elements.html", **context)
#html = oob_elements + main_panel
return await make_response(html)
@blogs_bp.get("/new/")
@require_admin
async def new_post():
if not is_htmx_request():
html = await render_template("_types/blog_new/index.html")
else:
html = await render_template("_types/blog_new/_oob_elements.html")
return await make_response(html)
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
from .ghost.ghost_posts import create_post
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_post
form = await request.form
title = form.get("title", "").strip() or "Untitled"
lexical_raw = form.get("lexical", "")
status = form.get("status", "draft")
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
html = await render_template(
"_types/blog_new/index.html",
save_error="Invalid JSON in editor content.",
)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
html = await render_template(
"_types/blog_new/index.html",
save_error=reason,
)
return await make_response(html, 400)
# Create in Ghost
ghost_post = await create_post(
title=title,
lexical_json=lexical_raw,
status=status,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB
await sync_single_post(g.s, ghost_post["id"])
await g.s.flush()
# Set user_id on the newly created post
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_post["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the edit page (post is likely a draft, so public detail would 404)
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
@blogs_bp.get("/new-page/")
@require_admin
async def new_page():
if not is_htmx_request():
html = await render_template("_types/blog_new/index.html", is_page=True)
else:
html = await render_template("_types/blog_new/_oob_elements.html", is_page=True)
return await make_response(html)
@blogs_bp.post("/new-page/")
@require_admin
async def new_page_save():
from .ghost.ghost_posts import create_page
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_page
form = await request.form
title = form.get("title", "").strip() or "Untitled"
lexical_raw = form.get("lexical", "")
status = form.get("status", "draft")
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
html = await render_template(
"_types/blog_new/index.html",
save_error="Invalid JSON in editor content.",
is_page=True,
)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
html = await render_template(
"_types/blog_new/index.html",
save_error=reason,
is_page=True,
)
return await make_response(html, 400)
# Create in Ghost (as page)
ghost_page = await create_page(
title=title,
lexical_json=lexical_raw,
status=status,
feature_image=feature_image or None,
custom_excerpt=custom_excerpt or None,
feature_image_caption=feature_image_caption or None,
)
# Sync to local DB (uses pages endpoint)
await sync_single_page(g.s, ghost_page["id"])
await g.s.flush()
# Set user_id on the newly created page
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_page["id"])
)).scalar_one_or_none()
if local_post and local_post.user_id is None:
local_post.user_id = g.user.id
await g.s.flush()
# Clear blog listing cache
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"])))
@blogs_bp.get("/drafts/")
async def drafts():
return redirect(host_url(url_for("blog.index")) + "?drafts=1")
return blogs_bp

View File

@@ -0,0 +1,18 @@
from ..ghost_db import DBClient
async def pages_data(session, page, search):
client = DBClient(session)
pages, pagination = await client.list_pages(
limit=10,
page=page,
search=search,
)
return {
"pages": pages,
"page": pagination.get("page", page),
"total_pages": pagination.get("pages", 1),
"search": search,
}

View File

@@ -0,0 +1,142 @@
import re
from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from shared.infrastructure.fragments import fetch_fragment
from quart import g
async def posts_data(
session,
page, search, sort, selected_tags, selected_authors, liked,
drafts=False, drafts_user_id=None, count_drafts_for_user_id=None,
selected_groups=(),
):
client = DBClient(session)
# --- Tag-group resolution ---
tag_groups = await client.list_tag_groups_with_counts()
# Collect all assigned tag IDs across groups
all_assigned_tag_ids = []
for grp in tag_groups:
all_assigned_tag_ids.extend(grp["tag_ids"])
# Build slug-lookup for groups
group_by_slug = {grp["slug"]: grp for grp in tag_groups}
# Resolve selected group → post filtering
# Groups and tags are mutually exclusive — groups override tags when set
effective_tags = selected_tags
etc_mode_tag_ids = None # set when "etc" is selected
if selected_groups:
group_slug = selected_groups[0]
if group_slug == "etc":
# etc = posts NOT covered by any group (includes untagged)
etc_mode_tag_ids = all_assigned_tag_ids
effective_tags = ()
elif group_slug in group_by_slug:
effective_tags = tuple(group_by_slug[group_slug]["tag_slugs"])
# Compute "etc" virtual group
etc_count = await client.count_etc_posts(all_assigned_tag_ids)
if etc_count > 0 or (selected_groups and selected_groups[0] == "etc"):
tag_groups.append({
"id": None,
"name": "etc",
"slug": "etc",
"feature_image": None,
"colour": None,
"sort_order": 999999,
"post_count": etc_count,
"tag_slugs": [],
"tag_ids": [],
})
posts, pagination = await client.list_posts(
limit=10,
page=page,
selected_tags=effective_tags,
selected_authors=selected_authors,
search=search,
drafts=drafts,
drafts_user_id=drafts_user_id,
exclude_covered_tag_ids=etc_mode_tag_ids,
)
# Get all post IDs in this batch
post_ids = [p["id"] for p in posts]
# Add is_liked field to each post for current user
if g.user:
# Fetch all likes for this user and these posts in one query
liked_posts = await session.execute(
select(PostLike.post_id).where(
PostLike.user_id == g.user.id,
PostLike.post_id.in_(post_ids),
PostLike.deleted_at.is_(None),
)
)
liked_post_ids = {row[0] for row in liked_posts}
# Add is_liked to each post
for post in posts:
post["is_liked"] = post["id"] in liked_post_ids
else:
# Not logged in - no posts are liked
for post in posts:
post["is_liked"] = False
# Fetch card decoration fragments from events
card_widgets_html = {}
if post_ids:
post_slugs = [p.get("slug", "") for p in posts]
cards_html = await fetch_fragment("events", "container-cards", params={
"post_ids": ",".join(str(pid) for pid in post_ids),
"post_slugs": ",".join(post_slugs),
})
if cards_html:
card_widgets_html = _parse_card_fragments(cards_html)
tags=await client.list_tags(
limit=50000
)
authors=await client.list_authors(
limit=50000
)
# Draft count for the logged-in user (None → admin sees all)
draft_count = 0
if count_drafts_for_user_id is not False:
draft_count = await client.count_drafts(user_id=count_drafts_for_user_id)
return {
"posts": posts,
"page": pagination.get("page", page),
"total_pages": pagination.get("pages", 1),
"search_count": pagination.get("search_count"),
"tags": tags,
"authors": authors,
"draft_count": draft_count,
"tag_groups": tag_groups,
"selected_groups": selected_groups,
"card_widgets_html": card_widgets_html,
}
# Regex to extract per-post blocks delimited by comment markers
_CARD_MARKER_RE = re.compile(
r'<!-- card-widget:(\d+) -->(.*?)<!-- /card-widget:\1 -->',
re.DOTALL,
)
def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict."""
result = {}
for m in _CARD_MARKER_RE.finditer(html):
post_id_str = m.group(1)
inner = m.group(2).strip()
if inner:
result[post_id_str] = inner
return result

View File

@@ -0,0 +1,120 @@
# suma_browser/webhooks.py
from __future__ import annotations
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,
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")
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 {}
return cur.get("id") or prev.get("id")
@csrf_exempt
@ghost_webhooks.route("/member/", methods=["POST"])
#@ghost_webhooks.post("/member/")
async def webhook_member() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "member")
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)
return Response(status=204)
@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")
# 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
@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")
# 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
@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 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
@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")
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"]
await sync_single_tag(g.s, ghost_id)
return Response(status=204)

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View File

@@ -0,0 +1,52 @@
"""Blog app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- nav-tree fragment ---
async def _nav_tree_handler():
app_name = request.args.get("app_name", "")
path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0]
menu_items = await get_navigation_tree(g.s)
return await render_template(
"fragments/nav_tree.html",
menu_items=menu_items,
frag_app_name=app_name,
frag_first_seg=first_seg,
)
_handlers["nav-tree"] = _nav_tree_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers
return bp

View File

@@ -0,0 +1,3 @@
from .routes import register
__all__ = ["register"]

View File

@@ -0,0 +1,213 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, jsonify, g
from shared.browser.app.authz import require_admin
from .services.menu_items import (
get_all_menu_items,
get_menu_item_by_id,
create_menu_item,
update_menu_item,
delete_menu_item,
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
async def get_menu_items_nav_oob():
"""Helper to generate OOB update for root nav menu items"""
menu_items = await get_all_menu_items(g.s)
nav_oob = await render_template(
"_types/menu_items/_nav_oob.html",
menu_items=menu_items,
)
return nav_oob
@bp.get("/")
@require_admin
async def list_menu_items():
"""List all menu items"""
menu_items = await get_all_menu_items(g.s)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/menu_items/index.html",
menu_items=menu_items,
)
else:
html = await render_template(
"_types/menu_items/_oob_elements.html",
menu_items=menu_items,
)
#html = await render_template("_types/root/settings/_oob_elements.html")
return await make_response(html)
@bp.get("/new/")
@require_admin
async def new_menu_item():
"""Show form to create new menu item"""
html = await render_template(
"_types/menu_items/_form.html",
menu_item=None,
)
return await make_response(html)
@bp.post("/")
@require_admin
async def create_menu_item_route():
"""Create a new menu item"""
form = await request.form
post_id = form.get("post_id")
if not post_id:
return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422
try:
post_id = int(post_id)
except ValueError:
return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422
try:
menu_item = await create_menu_item(g.s, post_id)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@bp.get("/<int:item_id>/edit/")
@require_admin
async def edit_menu_item(item_id: int):
"""Show form to edit menu item"""
menu_item = await get_menu_item_by_id(g.s, item_id)
if not menu_item:
return await make_response("Menu item not found", 404)
html = await render_template(
"_types/menu_items/_form.html",
menu_item=menu_item,
)
return await make_response(html)
@bp.put("/<int:item_id>/")
@require_admin
async def update_menu_item_route(item_id: int):
"""Update a menu item"""
form = await request.form
post_id = form.get("post_id")
if not post_id:
return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422
try:
post_id = int(post_id)
except ValueError:
return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422
try:
menu_item = await update_menu_item(g.s, item_id, post_id=post_id)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@bp.delete("/<int:item_id>/")
@require_admin
async def delete_menu_item_route(item_id: int):
"""Delete a menu item"""
success = await delete_menu_item(g.s, item_id)
if not success:
return await make_response("Menu item not found", 404)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
@bp.get("/pages/search/")
@require_admin
async def search_pages_route():
"""Search for pages to add as menu items"""
query = request.args.get("q", "").strip()
page = int(request.args.get("page", 1))
per_page = 10
pages, total = await search_pages(g.s, query, page, per_page)
has_more = (page * per_page) < total
html = await render_template(
"_types/menu_items/_page_search_results.html",
pages=pages,
query=query,
page=page,
has_more=has_more,
)
return await make_response(html)
@bp.post("/reorder/")
@require_admin
async def reorder_menu_items_route():
"""Reorder menu items"""
from .services.menu_items import reorder_menu_items
form = await request.form
item_ids_str = form.get("item_ids", "")
if not item_ids_str:
return jsonify({"message": "No items to reorder", "errors": {}}), 400
try:
item_ids = [int(id.strip()) for id in item_ids_str.split(",") if id.strip()]
except ValueError:
return jsonify({"message": "Invalid item IDs", "errors": {}}), 400
await reorder_menu_items(g.s, item_ids)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
return bp

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.models.menu_node import MenuNode
from models.ghost_content import Post
from shared.services.relationships import attach_child, detach_child
class MenuItemError(ValueError):
"""Base error for menu item service operations."""
async def get_all_menu_items(session: AsyncSession) -> list[MenuNode]:
"""
Get all menu nodes (excluding deleted), ordered by sort_order.
"""
result = await session.execute(
select(MenuNode)
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
.order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
)
return list(result.scalars().all())
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
"""Get a menu node by ID (excluding deleted)."""
result = await session.execute(
select(MenuNode)
.where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def create_menu_item(
session: AsyncSession,
post_id: int,
sort_order: int | None = None
) -> MenuNode:
"""
Create a MenuNode + ContainerRelation for a page.
If sort_order is not provided, adds to end of list.
"""
# Verify post exists and is a page
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
raise MenuItemError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# If no sort_order provided, add to end
if sort_order is None:
max_order = await session.scalar(
select(func.max(MenuNode.sort_order))
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
)
sort_order = (max_order or 0) + 1
# Check for duplicate (same page, not deleted)
existing = await session.scalar(
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError("Menu item for this page already exists.")
menu_node = MenuNode(
container_type="page",
container_id=post_id,
label=post.title,
slug=post.slug,
feature_image=post.feature_image,
sort_order=sort_order,
)
session.add(menu_node)
await session.flush()
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
return menu_node
async def update_menu_item(
session: AsyncSession,
item_id: int,
post_id: int | None = None,
sort_order: int | None = None
) -> MenuNode:
"""Update an existing menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
raise MenuItemError(f"Menu item {item_id} not found.")
if post_id is not None:
# Verify post exists and is a page
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
raise MenuItemError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# Check for duplicate (same page, different menu node)
existing = await session.scalar(
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.id != item_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError("Menu item for this page already exists.")
old_post_id = menu_node.container_id
menu_node.container_id = post_id
menu_node.label = post.title
menu_node.slug = post.slug
menu_node.feature_image = post.feature_image
if sort_order is not None:
menu_node.sort_order = sort_order
await session.flush()
if post_id is not None and post_id != old_post_id:
await detach_child(session, "page", old_post_id, "menu_node", menu_node.id)
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
return menu_node
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
return False
menu_node.deleted_at = func.now()
await session.flush()
await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id)
return True
async def reorder_menu_items(
session: AsyncSession,
item_ids: list[int]
) -> list[MenuNode]:
"""
Reorder menu nodes by providing a list of IDs in desired order.
Updates sort_order for each node.
"""
items = []
for index, item_id in enumerate(item_ids):
menu_node = await get_menu_item_by_id(session, item_id)
if menu_node:
menu_node.sort_order = index
items.append(menu_node)
await session.flush()
return items
async def search_pages(
session: AsyncSession,
query: str,
page: int = 1,
per_page: int = 10
) -> tuple[list[Post], int]:
"""
Search for pages (not posts) by title.
Returns (pages, total_count).
"""
filters = [
Post.is_page == True, # noqa: E712
Post.status == "published",
Post.deleted_at.is_(None)
]
if query:
filters.append(Post.title.ilike(f"%{query}%"))
# Get total count
count_result = await session.execute(
select(func.count(Post.id)).where(*filters)
)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
select(Post)
.where(*filters)
.order_by(Post.title.asc())
.limit(per_page)
.offset(offset)
)
pages = list(result.scalars().all())
return pages, total

View File

@@ -0,0 +1,688 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
Blueprint,
g,
request,
redirect,
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.utils import host_url
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.get("/")
@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
# Load features for page admin
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 ""
ctx = {
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
}
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/post/admin/index.html", **ctx)
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/admin/_oob_elements.html", **ctx)
return await make_response(html)
@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
from quart import jsonify
import json
post = g.post_data.get("post")
if not post or not post.get("is_page"):
return jsonify({"error": "This is not a page."}), 400
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"):
val = form.get(key)
if val is not None:
body[key] = val in ("true", "1", "on")
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
pc.features = features
from sqlalchemy.orm.attributes import flag_modified
flag_modified(pc, "features")
await g.s.flush()
# 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 "",
)
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
from quart import jsonify
post = g.post_data.get("post")
if not post or not post.get("is_page"):
return jsonify({"error": "This is not a page."}), 400
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)
if api_key:
pc.sumup_api_key = api_key
await g.s.flush()
features = pc.features or {}
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 "",
)
return await make_response(html)
@bp.get("/data/")
@require_admin
async def data(slug: str):
if not is_htmx_request():
html = await render_template(
"_types/post_data/index.html",
)
else:
html = await render_template(
"_types/post_data/_oob_elements.html",
)
return await make_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
"""Show calendar month view for browsing entries"""
from shared.models.calendars import Calendar
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
from shared.services.registry import services
from sqlalchemy import select
from datetime import datetime, timezone
import calendar as pycalendar
from quart import session as qsession
from ..services.entry_associations import get_post_entry_ids
# Get month/year from query params
today = datetime.now(timezone.utc).date()
month = parse_int_arg("month")
year = parse_int_arg("year")
if year is None:
year = today.year
if month is None or not (1 <= month <= 12):
month = today.month
# Load calendar
result = await g.s.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.deleted_at.is_(None))
)
calendar_obj = result.scalar_one_or_none()
if not calendar_obj:
return await make_response("Calendar not found", 404)
# Build calendar data
prev_month_year, prev_month = add_months(year, month, -1)
next_month_year, next_month = add_months(year, month, +1)
prev_year = year - 1
next_year = year + 1
weeks = build_calendar_weeks(year, month)
month_name = pycalendar.month_name[month]
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
# Get entries for this month
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
next_y, next_m = add_months(year, month, +1)
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
user = getattr(g, "user", None)
user_id = user.id if user else None
is_admin = bool(user and getattr(user, "is_admin", False))
session_id = qsession.get("calendar_sid")
month_entries = await services.calendar.visible_entries_for_period(
g.s, calendar_obj.id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
html = await render_template(
"_types/post/admin/_calendar_view.html",
calendar=calendar_obj,
year=year,
month=month,
month_name=month_name,
weekday_names=weekday_names,
weeks=weeks,
prev_month=prev_month,
prev_month_year=prev_month_year,
next_month=next_month,
next_month_year=next_month_year,
prev_year=prev_year,
next_year=next_year,
month_entries=month_entries,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@bp.get("/entries/")
@require_admin
async def entries(slug: str):
from ..services.entry_associations import get_post_entry_ids
from shared.models.calendars import Calendar
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
# Load ALL calendars (not just this post's calendars)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
if not is_htmx_request():
html = await render_template(
"_types/post_entries/index.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
else:
html = await render_template(
"_types/post_entries/_oob_elements.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
async def toggle_entry(slug: str, entry_id: int):
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
from shared.models.calendars import Calendar
from sqlalchemy import select
from quart import jsonify
post_id = g.post_data["post"]["id"]
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
if error:
return jsonify({"message": error, "errors": {}}), 400
await g.s.flush()
# Return updated association status
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
# Load ALL calendars
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, post_id)
# Load calendars for this post (for nav display)
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
admin_list = await render_template(
"_types/post/admin/_associated_entries.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
nav_entries_oob = await render_template(
"_types/post/admin/_nav_entries_oob.html",
associated_entries=associated_entries,
calendars=calendars,
post=g.post_data["post"],
)
return await make_response(admin_list + nav_entries_oob)
@bp.get("/settings/")
@require_post_author
async def settings(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
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"
if not is_htmx_request():
html = await render_template(
"_types/post_settings/index.html",
ghost_post=ghost_post,
save_success=save_success,
)
else:
html = await render_template(
"_types/post_settings/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
)
return await make_response(html)
@bp.post("/settings/")
@require_post_author
async def settings_save(slug: str):
from ...blog.ghost.ghost_posts import update_post_settings
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
updated_at = form.get("updated_at", "")
# Build kwargs — only include fields that were submitted
kwargs: dict = {}
# Text fields
for field in (
"slug", "custom_template", "meta_title", "meta_description",
"canonical_url", "og_image", "og_title", "og_description",
"twitter_image", "twitter_title", "twitter_description",
"feature_image_alt",
):
val = form.get(field)
if val is not None:
kwargs[field] = val.strip()
# Select fields
visibility = form.get("visibility")
if visibility is not None:
kwargs["visibility"] = visibility
# Datetime
published_at = form.get("published_at", "").strip()
if published_at:
kwargs["published_at"] = published_at
# Checkbox fields: present = True, absent = False
kwargs["featured"] = form.get("featured") == "on"
kwargs["email_only"] = form.get("email_only") == "on"
# Tags — comma-separated string → list of {"name": "..."} dicts
tags_str = form.get("tags", "").strip()
if tags_str:
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
else:
kwargs["tags"] = []
# Update in Ghost
await update_post_settings(
ghost_id=ghost_id,
updated_at=updated_at,
is_page=is_page,
**kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
@bp.get("/edit/")
@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
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()
if not is_htmx_request():
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
else:
html = await render_template(
"_types/post_edit/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
return await make_response(html)
@bp.post("/edit/")
@require_post_author
async def edit_save(slug: str):
import json
from ...blog.ghost.ghost_posts import update_post
from ...blog.ghost.lexical_validator import validate_lexical
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
title = form.get("title", "").strip()
lexical_raw = form.get("lexical", "")
updated_at = form.get("updated_at", "")
status = form.get("status", "draft")
publish_mode = form.get("publish_mode", "web")
newsletter_slug = form.get("newsletter_slug", "").strip() or None
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate the lexical JSON
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error="Invalid JSON in editor content.",
)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error=reason,
)
return await make_response(html, 400)
# Update in Ghost (content save — no status change yet)
ghost_post = await update_post(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=title or None,
updated_at=updated_at,
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
is_page=is_page,
)
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
publish_requested_msg = None
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
if already_emailed and publish_mode in ("email", "both"):
publish_mode = "web"
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
# Non-admin requesting publish: don't send status to Ghost, set local flag
publish_requested_msg = "Publish requested — an admin will review."
elif status and status != ghost_post.get("status"):
# Status is changing — determine email params based on publish_mode
email_kwargs: dict = {}
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
email_kwargs["newsletter_slug"] = newsletter_slug
email_kwargs["email_segment"] = "all"
if publish_mode == "email":
email_kwargs["email_only"] = True
from ...blog.ghost.ghost_posts import update_post as _up
ghost_post = await _up(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=None,
updated_at=ghost_post["updated_at"],
status=status,
is_page=is_page,
**email_kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Handle publish_requested flag on the local post
from models.ghost_content import Post
from sqlalchemy import select as sa_select
local_post = (await g.s.execute(
sa_select(Post).where(Post.ghost_id == ghost_id)
)).scalar_one_or_none()
if local_post:
if publish_requested_msg:
local_post.publish_requested = True
elif status == "published" and is_admin:
local_post.publish_requested = False
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
@bp.get("/markets/")
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from shared.services.registry import services
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
@bp.post("/markets/new/")
@require_admin
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return jsonify({"error": "Post not found"}), 404
form = await request.form
name = (form.get("name") or "").strip()
try:
await _create_market(g.s, post_id, name)
except MarketError as e:
return jsonify({"error": str(e)}), 400
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
@bp.delete("/markets/<market_slug>/")
@require_admin
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
deleted = await soft_delete_market(g.s, slug, market_slug)
if not deleted:
return jsonify({"error": "Market not found"}), 404
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
return bp

180
blog/bp/post/routes.py Normal file
View File

@@ -0,0 +1,180 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
g,
Blueprint,
abort,
url_for,
request,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.services.registry import services
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
bp.register_blueprint(
register_admin()
)
# Calendar blueprints now live in the events service.
# Post pages link to events_url() instead of embedding calendars.
@bp.url_value_preprocessor
def pull_blog(endpoint, values):
g.post_slug = values.get("slug")
@bp.before_request
async def hydrate_post_data():
slug = getattr(g, "post_slug", None)
if not slug:
return # not a blog route or no slug in this URL
is_admin = bool((g.get("rights") or {}).get("admin"))
# Always include drafts so we can check ownership below
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
return
# Access control for draft posts
if p_data["post"].get("status") != "published":
if is_admin:
pass # admin can see all drafts
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass # author can see their own drafts
else:
abort(404)
return
g.post_data = p_data
@bp.context_processor
async def context():
p_data = getattr(g, "post_data", None)
if p_data:
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = (g.post_data.get("post") or {}).get("id")
post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{config()['title']} {p_data['post']['title']}",
"container_nav_html": container_nav_html,
}
# Page cart badge via service
post_dict = p_data.get("post") or {}
if post_dict.get("is_page"):
ident = current_cart_identity()
page_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_dict["slug"],
)
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
return ctx
else:
return {}
@bp.get("/")
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/post/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/_oob_elements.html")
return await make_response(html)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from shared.utils import host_url
# Get post_id from g.post_data
if not g.user:
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=False,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
resp = make_response(html, 403)
return resp
post_id = g.post_data["post"]["id"]
user_id = g.user.id
liked, error = await toggle_post_like(g.s, user_id, post_id)
if error:
resp = make_response(error, 404)
return resp
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=liked,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
return html
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):
"""Proxies paginated widget requests to the appropriate fragment provider."""
page = int(request.args.get("page", 1))
post_id = g.post_data["post"]["id"]
if widget_domain == "calendar":
html = await fetch_fragment("events", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
"page": str(page),
"paginate_url": url_for(
'blog.post.widget_paginate',
slug=slug, widget_domain='calendar',
),
})
return await make_response(html or "")
abort(404)
return bp

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.registry import services
async def toggle_entry_association(
session: AsyncSession,
post_id: int,
entry_id: int
) -> tuple[bool, str | None]:
"""
Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message).
"""
post = await services.blog.get_post_by_id(session, post_id)
if not post:
return False, "Post not found"
is_associated = await services.calendar.toggle_entry_post(
session, entry_id, "post", post_id,
)
return is_associated, None
async def get_post_entry_ids(
session: AsyncSession,
post_id: int
) -> set[int]:
"""
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
return await services.calendar.entry_ids_for_content(session, "post", post_id)
async def get_associated_entries(
session: AsyncSession,
post_id: int,
page: int = 1,
per_page: int = 10
) -> dict:
"""
Get paginated associated entries for this post.
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
"""
entries, has_more = await services.calendar.associated_entries(
session, "post", post_id, page,
)
total_count = len(entries) + (page - 1) * per_page
if has_more:
total_count += 1 # at least one more
return {
"entries": entries,
"total_count": total_count,
"has_more": has_more,
"page": page,
}

View File

@@ -0,0 +1,61 @@
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.services.registry import services
class MarketError(ValueError):
"""Base error for market service operations."""
def slugify(value: str, max_len: int = 255) -> str:
if value is None:
value = ""
value = unicodedata.normalize("NFKD", value)
value = value.encode("ascii", "ignore").decode("ascii")
value = value.lower()
value = value.replace("/", "-")
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
value = value.strip("-")[:max_len].strip("-")
return value or "market"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
name = (name or "").strip()
if not name:
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = await services.blog.get_post_by_id(sess, post_id)
if not post:
raise MarketError(f"Post {post_id} does not exist.")
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"):
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
raise MarketError(str(e)) from e
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
if not post:
return False
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)

View File

@@ -0,0 +1,42 @@
from ...blog.ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from quart import g
async def post_data(slug, session, include_drafts=False):
client = DBClient(session)
posts = (await client.posts_by_slug(slug, include_drafts=include_drafts))
if not posts:
# 404 page (you can make a template for this if you want)
return None
post, original_post = posts[0]
# Check if current user has liked this post
is_liked = False
if g.user:
liked_record = await session.scalar(
select(PostLike).where(
PostLike.user_id == g.user.id,
PostLike.post_id == post["id"],
PostLike.deleted_at.is_(None),
)
)
is_liked = liked_record is not None
# Add is_liked to post dict
post["is_liked"] = is_liked
tags=await client.list_tags(
limit=50000
) # <-- new
authors=await client.list_authors(
limit=50000
)
return {
"post": post,
"original_post": original_post,
"tags": tags,
"authors": authors,
}

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, PostLike
async def toggle_post_like(
session: AsyncSession,
user_id: int,
post_id: int,
) -> tuple[bool, Optional[str]]:
"""
Toggle a post like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether post is now liked (True) or unliked (False).
"""
# Verify post exists
post_exists = await session.scalar(
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
)
if not post_exists:
return False, "Post not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(PostLike).where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(PostLike)
.where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = PostLike(
user_id=user_id,
post_id=post_id,
)
session.add(new_like)
return True, None

View File

@@ -0,0 +1,3 @@
from .routes import register
__all__ = ["register"]

107
blog/bp/snippets/routes.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from models import Snippet
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
async def _visible_snippets(session):
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return rows
def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.get("/")
@require_login
async def list_snippets():
"""List snippets visible to the current user."""
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
if not is_htmx_request():
html = await render_template(
"_types/snippets/index.html",
snippets=snippets,
is_admin=is_admin,
)
else:
html = await render_template(
"_types/snippets/_oob_elements.html",
snippets=snippets,
is_admin=is_admin,
)
return await make_response(html)
@bp.delete("/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
"""Delete a snippet. Owners delete their own; admins can delete any."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
abort(404)
is_admin = g.rights.get("admin")
if snippet.user_id != g.user.id and not is_admin:
abort(403)
await g.s.delete(snippet)
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=is_admin,
)
return await make_response(html)
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
async def patch_visibility(snippet_id: int):
"""Change snippet visibility. Admin only."""
if not g.rights.get("admin"):
abort(403)
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
abort(404)
form = await request.form
visibility = form.get("visibility", "").strip()
if visibility not in VALID_VISIBILITY:
abort(400)
snippet.visibility = visibility
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=True,
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,84 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
federation: "http://localhost:8004"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

32
blog/entrypoint.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
echo "Running Alembic migrations..."
(cd shared && alembic upgrade head)
fi
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushall()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}

14
blog/models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

View File

@@ -0,0 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike,
)

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
blog/models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
blog/models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
blog/models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
blog/models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
blog/models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

9
blog/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

28
blog/services/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""Blog app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Hello,
Click this link to sign in:
{{ link_url }}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,64 @@
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %}
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
{% set new_page_href = url_for('blog.new_page')|host %}
<a
href="{{ new_page_href }}"
hx-get="{{ new_page_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
title="New Page"
>
<i class="fa fa-plus mr-1"></i> New Page
</a>
{% endif %}
{% if g.user and (draft_count or drafts) %}
{% if drafts %}
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% else %}
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,80 @@
{% import 'macros/stickers.html' as stick %}
<article class="border-b pb-6 last:border-b-0 relative">
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
{% if g.user %}
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
<header class="mb-2 text-center">
<h2 class="text-4xl font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-2 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</header>
{% if post.feature_image %}
<div class="mb-4">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full object-cover"
>
</div>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.custom_excerpt }}
</p>
{% else %}
{% if post.excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.excerpt }}
</p>
{% endif %}
{% endif %}
</a>
{# Card decorations — via fragments #}
{% if card_widgets_html %}
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
{% if _card_html %}{{ _card_html | safe }}{% endif %}
{% endif %}
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row justify-center gap-3">
{% if post.tags %}
<div class="mt-4 flex items-center gap-2">
<div>in</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/tags.html' %}
</ul>
</div>
{% endif %}
<div></div>
{% if post.authors %}
<div class="mt-4 flex items-center gap-2">
<div>by</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/authors.html' %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,21 @@
{% macro author(author) %}
{% if author %}
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6"></div>
{# optional fallback circle with first letter
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div> #}
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ author.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{# --- AUTHORS LIST STARTS HERE --- #}
{% if post.authors and post.authors|length %}
{% for a in post.authors %}
{% for author in authors if author.slug==a.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
>
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- AUTHOR LIST ENDS HERE --- #}

View File

@@ -0,0 +1,19 @@
{% macro tag(tag) %}
{% if tag %}
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ tag.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,22 @@
{% macro tag_group(group) %}
{% if group %}
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,17 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# --- TAG LIST STARTS HERE --- #}
{% if post.tags and post.tags|length %}
{% for t in post.tags %}
{% for tag in tags if tag.slug==t.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
>
{{dotag.tag(tag)}}
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- TAG LIST ENDS HERE --- #}

View File

@@ -0,0 +1,59 @@
<article class="relative">
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
{% if post.feature_image %}
<div>
<img
src="{{ post.feature_image }}"
alt=""
class="w-full aspect-video object-cover"
>
</div>
{% endif %}
<div class="p-3 text-center">
<h2 class="text-lg font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-1 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.custom_excerpt }}
</p>
{% elif post.excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.excerpt }}
</p>
{% endif %}
</div>
</a>
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,111 @@
{% for post in posts %}
{% if view == 'tile' %}
{% include "_types/blog/_card_tile.html" %}
{% else %}
{% include "_types/blog/_card.html" %}
{% endif %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
{# Content type tabs: Posts | Pages #}
<div class="flex justify-center gap-1 px-3 pt-3">
{% set posts_href = (url_for('blog.index'))|host %}
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
<a
href="{{ posts_href }}"
hx-get="{{ posts_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
{{ 'bg-stone-700 text-white' if content_type != 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Posts</a>
<a
href="{{ pages_href }}"
hx-get="{{ pages_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
{{ 'bg-stone-700 text-white' if content_type == 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Pages</a>
</div>
{% if content_type == 'pages' %}
{# Pages listing #}
<div class="max-w-full px-3 py-3 space-y-3">
{% set page_num = page %}
{% include "_types/blog/_page_cards.html" %}
</div>
<div class="pb-8"></div>
{% else %}
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/blog/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/blog/_cards.html" %}
</div>
{% endif %}
<div class="pb-8"></div>
{% endif %}{# end content_type check #}

Some files were not shown because too many files have changed in this diff Show More