Skip to content

Add moderated home image uploads - #3

Merged
DJAscendance merged 6 commits into
masterfrom
feature/home-image-moderation
Jul 17, 2026
Merged

Add moderated home image uploads#3
DJAscendance merged 6 commits into
masterfrom
feature/home-image-moderation

Conversation

@DJAscendance

@DJAscendance DJAscendance commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Adds the home-image review workflow used by the Block Tools CHECK feature.

Homeowners can upload an image, but it stays private and shows a “NOT CHECKED!” placeholder until a staff member approves it. Rejected images are removed and must be uploaded again.

What changed

  • Added moderation status, reviewer, timestamp, and revision fields to home images.
  • Stored pending uploads outside the public asset tree.
  • Added authenticated moderator previews and approve/reject endpoints.
  • Added the CHECK queue and connected it to Block Tools.
  • Added revision-bound approval so a moderator can only publish the exact image they reviewed.
  • Serialized upload, approval, rejection, removal, and reset operations with per-home database row locks.
  • Added state-guarded cleanup so an older request cannot delete files created by a newer operation.
  • Added unit coverage and an HTTP race-test harness for the upload and moderation lifecycle.

Behavior

  • Pending images are not publicly accessible.
  • Approved images are promoted to the normal public home-image path.
  • Replacing an image sends the new revision back through moderation.
  • Stale approve or reject actions return a conflict instead of acting on a replacement image.
  • Any current staff member or admin may review pending images in this first version; place-scoped moderation can be added later.

Verification

  • API typecheck and SPA build passed.
  • Migrations and rollback/reapply behavior were tested.
  • Authorization and private-storage boundaries were verified.
  • Unit tests passed 8/8.
  • Independent concurrency QA reproduced the old vulnerable behavior, then passed the repaired implementation with no invariant violations.

DJAscendance and others added 2 commits July 17, 2026 08:03
Recreates the classic Cybertown image-check flow: an uploaded home image is
held 'pending' and hidden behind a 'NOT CHECKED!' placeholder until a Block
Leader / Deputy / admin approves it via the CHECK tool.

Backend:
- Migration adds image_status/image_checked_by/image_checked_at to home
  (existing images grandfathered to 'approved').
- uploadHomeImage sets status 'pending'; reset/remove clear it.
- GET /home/:username only exposes the real image once approved.
- New moderator endpoints (gated by canStaff/canAdmin):
  GET /home/moderation/queue, POST /home/moderation/:placeId/approve,
  POST /home/moderation/:placeId/reject (deletes the file).

Frontend:
- main2d.vue shows the NOT CHECKED! placeholder for pending images.
- New HomeImageCheckPage lists the pending queue with Approve/Reject.
- Wires the previously-inert CHECK button in BlockTools to open it.

Also gitignores spa/assets/homes-uploads (runtime user uploads), matching
the existing spa/assets/object ignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(QA finding)

Blocking finding: pending (unapproved) uploads were written to
spa/assets/homes-uploads, which nginx serves publicly, so anyone could fetch an
unchecked image directly at /assets/homes-uploads/<placeId>.webp - bypassing
moderation entirely even though the public home API hid it.

Storage redesign:
- Pending uploads now go to a private directory outside every nginx-served path
  (PRIVATE_UPLOADS_DIR, default /usr/src/app/private-uploads/homes-pending),
  gitignored and created on demand. Approved images live in the public dir as before.
- Approval promotes the file from private -> public (rename, with an EXDEV copy+unlink
  fallback for separate mounts). The record is flipped to approved via an atomic
  UPDATE ... WHERE image_status='pending' so concurrent/duplicate approvals cannot both
  proceed; the file only becomes public after that claim succeeds, and a failed move
  reverts to pending - the image is never publicly readable while still marked pending.
- Rejection/removal/reset delete the file from both directories (derived from the numeric
  place id, so no path traversal) and clear the DB fields; rejection uses the same atomic
  claim. Missing files are tolerated; re-upload after rejection works.
- Replacing an approved image with a new upload removes the old public file and holds the
  new one privately/pending, so it is not exposed until re-approved.

Authenticated preview endpoint:
- New GET /home/moderation/:placeId/image streams the pending image to a moderator only:
  401 unauthenticated, 403 non-moderator, 404 when absent, image/webp with no-store, and
  resolved from a validated numeric id (never a client path).
- The CHECK queue now loads previews through this endpoint as authenticated blobs (the
  apiToken header cannot ride on a plain <img src>), and the queue payload no longer
  exposes a public URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DJAscendance
DJAscendance force-pushed the feature/home-image-moderation branch from c69be8b to 255b008 Compare July 17, 2026 12:33
DJAscendance and others added 4 commits July 17, 2026 10:14
…ass race)

Pending home images were all written to a single shared private filename
(<placeId>.webp), and approval published "whatever is currently pending" rather
than the exact image the moderator reviewed. So an approval begun for image A
could publish a different, unchecked image B that had replaced A under that
shared name - deterministically (review A -> owner swaps to B -> approve) and
under a concurrent approve/upload race. Anonymous users could then fetch B.

Fix - revision-bound uploads serialized by a per-home row lock:
- Add home.image_revision: every upload gets a fresh unguessable token and its
  own private file <placeId>-<revision>.webp, so a replacement upload never
  overwrites the file an in-flight approval is reading. The public file stays the
  canonical <placeId>.webp (stable URL).
- All image mutations (upload/approve/reject/remove/reset) run inside a
  transaction that first takes SELECT ... FOR UPDATE on the home row, serializing
  them across processes.
- Approve/reject are bound to the revision the moderator reviewed (sent from the
  queue). If the current revision no longer matches, the API returns 409 and
  nothing is published. Approval publishes the reviewed revision via an atomic
  temp-then-rename into the public dir; the private copy is removed after commit.

Net invariant: the public file only ever contains an approved revision's bytes;
an unchecked upload can never be promoted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Unit spec (home.service.spec.ts): approval/rejection refuse a revision that no
  longer matches the reviewed one (or is absent), and write nothing.
- Integration QA script (api/qa/home-image-moderation-race.sh): races approve(A)
  vs upload(B) and the review->swap->approve case over many iterations, asserting
  the unchecked replacement is never publicly reachable (409 on stale approve).
- CLAUDE.md: correct the stale note that all home images live under
  spa/assets/homes-uploads (only approved do; pending live in the private
  PRIVATE_UPLOADS_DIR), and document the concurrency contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ommit races)

The row lock only protects work done while its transaction is open. Several filesystem
cleanups ran AFTER the transaction committed and the lock was released, so in a
multi-process deployment they were not serialized against a later operation. Follow-up
review found:

- Upload / remove / reset / reject deleted the shared canonical PUBLIC file after commit,
  outside the lock. A concurrent operation that committed in the gap (e.g. an approval that
  just published an image) could have its public file deleted by the earlier request.
- remove / reset used a wildcard delete of every "<placeId>-*.webp" pending file, which
  could wipe a concurrent upload's freshly-written file, leaving a pending record with no
  file (broken preview, un-approvable).
- approveHomeImage published the public file inside the transaction; if the commit then
  failed, the rollback left the image public while the record was still pending.
- approve's post-commit unlink was not wrapped, so a transient FS error returned HTTP 400
  even though the approval had already committed and published.
- Legacy images left pending before the image_revision migration had a NULL revision and
  became permanently un-moderatable (stuck in the queue).

Fixes:
- deletePublicImageIfState(): re-acquires the home row lock and deletes the canonical public
  file ONLY while the record still holds the exact (status, revision) the caller committed,
  so an old request can never clobber a newer operation's public file.
- remove / reset capture the exact revision under the lock and delete only that immutable
  private file (no wildcard). Reject/approve delete only their captured revision.
- approveHomeImage compensates on rollback: if publishing wrote the public file but the
  commit failed, the (state-guarded) public file is removed. Post-commit unlinks are now
  best-effort and never fail an already-committed operation.
- publishApprovedImage sweeps stale ".tmp-<placeId>-*" staging files from the public dir.
- Migration backfills legacy NULL-revision pending rows to "no image" so the queue is never
  stuck (unchecked images are never exposed; owner re-uploads).

Adds hermetic unit tests for the state-guarded cleanup / capture / rollback compensation,
and extends the QA race script with the post-commit cleanup scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation

Record the design rule that prevented the post-commit races: after releasing the home row
lock, cleanup must be revision-specific or routed through the state-guarded
deletePublicImageIfState(), never an unguarded delete of the shared public path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DJAscendance
DJAscendance changed the base branch from local-testing to master July 17, 2026 16:07
@DJAscendance
DJAscendance merged commit 2e96fa2 into master Jul 17, 2026
@DJAscendance DJAscendance changed the title Add home image moderation (Block Leader CHECK workflow) Add moderated home image uploads Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant