facebook posts: fb_posts table, ingest with hash-verified images, gated image, cancel

Open item 12, designed 2026-08-26, built today. scout-publisher reports
every post it drafts, schedules, holds or cancels by POSTing here; it
never opens the database. id is <unit>/<queue-stem>, stable across body
edits, so a redrafted post is one row and cancel is an indexed lookup on
fb_post_id. The image is copied, content-addressed at
/data/post-images/<sha256>.<ext>, and the sha256 is recomputed on arrival
- a mismatch is refused, so a row never claims a version nobody sent.
image_ref keeps the NAS path as provenance. The image route runs the same
capability check as the list on every request. Cancel goes through the
publisher's own signed per-post link stored on the row; the site never
holds the publisher's secret. fbposts:read for leaders, fbposts:ingest
for admins and scopable so the publisher's key carries exactly that.

tests/smoke_admin.py 147 -> 157.
This commit is contained in:
2026-09-04 19:46:44 -04:00
parent 7f04d57665
commit 55e4c67b9a
4 changed files with 270 additions and 0 deletions
+80
View File
@@ -962,3 +962,83 @@ def put_check(request: Request, sid: str, item: str, payload: dict = Body(...),
raise HTTPException(404, "no such scout")
_log(request, "roster.check", "%s %s %s -> %s" % (sid, _year(year), item, "done" if payload.get("done") else "cleared"))
return rec
# ----------------------------------------------------------------------------
# Facebook posts (open item 12). The publisher reports; leaders read and
# cancel. Ingest is fbposts:ingest - the scope a script key carries -
# and the image is hash-verified on arrival. The image route runs the same
# capability check as the page, on every request, per the design note:
# a gated page whose images are served unchecked is the failure to avoid.
# ----------------------------------------------------------------------------
@router.get("/fbposts")
def list_fb_posts(request: Request, include_done: bool = True, limit: int = Query(100, ge=1, le=500),
x_admin_token: str = Header(None)):
"""Every post the publisher has reported, newest scheduled first, with
status (drafted, scheduled, handed_off, cancelled, published, failed)."""
_auth(request, x_admin_token, "fbposts:read")
return {"posts": store.list_fb_posts(limit=limit, include_done=include_done)}
@router.post("/fbposts/ingest")
def ingest_fb_post(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""scout-publisher reports a post. Body: id (<unit>/<queue-stem>), unit,
status, page_id, fb_post_id, message, link, scheduled_for, queue_file,
cancel_url, image_ref, and optionally image {b64, mime, sha256}. The
sha256 is recomputed here; a mismatch is refused."""
_auth(request, x_admin_token, "fbposts:ingest")
image = None
img = payload.get("image")
if img and img.get("b64"):
import base64 as _b64
try:
data = _b64.b64decode(img["b64"], validate=True)
except Exception:
raise HTTPException(422, "image.b64 is not valid base64")
image = (data, img.get("mime") or "", img.get("sha256") or "")
try:
rec = store.upsert_fb_post(payload, image)
except store.FbRejected as e:
raise _reject(e)
_log(request, "fbpost.reported", "%s %s%s" % (rec["id"], rec["status"], " +image" if image else ""))
return rec
@router.get("/fbposts/{pid:path}/image")
def fb_post_image(request: Request, pid: str, x_admin_token: str = Header(None)):
"""The stored image, behind the same gate as the list."""
from fastapi.responses import FileResponse
_auth(request, x_admin_token, "fbposts:read")
rec = store.get_fb_post(pid)
path = store.image_path(rec)
if not path:
raise HTTPException(404, "no image")
return FileResponse(str(path), media_type=rec["image_mime"], headers={"Cache-Control": "private, max-age=86400, immutable"})
@router.post("/fbposts/{pid:path}/cancel")
def cancel_fb_post(request: Request, pid: str, x_admin_token: str = Header(None)):
"""Cancel a scheduled post through the publisher's own signed link, then
record it here. The site never holds the publisher's secret."""
actor = _auth(request, x_admin_token, "fbposts:read")
rec = store.get_fb_post(pid)
if not rec:
raise HTTPException(404, "no such post")
if rec["status"] not in ("scheduled",):
raise HTTPException(409, "post is %s, not scheduled" % rec["status"])
if not rec.get("cancel_url"):
raise HTTPException(409, "no cancel link was recorded for this post")
import urllib.request as _ur, urllib.error as _ue
try:
with _ur.urlopen(_ur.Request(rec["cancel_url"], headers={"User-Agent": "scout-website"}), timeout=15) as resp:
status = resp.status
except _ue.HTTPError as e:
raise HTTPException(502, "publisher answered %d on cancel" % e.code)
except Exception as e:
raise HTTPException(502, "publisher unreachable: %s" % e)
if status >= 300:
raise HTTPException(502, "publisher answered %d on cancel" % status)
out = store.mark_fb_cancelled(pid, actor)
_log(request, "fbpost.cancelled", pid)
return out