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