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
+3
View File
@@ -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",
+158
View File
@@ -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 <unit>/<queue-file-stem>, stable across body
-- edits, so a redrafted post is one row. The image is copied, content-addressed
-- at /data/post-images/<sha256>.<ext>, 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 <unit>/<queue-file-stem>")
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