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:
@@ -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