mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
b928ab71f7
commit
db7fc2449e
|
|
@ -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")
|
||||
|
|
|
|||
144
packages/secubox-billets/api/routes/jwt_admin.py
Normal file
144
packages/secubox-billets/api/routes/jwt_admin.py
Normal 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"}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user