feat: CMS-owned footer, public-site endpoints, and classifieds moderation - #130
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 menuThe public site's six footer columns were hardcoded, so changing a link meant editing Astro and redeploying. Stored as JSON in
cms_settings, served fromGET /v1/settings/footer, adminPATCH, 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, sitemapGET /v1/gallery/wp-json/triangle/v1/galleryGET /v1/articles/random/wp-json/triangle/v1/random/GET /v1/sitemap/slugs/wp-json/triangle/v2/sitemap-slugsGallery 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-textsearchparam, 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 approvalsClassifieds 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
pendingon 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. WithSLACK_SIGNING_SECRETunset 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 itselfSlack'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_configuredon the manage response lets the queue stop promising Slack approvals that can't work.Verification
go build,go vet,go test,npm run buildall 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_SECRETadded to.env.example,docker-compose.yml,deploy/cms.env.example,deploy/compose.cms.yml.:80). The CMS queue works regardless — nothing is blocked on it.🤖 Generated with Claude Code