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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user