Skip to content

feat: CMS-owned footer, public-site endpoints, and classifieds moderation - #130

Merged
ssavutu merged 4 commits into
mainfrom
feat/cms-owned-footer-and-classifieds
Aug 1, 2026
Merged

feat: CMS-owned footer, public-site endpoints, and classifieds moderation#130
ssavutu merged 4 commits into
mainfrom
feat/cms-owned-footer-and-classifieds

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 1, 2026

Copy link
Copy Markdown
Member

Moves three reader-facing things off the legacy WordPress install and into the CMS. Paired with Scalene CMS-Testing (already pushed), which consumes all of it.

Four commits, each self-contained and building on its own.

1. feat(settings) — CMS-owned footer menu

The public site's six footer columns were hardcoded, so changing a link meant editing Astro and redeploying. Stored as JSON in cms_settings, served from GET /v1/settings/footer, admin PATCH, with an editor on the settings screen.

A column is a flat ordered list of typed entries (heading / link / spacer) rather than a heading with children — the live footer stacks two bolded groups in one column ("Columns" under "Opinion", "Special Editions" under "Comics & Puzzles"), which a single-heading shape can't express.

The stored default is the footer already shipped, so an untouched install serves exactly what it served before. A save that normalizes to nothing reverts to that default — the footer can't be blanked by a bad write.

2. feat(public) — gallery, random, sitemap

Endpoint Replaces
GET /v1/gallery /wp-json/triangle/v1/gallery
GET /v1/articles/random /wp-json/triangle/v1/random/
GET /v1/sitemap/slugs /wp-json/triangle/v2/sitemap-slugs

Gallery is a separate endpoint rather than opening up /v1/media/gallery: it's unauthenticated, so it's narrowed to images (which Nginx already serves publicly off CephFS) and drops the free-text search param, which would otherwise make it a lookup tool over editors' filenames and captions. The editor-facing catalogue stays authenticated.

3. feat(classifieds) — moderation queue + signed Slack approvals

Classifieds were the last reader-facing flow on WordPress, and the Slack message was the submission — the whole classified rode along base64-encoded in the button value, so anything unclicked or scrolled past was lost with no record.

Submissions now persist as pending on arrival. Approving happens either in a CMS queue (mirroring the comments queue) or from the Slack buttons, both writing the same row and recording who decided and by which route.

Slack carries no session, so the request signature is the entire authorization story for POST /v1/integrations/slack/classifieds: verified over the raw body before anything parses it, constant-time, five-minute replay window in both directions, 1 MiB read cap. With SLACK_SIGNING_SECRET unset it returns 503 rather than falling open.

Clicking a stale message can't reverse a decision already made in the CMS. Messages from the old flow carry a base64 blob instead of a row id and are told to use the CMS.

4. feat(classifieds) — make a broken Slack integration announce itself

Slack's interactivity setup never probes the Request URL (unlike the Events API), so a wrong or unreachable URL saves cleanly and fails silently at the first click. Rejections now log as warnings naming which check failed, throttled to one a minute with suppressed events counted into the next line; the response stays a flat "invalid slack signature" so a prober learns nothing. slack_configured on the manage response lets the queue stop promising Slack approvals that can't work.

Verification

go build, go vet, go test, npm run build all clean. Exercised end to end against a local MariaDB: submit → pending → signed Slack approve → appears in the public listing; expired items excluded; bad signature 401; stale message reports the prior decision. 11 new tests. Swagger regenerated.

Deploy notes

  • SLACK_SIGNING_SECRET added to .env.example, docker-compose.yml, deploy/cms.env.example, deploy/compose.cms.yml.
  • The Slack buttons need Delta on public HTTPS (Slack requires a publicly-trusted cert; the nginx config is still :80). The CMS queue works regardless — nothing is blocked on it.
  • Whatever consumes the Slack clicks today must be turned off at cutover, or an approval writes to both WordPress and Delta.

🤖 Generated with Claude Code

ssavutu and others added 4 commits July 31, 2026 20:35
The footer's six link columns were hardcoded in the public site, so changing
a link — or the year in a section name — meant editing Astro and redeploying.
Store the menu as JSON in cms_settings and serve it from
GET /v1/settings/footer, with an admin-only PATCH behind the settings screen.

A column is a flat ordered list of typed entries rather than a heading with
children: the live footer stacks two bolded groups in one column ("Columns"
under "Opinion", "Special Editions" under "Comics & Puzzles") separated by a
blank line, which a single-heading shape cannot represent.

The stored default is the footer the public site already shipped, so an
untouched install serves exactly what it served before and the editor opens
pre-populated rather than blank. Saving a menu that normalizes down to nothing
reverts to that default — the public footer should never render empty because
of a bad write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… CMS

The public site still read three feeds from the legacy WordPress install.
Add CMS equivalents so it can stop:

  GET /v1/gallery         photo-gallery images
  GET /v1/articles/random the "surprise me" redirect target
  GET /v1/sitemap/slugs   slug + lastmod for the year-partitioned sitemaps

The gallery is a separate endpoint rather than opening up /v1/media/gallery:
it is unauthenticated, so it is narrowed to images — which Nginx already
serves publicly off the CephFS mount — and it drops the free-text search
parameter, which would otherwise turn it into a lookup tool over editors'
file names and captions. The editor-facing catalogue stays authenticated.

Random and sitemap are both pinned to published, non-archived rows the same
way the public article listing is. Sitemap is deliberately unpaginated: both
sitemap routes need the whole set to bucket it by year.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Classifieds were the last reader-facing flow still living on WordPress, and
the Slack notification *was* the submission: the whole classified rode along
base64-encoded in the button value, so anything unclicked or scrolled past
was simply lost, with no record anywhere.

Give them a real home. Submissions persist as pending on arrival; approving
happens either in a CMS moderation queue (mirroring the comments queue) or
from the buttons on the Slack message, both writing the same row and
recording who decided and by which route.

  GET    /v1/classifieds          approved and unexpired, public
  POST   /v1/classifieds          public, rate limited like comments
  GET    /v1/classifieds/manage   every status, for the queue
  PATCH  /v1/classifieds/{id}     editor decision
  DELETE /v1/classifieds/{id}     admin
  POST   /v1/integrations/slack/classifieds

Slack carries no session, so the request signature is the entire
authorization story for that last endpoint: it is verified over the raw body
before anything else parses it, in constant time, with a five-minute replay
window in both directions and a read cap on what is an unauthenticated
endpoint. With SLACK_SIGNING_SECRET unset it returns 503 rather than falling
open, and startup warns.

Clicking a button on a stale message cannot reverse a decision already made
in the CMS — it reports who decided and leaves the row alone. Messages from
the old flow carry a base64 blob instead of a row id and are told to use the
CMS, since those submissions were never persisted anywhere this server can
reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A misconfigured Slack app was diagnosable only by noticing a bare 401 or 503
in the access log, or by an editor reporting that a button did nothing.
Slack's interactivity setup gives no help here: unlike the Events API it
never probes the Request URL, so a wrong or unreachable URL saves cleanly and
fails silently at the first click.

Log rejections as warnings, naming which check failed — no secret, missing
headers, unparseable or replayed timestamp, digest mismatch. The response
stays a flat "invalid slack signature" so a prober still learns nothing about
which one it tripped. This endpoint is trivially floodable, so the warnings
are throttled to one a minute and what was dropped in between is counted into
the next line rather than lost.

Surface the same state to the moderation queue via slack_configured on the
manage response, which it already fetches. With the secret unset it now says
so plainly instead of telling editors that clicking Approve in Slack does the
same thing as approving in the CMS — which, right then, it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit 344cea5 into main Aug 1, 2026
6 checks passed
@ssavutu
ssavutu deleted the feat/cms-owned-footer-and-classifieds branch August 1, 2026 01:25
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