From 55e4c67b9a2e0b54c5dcc33d08f8841625cd906a Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 19:46:44 -0400 Subject: [PATCH] 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 /, 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/., 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. --- app/admin_api.py | 80 ++++++++++++++++++++++ app/identity.py | 3 + app/store.py | 158 +++++++++++++++++++++++++++++++++++++++++++ tests/smoke_admin.py | 29 ++++++++ 4 files changed, 270 insertions(+) diff --git a/app/admin_api.py b/app/admin_api.py index 979f26f..e0983b3 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -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, + 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 diff --git a/app/identity.py b/app/identity.py index 2cc1715..14eb749 100644 --- a/app/identity.py +++ b/app/identity.py @@ -78,6 +78,7 @@ CAPS = { "calendar:write", "unit:write_own", "roster:write", + "fbposts:read", "apikeys:own", "api:docs", "email:draft", @@ -91,6 +92,8 @@ CAPS = { "calendar:write", "unit:write_own", "roster:write", + "fbposts:read", + "fbposts:ingest", "units:write", "settings:write", "apikeys:own", diff --git a/app/store.py b/app/store.py index bcfbee9..48b5030 100644 --- a/app/store.py +++ b/app/store.py @@ -28,6 +28,7 @@ Stdlib only - sqlite3 ships with Python, so this adds no image dependencies. """ import json +import hashlib import os import sqlite3 import uuid @@ -144,6 +145,38 @@ CREATE TABLE IF NOT EXISTS roster_checks ( PRIMARY KEY (scout_id, year, item) ); +-- Facebook posts (ops/open-items.md section 12, built 2026-09-04). scout-publisher +-- reports every post it drafts, schedules, holds or cancels by POSTing here; it +-- never opens this file. id is /, stable across body +-- edits, so a redrafted post is one row. The image is copied, content-addressed +-- at /data/post-images/., hash verified on arrival; image_ref is +-- provenance only (the NAS path it came from). cancel_url is the publisher's +-- own signed per-post link, so the panel gets Cancel with no new secret. +CREATE TABLE IF NOT EXISTS fb_posts ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + page_id TEXT, + fb_post_id TEXT, + status TEXT NOT NULL, + message TEXT, + link TEXT, + scheduled_for TEXT, + queue_file TEXT, + cancel_url TEXT, + image_sha256 TEXT, + image_ref TEXT, + image_mime TEXT, + image_bytes INTEGER, + image_width INTEGER, + image_height INTEGER, + reported_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + cancelled_at TEXT, + cancelled_by TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_fb_posts_fbid ON fb_posts(fb_post_id) WHERE fb_post_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_fb_posts_sched ON fb_posts(status, scheduled_for); + CREATE TABLE IF NOT EXISTS mirrors ( record_id TEXT NOT NULL, target TEXT NOT NULL, @@ -1070,3 +1103,128 @@ def households_for_person(person_id, year): finally: con.close() return [h for h in (get_household(i, year) for i in ids) if h and h.get("active")] + + +# --------------------------------------------------------------------------- +# Facebook posts. See the schema comment. +# --------------------------------------------------------------------------- + +class FbRejected(Rejected): + pass + + +FB_STATUSES = ("drafted", "scheduled", "handed_off", "cancelled", "published", "failed") +IMAGE_DIR = DB_PATH.parent / "post-images" +IMAGE_MIMES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"} + + +def upsert_fb_post(rec, image=None): + """Record what the publisher reports. `image` is (bytes, mime, claimed_sha256) + or None; the hash is recomputed here and a mismatch is refused, so the row + never claims a version of the image nobody sent.""" + pid = (rec.get("id") or "").strip() + if not pid or "/" not in pid: + raise FbRejected(422, "id must be /") + status = (rec.get("status") or "").strip() + if status not in FB_STATUSES: + raise FbRejected(422, "status must be one of %s" % (FB_STATUSES,)) + unit = (rec.get("unit") or pid.split("/", 1)[0]).strip() + img = {} + if image is not None: + data, mime, claimed = image + if mime not in IMAGE_MIMES: + raise FbRejected(422, "image mime must be one of %s" % sorted(IMAGE_MIMES)) + if len(data) > 15 * 1024 * 1024: + raise FbRejected(413, "image over 15 MB") + actual = hashlib.sha256(data).hexdigest() + if claimed and claimed.lower() != actual: + raise FbRejected(422, "image sha256 mismatch: sent %s, is %s" % (claimed, actual)) + IMAGE_DIR.mkdir(parents=True, exist_ok=True) + dest = IMAGE_DIR / ("%s.%s" % (actual, IMAGE_MIMES[mime])) + if not dest.exists(): + tmp = dest.with_suffix(".tmp") + tmp.write_bytes(data); tmp.replace(dest) + w, h = _image_size(data, mime) + img = {"image_sha256": actual, "image_mime": mime, "image_bytes": len(data), "image_width": w, "image_height": h} + con = connect() + try: + old = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone() + fields = {"unit": unit, "page_id": rec.get("page_id"), "fb_post_id": (rec.get("fb_post_id") or None), + "status": status, "message": rec.get("message"), "link": rec.get("link"), + "scheduled_for": rec.get("scheduled_for"), "queue_file": rec.get("queue_file"), + "cancel_url": rec.get("cancel_url"), "image_ref": rec.get("image_ref"), "updated_at": _now()} + fields.update(img) + if status == "cancelled" and not (old and old["cancelled_at"]): + fields["cancelled_at"] = rec.get("cancelled_at") or _now() + fields["cancelled_by"] = rec.get("cancelled_by") or "publisher" + if old: + if fields.get("fb_post_id") is None: + fields.pop("fb_post_id") + sets = ", ".join("%s=?" % k for k in fields) + con.execute("UPDATE fb_posts SET %s WHERE id=?" % sets, (*fields.values(), pid)) + else: + cols = ["id", "reported_at"] + list(fields) + con.execute("INSERT INTO fb_posts (%s) VALUES (%s)" % (", ".join(cols), ",".join("?" * len(cols))), + (pid, _now(), *fields.values())) + con.commit() + return dict(con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone()) + finally: + con.close() + + +def _image_size(data, mime): + """Width and height from the header, no image library. None if unsure.""" + try: + if mime == "image/png" and data[:8] == b"\x89PNG\r\n\x1a\n": + return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") + if mime == "image/jpeg": + i = 2 + while i < len(data) - 9: + if data[i] != 0xFF: + i += 1; continue + marker = data[i + 1] + if marker in (0xC0, 0xC1, 0xC2): + return int.from_bytes(data[i + 7:i + 9], "big"), int.from_bytes(data[i + 5:i + 7], "big") + i += 2 + int.from_bytes(data[i + 2:i + 4], "big") + except Exception: + pass + return None, None + + +def list_fb_posts(limit=100, include_done=True): + con = connect() + try: + sql = "SELECT * FROM fb_posts" + if not include_done: + sql += " WHERE status IN ('drafted','scheduled','handed_off')" + sql += " ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?" + return [dict(r) for r in con.execute(sql, (max(1, min(int(limit), 500)),))] + finally: + con.close() + + +def get_fb_post(pid): + con = connect() + try: + r = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone() + return dict(r) if r else None + finally: + con.close() + + +def mark_fb_cancelled(pid, by): + con = connect() + try: + con.execute("UPDATE fb_posts SET status='cancelled', cancelled_at=?, cancelled_by=?, updated_at=? WHERE id=?", + (_now(), by, _now(), pid)) + con.commit() + return dict(con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone()) + finally: + con.close() + + +def image_path(rec): + if not rec or not rec.get("image_sha256"): + return None + p = IMAGE_DIR / ("%s.%s" % (rec["image_sha256"], IMAGE_MIMES.get(rec.get("image_mime"), "bin"))) + return p if p.exists() else None diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 7429456..194e465 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -378,6 +378,35 @@ check("an inactive household drops off the family view", S.households_for_person check("unknown household is None", S.set_household_people("nope", ["p-x"]) is None) con = S.connect(); con.execute("DELETE FROM household_people WHERE household_id=?", (hid,)); con.execute("DELETE FROM scouts WHERE household_id=?", (hid,)); con.execute("DELETE FROM households WHERE id=?", (hid,)); con.commit(); con.close() +print("\nfacebook posts") +import hashlib as _h, struct as _st, zlib as _z +def _png(w, h): + def chunk(t, d): return _st.pack(">I", len(d)) + t + d + _st.pack(">I", _z.crc32(t + d) & 0xffffffff) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", _st.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IEND", b"") +png = _png(640, 480); sha = _h.sha256(png).hexdigest() +raises("id shape", 422, S.FbRejected, S.upsert_fb_post, {"id": "nope", "status": "scheduled"}) +raises("status vocabulary", 422, S.FbRejected, S.upsert_fb_post, {"id": "pack-73/x", "status": "posted"}) +raises("image hash mismatch refused", 422, S.FbRejected, S.upsert_fb_post, {"id": "pack-73/x", "status": "scheduled"}, (png, "image/png", "deadbeef")) +raises("image mime", 422, S.FbRejected, S.upsert_fb_post, {"id": "pack-73/x", "status": "scheduled"}, (png, "image/gif", sha)) +r = S.upsert_fb_post({"id": "pack-73/2026-09-10-hike", "status": "scheduled", "page_id": "141", "fb_post_id": "141_9", + "message": "Hike Saturday", "scheduled_for": "2026-09-10T12:00:00+00:00", + "cancel_url": "https://x.test/cancel?id=141_9&sig=abc", "image_ref": "scouting-comms/generated/hike.png"}, + (png, "image/png", sha)) +check("row stored with content-addressed image and size", r["image_sha256"] == sha and r["image_width"] == 640 and r["image_height"] == 480 + and S.image_path(r) and S.image_path(r).name == sha + ".png") +r2 = S.upsert_fb_post({"id": "pack-73/2026-09-10-hike", "status": "scheduled", "message": "Hike Saturday, 1 PM"}) +check("same id is one row, updated, image kept", r2["message"].endswith("1 PM") and r2["image_sha256"] == sha and r2["fb_post_id"] == "141_9") +check("listed, open-only filter", any(p["id"] == "pack-73/2026-09-10-hike" for p in S.list_fb_posts(include_done=False))) +c = S.mark_fb_cancelled("pack-73/2026-09-10-hike", "mike@example.test") +check("cancel recorded", c["status"] == "cancelled" and c["cancelled_by"] == "mike@example.test" + and not any(p["id"] == c["id"] for p in S.list_fb_posts(include_done=False))) +r3 = S.upsert_fb_post({"id": "troop-73/2026-09-12-canoe", "status": "cancelled", "cancelled_by": "ntfy button"}) +check("publisher-reported cancel stamps cancelled_at", r3["cancelled_at"] and r3["cancelled_by"] == "ntfy button") +check("fbposts caps: read for leaders, ingest admin-only and scopable", "fbposts:read" in I.CAPS["leader"] and "fbposts:ingest" in I.CAPS["admin"] + and "fbposts:ingest" not in I.KEY_UNSCOPABLE) +con = S.connect(); con.execute("DELETE FROM fb_posts"); con.commit(); con.close() +import os as _os; _os.remove(S.image_path(r)) + print("\napi docs registry") import admin_api as A reg = A.describe_routes()