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