feat(billets): JWT admin surface + Companion authoring & image upload

Add packages/secubox-billets/api/routes/jwt_admin.py — a JWT-authed JSON
admin surface (secubox_core.auth.require_jwt: Bearer OR secubox_session
cookie) that reuses the same repo/media layer as the session HTML admin.
Endpoints: POST/PUT/DELETE /admin/api/billets, POST
/admin/api/billets/{id}/media (multipart, EXIF-strip via services.media),
GET /admin/api/comments, approve/delete comment moderation. No-op if
secubox_core is absent (isolated unit tests, session admin unaffected).

Wire it into api/main.py (register_jwt_admin after register_admin).

postinst: drop a sorted-last zz-secubox-system.pth into the venv so the
isolated billets venv can import the .deb-managed secubox_core without
shadowing its own fastapi/pydantic wheels.

Companion billets module: point EP map at /feed.json + /admin/api/*, add
an image file input to the editor and upload it after the billet is
created (POST /admin/api/billets/{id}/media, multipart). Fixes the "New
billet missing upload" gap. Bump service worker v6 -> v7.

Verified live on gk2: create -> feed, valid PNG upload -> {url,thumb},
delete — all 200.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-17 07:48:12 +02:00
parent b928ab71f7
commit db7fc2449e
5 changed files with 178 additions and 8 deletions

View File

@ -19,6 +19,7 @@ from fastapi.templating import Jinja2Templates
from . import repo
from .routes.admin import register_admin
from .routes.jwt_admin import register_jwt_admin
from .routes.public import (PCSRF_COOKIE, VISITOR_COOKIE, reactions_context,
register_public, _visitor)
from .services import antispam, feeds, media
@ -157,6 +158,7 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
except OSError:
pass
register_admin(app, templates)
register_jwt_admin(app) # JWT/SSO JSON surface for the SecuBox Companion
register_public(app, templates)
@app.middleware("http")

View File

@ -0,0 +1,144 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""JWT-authed JSON admin surface for the SecuBox Companion.
The HTML admin (routes/admin.py) is gated by billets' own session login
(argon2id + TOTP + CSRF). This parallel JSON surface lets the SecuBox Companion
which carries a SecuBox JWT / parent-domain `secubox_session` cookie create
billets, upload media, and moderate comments, reusing the SAME repo/media layer.
Auth is `secubox_core.auth.require_jwt` (accepts the Bearer token OR the session
cookie). If secubox_core is unavailable (isolated unit tests), the surface is not
registered and the session admin is unaffected.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, ValidationError
from .. import repo
from ..ids import new_ulid
from ..models import BilletIn
from ..services import media
try:
from secubox_core.auth import require_jwt
except Exception: # pragma: no cover - secubox_core absent in isolated tests
require_jwt = None
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
class BilletPayload(BaseModel):
body: str
ref_url: Optional[str] = None
embed_url: Optional[str] = None
style: str = "default"
status: str = "published" # "published" | "draft"
def _view(row) -> dict:
d = dict(row)
return {
"id": d.get("id"),
"slug": d.get("slug"),
"body": d.get("body"),
"status": d.get("status"),
"style": d.get("style"),
"ref_url": d.get("ref_url"),
"embed_url": d.get("embed_url"),
"published_at": d.get("published_at"),
"created_at": d.get("created_at"),
}
def register_jwt_admin(app: FastAPI) -> None:
"""Register the /admin/api/* JSON surface. No-op if secubox_core is absent."""
if require_jwt is None:
return
def _billet_in(p: BilletPayload) -> BilletIn:
try:
return BilletIn(body=p.body, ref_url=p.ref_url, embed_url=p.embed_url,
style=p.style, publish=(p.status == "published"))
except ValidationError as exc:
raise HTTPException(422, f"invalid billet: {exc.errors()}")
@app.post("/admin/api/billets")
async def api_create(request: Request, payload: BilletPayload, user=Depends(require_jwt)):
data = _billet_in(payload)
conn = request.app.state.conn
billet_id = await repo.create_billet(conn, data, now=_now())
row = await repo.get_by_id(conn, billet_id)
return {"success": True, **_view(row)}
@app.put("/admin/api/billets/{billet_id}")
async def api_update(request: Request, billet_id: str, payload: BilletPayload,
user=Depends(require_jwt)):
conn = request.app.state.conn
if await repo.get_by_id(conn, billet_id) is None:
raise HTTPException(404, "billet not found")
data = _billet_in(payload)
await repo.update_billet(conn, billet_id, body=data.body, ref_url=data.ref_url,
embed_url=data.embed_url, now=_now())
await repo.set_status(conn, billet_id,
"published" if data.publish else "draft", now=_now())
return {"success": True, **_view(await repo.get_by_id(conn, billet_id))}
@app.delete("/admin/api/billets/{billet_id}")
async def api_delete(request: Request, billet_id: str, user=Depends(require_jwt)):
conn = request.app.state.conn
if await repo.get_by_id(conn, billet_id) is None:
raise HTTPException(404, "billet not found")
await repo.delete_billet(conn, billet_id)
return {"success": True, "id": billet_id, "deleted": True}
@app.post("/admin/api/billets/{billet_id}/media")
async def api_media(request: Request, billet_id: str, file: UploadFile = File(...),
user=Depends(require_jwt)):
conn = request.app.state.conn
if await repo.get_by_id(conn, billet_id) is None:
raise HTTPException(404, "billet not found")
raw = await file.read()
if not raw:
raise HTTPException(400, "empty file")
try:
processed = await asyncio.to_thread(media.process, raw)
except media.MediaError as exc:
raise HTTPException(415, f"unsupported media: {exc}")
mid = new_ulid()
fn, thumb = await asyncio.to_thread(media.store, mid, processed)
await repo.add_media(conn, billet_id, filename=fn, thumb=thumb,
mime=processed["mime"], width=processed["width"],
height=processed["height"], alt="", now=_now(), ulid=mid)
return {"success": True, "id": mid, "url": f"/media/{fn}", "thumb": f"/media/{thumb}"}
@app.get("/admin/api/comments")
async def api_comments(request: Request, status: str = "pending",
user=Depends(require_jwt)):
rows = await repo.list_pending_comments(request.app.state.conn)
return {"comments": [dict(r) for r in rows]}
@app.post("/admin/api/comments/{comment_id}/approve")
async def api_comment_approve(request: Request, comment_id: str,
user=Depends(require_jwt)):
conn = request.app.state.conn
if await repo.get_comment(conn, comment_id) is None:
raise HTTPException(404, "comment not found")
await repo.moderate_comment(conn, comment_id, "approved")
return {"success": True, "id": comment_id, "status": "approved"}
@app.delete("/admin/api/comments/{comment_id}")
async def api_comment_delete(request: Request, comment_id: str,
user=Depends(require_jwt)):
conn = request.app.state.conn
if await repo.get_comment(conn, comment_id) is None:
raise HTTPException(404, "comment not found")
await repo.moderate_comment(conn, comment_id, "rejected")
return {"success": True, "id": comment_id, "status": "rejected"}

View File

@ -28,6 +28,15 @@ case "$1" in
"$PREFIX/venv/bin/pip" install --upgrade pip >/dev/null 2>&1 || true
"$PREFIX/venv/bin/pip" install -r "$PREFIX/requirements.txt" \
|| echo "billets: pip install failed — install deps into $PREFIX/venv then 'systemctl restart secubox-billets'" >&2
# Expose the system-installed secubox_core (shared lib, .deb-managed) to the
# isolated venv so the JWT admin surface (api/routes/jwt_admin.py) can import
# require_jwt. A sorted-last .pth APPENDS dist-packages: venv wheels keep
# precedence (fastapi/pydantic are NOT shadowed by the older system copies).
SITEPKG="$("$PREFIX/venv/bin/python" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null)"
if [ -n "$SITEPKG" ] && [ -d "$SITEPKG" ]; then
echo /usr/lib/python3/dist-packages > "$SITEPKG/zz-secubox-system.pth"
fi
chown -R secubox:secubox "$PREFIX"
# nginx must read the uvicorn socket (group secubox).

View File

@ -5,14 +5,17 @@
// exact box route/shape is unconfirmed it is marked TODO(api); the box may need
// a JWT-authed admin API for billets (its web admin is session-based). Reads use
// the public feed and work today; writes go through these paths + auto-queue.
// All routes below are served by billets' JWT/SSO admin surface (routes/jwt_admin.py),
// which trusts the SecuBox session — reads use the public JSON Feed.
const EP = {
feed: (b) => `${b}/feed.json`, // public JSON Feed (jsonfeed.org)
create: (b) => `${b}/admin/api/billets`, // TODO(api): confirm JWT create route
update: (b, id) => `${b}/admin/api/billets/${id}`, // TODO(api)
remove: (b, id) => `${b}/admin/api/billets/${id}`, // TODO(api)
comments: (b) => `${b}/admin/api/comments?status=pending`, // TODO(api)
cApprove: (b, id) => `${b}/admin/api/comments/${id}/approve`, // TODO(api)
cDelete: (b, id) => `${b}/admin/api/comments/${id}`, // TODO(api)
feed: (b) => `${b}/feed.json`, // public JSON Feed (jsonfeed.org)
create: (b) => `${b}/admin/api/billets`, // POST {body,ref_url,embed_url,style,status}
update: (b, id) => `${b}/admin/api/billets/${id}`, // PUT
remove: (b, id) => `${b}/admin/api/billets/${id}`, // DELETE
media: (b, id) => `${b}/admin/api/billets/${id}/media`, // POST multipart file (EXIF-stripped server-side)
comments: (b) => `${b}/admin/api/comments?status=pending`, // GET
cApprove: (b, id) => `${b}/admin/api/comments/${id}/approve`, // POST
cDelete: (b, id) => `${b}/admin/api/comments/${id}`, // DELETE
};
export default async function mount(ctx) {
@ -84,6 +87,8 @@ export default async function mount(ctx) {
styleIn.value = seed.style || 'default';
const statusIn = el('select', {}, [opt('published', 'Publish'), opt('draft', 'Save as draft')]);
statusIn.value = seed.status || 'published';
// Image attachment — the box re-encodes + strips EXIF server-side (services.media).
const imgIn = el('input', { type: 'file', accept: 'image/*' });
const save = el('button.btn.primary', {
text: id ? 'Update billet' : 'Publish billet',
@ -93,6 +98,15 @@ export default async function mount(ctx) {
save.disabled = true;
try {
const r = id ? await api.put(EP.update(base, id), payload) : await api.post(EP.create(base), payload);
const bid = id || r.id || (r.billet && r.billet.id);
// Upload the image AFTER the billet exists (media attaches to a billet id).
const file = imgIn.files && imgIn.files[0];
if (file && bid) {
try {
const fd = new FormData(); fd.append('file', file);
await api.post(EP.media(base, bid), fd, { form: true });
} catch (e) { toast('Billet saved — image upload failed: ' + e.message, 'err'); }
}
toast(r.queued ? 'Saved offline — will sync' : (id ? 'Updated ✓' : 'Published ✓'));
active = ''; renderTabs(); list();
} catch (e) { toast('Save failed: ' + e.message, 'err'); save.disabled = false; }
@ -104,6 +118,7 @@ export default async function mount(ctx) {
el('label', { text: 'Body' }), bodyIn,
el('label', { text: 'Source URL' }), refIn,
el('label', { text: 'Embed URL' }), embedIn,
el('label', { text: 'Image (optional)' }), imgIn,
el('div.row', { style: 'gap:12px' }, [
el('div.stack', { style: 'flex:1' }, [el('label', { text: 'Style' }), styleIn]),
el('div.stack', { style: 'flex:1' }, [el('label', { text: 'Status' }), statusIn]),

View File

@ -7,7 +7,7 @@
// Same-origin app assets → stale-while-revalidate. Box API (cross-origin) →
// passthrough (the page handles offline reads/queue).
const VERSION = 'sbx-companion-v6';
const VERSION = 'sbx-companion-v7';
const SHELL = [
'./', './index.html', './manifest.webmanifest',
'./styles/charte.css', './styles/fonts.css',