From aa106385ac0fd80c109eaae66d580f91bfe426fa Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 31 Jul 2026 20:35:41 -0400 Subject: [PATCH 1/4] feat(settings): make the public-site footer editable in the CMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/FooterMenuEditor.tsx | 295 ++++++++++++++++++ frontend/src/pages/settingsPage.tsx | 3 + server/docs/docs.go | 123 ++++++++ server/docs/swagger.json | 123 ++++++++ server/docs/swagger.yaml | 78 +++++ server/internal/database/footer_settings.go | 177 +++++++++++ .../internal/database/footer_settings_test.go | 90 ++++++ server/internal/handlers/settings.go | 52 +++ server/internal/models/api_responses.go | 36 +++ server/internal/routes/routes.go | 2 + 10 files changed, 979 insertions(+) create mode 100644 frontend/src/components/FooterMenuEditor.tsx create mode 100644 server/internal/database/footer_settings.go create mode 100644 server/internal/database/footer_settings_test.go diff --git a/frontend/src/components/FooterMenuEditor.tsx b/frontend/src/components/FooterMenuEditor.tsx new file mode 100644 index 0000000..8940506 --- /dev/null +++ b/frontend/src/components/FooterMenuEditor.tsx @@ -0,0 +1,295 @@ +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react" +import { useEffect, useState } from "react" +import { useApiFetch } from "../hooks/useApiFetch" + +type FooterEntryKind = "link" | "heading" | "spacer" + +type FooterEntry = { + kind: FooterEntryKind + label: string + href: string + new_tab: boolean +} + +type FooterColumn = { + entries: FooterEntry[] +} + +const emptyEntry: FooterEntry = { kind: "link", label: "", href: "", new_tab: false } + +function normalizeEntry(raw: unknown): FooterEntry { + const entry = (raw ?? {}) as Partial + const kind: FooterEntryKind = + entry.kind === "heading" || entry.kind === "spacer" ? entry.kind : "link" + return { + kind, + label: String(entry.label ?? ""), + href: String(entry.href ?? ""), + new_tab: Boolean(entry.new_tab), + } +} + +function normalizeColumns(raw: unknown): FooterColumn[] { + if (!Array.isArray(raw)) return [] + return raw.map((column) => ({ + entries: Array.isArray((column as FooterColumn | undefined)?.entries) + ? (column as FooterColumn).entries.map(normalizeEntry) + : [], + })) +} + +/** + * Editor for the public site's footer menu. The footer is one ordered document + * rather than a set of independent records, so the whole menu is loaded, edited + * locally, and saved in a single PATCH. + */ +export default function FooterMenuEditor() { + const apiFetch = useApiFetch() + const [columns, setColumns] = useState([]) + const [saved, setSaved] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState(null) + + useEffect(() => { + let cancelled = false + async function loadFooter() { + try { + const res = await apiFetch("/v1/settings/footer") + if (!res.ok) throw new Error(`Failed to load footer settings (${res.status})`) + const body = (await res.json()) as { columns?: unknown } + const loaded = normalizeColumns(body.columns) + if (!cancelled) { + setColumns(loaded) + setSaved(JSON.stringify(loaded)) + } + } catch (err) { + if (!cancelled) setMessage(err instanceof Error ? err.message : "Failed to load footer settings") + } finally { + if (!cancelled) setLoading(false) + } + } + void loadFooter() + return () => { + cancelled = true + } + }, [apiFetch]) + + function updateColumn(index: number, next: FooterColumn) { + setColumns((current) => current.map((column, i) => (i === index ? next : column))) + } + + function updateEntry(columnIndex: number, entryIndex: number, patch: Partial) { + const column = columns[columnIndex] + updateColumn(columnIndex, { + entries: column.entries.map((entry, i) => (i === entryIndex ? { ...entry, ...patch } : entry)), + }) + } + + function moveEntry(columnIndex: number, entryIndex: number, delta: number) { + const column = columns[columnIndex] + const target = entryIndex + delta + if (target < 0 || target >= column.entries.length) return + const entries = [...column.entries] + const [moved] = entries.splice(entryIndex, 1) + entries.splice(target, 0, moved) + updateColumn(columnIndex, { entries }) + } + + function moveColumn(columnIndex: number, delta: number) { + const target = columnIndex + delta + if (target < 0 || target >= columns.length) return + const next = [...columns] + const [moved] = next.splice(columnIndex, 1) + next.splice(target, 0, moved) + setColumns(next) + } + + async function save() { + setSaving(true) + setMessage(null) + try { + const res = await apiFetch("/v1/settings/footer", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columns }), + }) + if (!res.ok) throw new Error(`Failed to save footer settings (${res.status})`) + // The server drops unlabelled entries and empty columns, so render what + // it stored rather than the draft. + const body = (await res.json()) as { columns?: unknown } + const stored = normalizeColumns(body.columns) + setColumns(stored) + setSaved(JSON.stringify(stored)) + setMessage("Saved") + } catch (err) { + setMessage(err instanceof Error ? err.message : "Failed to save footer settings") + } finally { + setSaving(false) + } + } + + const dirty = JSON.stringify(columns) !== saved + + return ( +
+

Footer Menu

+

+ Link columns shown in the public site footer. Headings are the bold entries; a spacer starts a + new group inside the same column. Saving an empty menu restores the built-in default. +

+ + {loading ? ( +

Loading…

+ ) : ( +
+ {columns.map((column, columnIndex) => ( +
+
+ + Column {columnIndex + 1} + +
+ + + +
+
+ + {column.entries.map((entry, entryIndex) => ( +
+ + + {entry.kind === "spacer" ? ( + Blank line + ) : ( + <> + updateEntry(columnIndex, entryIndex, { label: e.target.value })} + placeholder="Label" + className="flex-1 min-w-[8rem] px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + updateEntry(columnIndex, entryIndex, { href: e.target.value })} + placeholder="/section or https://…" + className="flex-[2] min-w-[12rem] px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + + + )} + +
+ + + +
+
+ ))} + + +
+ ))} + + +
+ )} + +
+ + {message && {message}} +
+
+ ) +} diff --git a/frontend/src/pages/settingsPage.tsx b/frontend/src/pages/settingsPage.tsx index c483a88..451ba8e 100644 --- a/frontend/src/pages/settingsPage.tsx +++ b/frontend/src/pages/settingsPage.tsx @@ -1,5 +1,6 @@ import { LogOut, RefreshCw } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" +import FooterMenuEditor from "../components/FooterMenuEditor" import { useApiFetch } from "../hooks/useApiFetch" import { useCurrentUserRole } from "../hooks/useCurrentUserRole" import { useNavigate } from "react-router-dom" @@ -356,6 +357,8 @@ export default function SettingsPage() { + {isAdmin && } +

Taxonomy

diff --git a/server/docs/docs.go b/server/docs/docs.go index e75f3bb..a6872f5 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -2925,6 +2925,79 @@ const docTemplate = `{ } } }, + "/v1/settings/footer": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Get the public-site footer menu", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.FooterSettingsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Update the public-site footer menu", + "parameters": [ + { + "description": "Footer menu", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.FooterSettingsPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.FooterSettingsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/settings/seo": { "get": { "produces": [ @@ -4349,6 +4422,56 @@ const docTemplate = `{ } } }, + "models.FooterColumn": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterEntry" + } + } + } + }, + "models.FooterEntry": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "new_tab": { + "type": "boolean" + } + } + }, + "models.FooterSettingsPatchRequest": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterColumn" + } + } + } + }, + "models.FooterSettingsResponse": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterColumn" + } + } + } + }, "models.HealthResponse": { "type": "object", "properties": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index a7bf3f6..d7976e8 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -2922,6 +2922,79 @@ } } }, + "/v1/settings/footer": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Get the public-site footer menu", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.FooterSettingsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Update the public-site footer menu", + "parameters": [ + { + "description": "Footer menu", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.FooterSettingsPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.FooterSettingsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/settings/seo": { "get": { "produces": [ @@ -4346,6 +4419,56 @@ } } }, + "models.FooterColumn": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterEntry" + } + } + } + }, + "models.FooterEntry": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "new_tab": { + "type": "boolean" + } + } + }, + "models.FooterSettingsPatchRequest": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterColumn" + } + } + } + }, + "models.FooterSettingsResponse": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FooterColumn" + } + } + } + }, "models.HealthResponse": { "type": "object", "properties": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 1b77211..01c6cbf 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -477,6 +477,38 @@ definitions: error: type: string type: object + models.FooterColumn: + properties: + entries: + items: + $ref: '#/definitions/models.FooterEntry' + type: array + type: object + models.FooterEntry: + properties: + href: + type: string + kind: + type: string + label: + type: string + new_tab: + type: boolean + type: object + models.FooterSettingsPatchRequest: + properties: + columns: + items: + $ref: '#/definitions/models.FooterColumn' + type: array + type: object + models.FooterSettingsResponse: + properties: + columns: + items: + $ref: '#/definitions/models.FooterColumn' + type: array + type: object models.HealthResponse: properties: status: @@ -2819,6 +2851,52 @@ paths: summary: Update breaking-news banner settings tags: - settings + /v1/settings/footer: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.FooterSettingsResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Get the public-site footer menu + tags: + - settings + patch: + consumes: + - application/json + parameters: + - description: Footer menu + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.FooterSettingsPatchRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.FooterSettingsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Update the public-site footer menu + tags: + - settings /v1/settings/seo: get: produces: diff --git a/server/internal/database/footer_settings.go b/server/internal/database/footer_settings.go new file mode 100644 index 0000000..1012f58 --- /dev/null +++ b/server/internal/database/footer_settings.go @@ -0,0 +1,177 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + + "server/internal/models" +) + +// The footer menu is a single JSON blob in cms_settings rather than its own +// table: it is one small document, read whole by the public site and written +// whole by the settings screen, and it never needs to be queried by parts. +const footerSettingKey = "footer_menu" + +// defaultFooterColumns mirrors the footer the public site shipped hardcoded, so +// an untouched install serves exactly what it served before and the settings +// screen opens pre-populated instead of blank. +func defaultFooterColumns() []models.FooterColumn { + link := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href} + } + external := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryLink, Label: label, Href: href, NewTab: true} + } + heading := func(label, href string) models.FooterEntry { + return models.FooterEntry{Kind: models.FooterEntryHeading, Label: label, Href: href} + } + spacer := models.FooterEntry{Kind: models.FooterEntrySpacer} + + return []models.FooterColumn{ + {Entries: []models.FooterEntry{ + heading("About", "/about"), + link("Contact Us", "/contact"), + external("Join The Triangle", "https://docs.google.com/forms/d/e/1FAIpQLScra_6sUenvmpIuQ5FjmMyWO0a2sz9z36HkrqfnYQvJGH9BGQ/viewform"), + link("Staff", "/staff"), + link("Find-A-Triangle", "/find"), + link("Photo Gallery", "/photo"), + external("Print Archive", "https://drexel.primo.exlibrisgroup.com/discovery/collectionDiscovery?vid=01DRXU_INST:01DRXU&inst=01DRXU_INST&collectionId=81448731180004721"), + link("Constitution", "/proxy/wp-content/uploads/2026/03/The-Triangle-Constitution-3.pdf"), + }}, + {Entries: []models.FooterEntry{ + heading("News", "/news"), + link("Campus", "/campus"), + link("Academic Transformation", "/academic-transformation"), + link("Politics", "/politics"), + link("Transit", "/transit"), + link("Crime & Policy Violations", "/crime-policy-violations"), + }}, + {Entries: []models.FooterEntry{ + heading("Sports", "/sports"), + link("Men's Basketball", "/mens-basketball"), + link("Women's Basketball", "/womens-basketball"), + link("Big 5", "/big-5"), + link("Philly Sports", "/philly-sports"), + link("Field Hockey", "/field-hockey"), + link("Men's Soccer", "/mens-soccer"), + link("Women's Soccer", "/womens-soccer"), + }}, + {Entries: []models.FooterEntry{ + heading("Opinion", "/opinion"), + link("Science & Tech", "/science-tech"), + link("From the Editor", "/from-the-editor"), + spacer, + heading("Columns", "/columns"), + link("From the Playbook", "/from-the-playbook"), + link("The Love Triangle", "/the-love-triangle"), + link("Tri This Sweet Treat", "/tri-this-sweet-treat"), + }}, + {Entries: []models.FooterEntry{ + heading("Entertainment", "/entertainment"), + link("Movies", "/movies"), + link("Music", "/music"), + link("Happening in Philly", "/happening-in-philly"), + link("Cooking", "/cooking"), + link("Books", "/books"), + link("Gaming", "/gaming"), + link("Listicles", "/listicles"), + }}, + {Entries: []models.FooterEntry{ + heading("Comics & Puzzles", "/comics-puzzles"), + link("Political Cartoons", "/political-cartoons"), + link("Crossword", "/crossword"), + link("Sudoku", "/sudoku"), + spacer, + heading("Special Editions", "/"), + link("Graduation", "/graduation"), + link("Welcome Week", "/search?s=Welcome%20Week"), + external("The Rectangle", "https://therectangle.org"), + link("100 Year Anniversary", "/one-hundred"), + }}, + } +} + +// GetFooterSettings returns the stored footer menu, falling back to the +// built-in default when the key is absent, blank, unparseable, or stores an +// empty menu. The public footer is not something that should ever render empty +// because of a bad write. +func GetFooterSettings(ctx context.Context, conn *sql.DB) (models.FooterSettings, error) { + var raw string + err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", footerSettingKey).Scan(&raw) + if err == sql.ErrNoRows { + return models.FooterSettings{Columns: defaultFooterColumns()}, nil + } + if err != nil { + return models.FooterSettings{}, err + } + if strings.TrimSpace(raw) == "" { + return models.FooterSettings{Columns: defaultFooterColumns()}, nil + } + + var parsed models.FooterSettings + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return models.FooterSettings{Columns: defaultFooterColumns()}, nil + } + parsed.Columns = normalizeFooterColumns(parsed.Columns) + if len(parsed.Columns) == 0 { + return models.FooterSettings{Columns: defaultFooterColumns()}, nil + } + return parsed, nil +} + +// SetFooterSettings persists the footer menu. Passing an empty menu clears the +// customization, which makes the public site fall back to the default columns. +func SetFooterSettings(ctx context.Context, conn *sql.DB, s models.FooterSettings) error { + s.Columns = normalizeFooterColumns(s.Columns) + + payload, err := json.Marshal(s) + if err != nil { + return err + } + return setSetting(ctx, conn, footerSettingKey, string(payload)) +} + +// normalizeFooterColumns trims the menu and drops entries that would render as +// nothing: unlabelled links, and columns left with no visible content. An +// unrecognized kind is treated as a link, since that is the only kind that +// carries a destination. +func normalizeFooterColumns(columns []models.FooterColumn) []models.FooterColumn { + normalized := make([]models.FooterColumn, 0, len(columns)) + for _, column := range columns { + entries := make([]models.FooterEntry, 0, len(column.Entries)) + for _, entry := range column.Entries { + entry.Label = strings.TrimSpace(entry.Label) + entry.Href = strings.TrimSpace(entry.Href) + + switch entry.Kind { + case models.FooterEntrySpacer: + entries = append(entries, models.FooterEntry{Kind: models.FooterEntrySpacer}) + continue + case models.FooterEntryHeading: + default: + entry.Kind = models.FooterEntryLink + } + + if entry.Label == "" { + continue + } + entries = append(entries, entry) + } + + // A column of nothing but spacers has no content to show. + hasContent := false + for _, entry := range entries { + if entry.Kind != models.FooterEntrySpacer { + hasContent = true + break + } + } + if !hasContent { + continue + } + normalized = append(normalized, models.FooterColumn{Entries: entries}) + } + return normalized +} diff --git a/server/internal/database/footer_settings_test.go b/server/internal/database/footer_settings_test.go new file mode 100644 index 0000000..b7b7fb1 --- /dev/null +++ b/server/internal/database/footer_settings_test.go @@ -0,0 +1,90 @@ +package database + +import ( + "testing" + + "server/internal/models" +) + +func TestNormalizeFooterColumns_TrimsAndDropsEmptyEntries(t *testing.T) { + columns := normalizeFooterColumns([]models.FooterColumn{ + {Entries: []models.FooterEntry{ + {Kind: models.FooterEntryHeading, Label: " News ", Href: " /news "}, + {Kind: models.FooterEntryLink, Label: " ", Href: "/campus"}, + {Kind: models.FooterEntryLink, Label: "Campus", Href: "/campus"}, + }}, + }) + + if len(columns) != 1 { + t.Fatalf("expected 1 column, got %d", len(columns)) + } + entries := columns[0].Entries + if len(entries) != 2 { + t.Fatalf("expected the unlabelled entry to be dropped, got %d entries", len(entries)) + } + if entries[0].Label != "News" || entries[0].Href != "/news" { + t.Errorf("expected the heading to be trimmed, got %+v", entries[0]) + } +} + +func TestNormalizeFooterColumns_DefaultsUnknownKindToLink(t *testing.T) { + columns := normalizeFooterColumns([]models.FooterColumn{ + {Entries: []models.FooterEntry{{Kind: "banner", Label: "Staff", Href: "/staff"}}}, + }) + + if len(columns) != 1 || len(columns[0].Entries) != 1 { + t.Fatalf("expected a single entry, got %+v", columns) + } + if got := columns[0].Entries[0].Kind; got != models.FooterEntryLink { + t.Errorf("expected kind %q, got %q", models.FooterEntryLink, got) + } +} + +// A spacer renders as a blank line, so a column holding nothing else would show +// as an empty gap in the footer. +func TestNormalizeFooterColumns_DropsColumnsWithoutContent(t *testing.T) { + columns := normalizeFooterColumns([]models.FooterColumn{ + {Entries: []models.FooterEntry{{Kind: models.FooterEntrySpacer}}}, + {Entries: nil}, + {Entries: []models.FooterEntry{{Kind: models.FooterEntryHeading, Label: "Sports", Href: "/sports"}}}, + }) + + if len(columns) != 1 { + t.Fatalf("expected only the column with content to survive, got %d", len(columns)) + } + if columns[0].Entries[0].Label != "Sports" { + t.Errorf("kept the wrong column: %+v", columns[0]) + } +} + +// Spacers carry no destination; a stored label or href would be dead data the +// public site must then decide whether to render. +func TestNormalizeFooterColumns_StripsSpacerContent(t *testing.T) { + columns := normalizeFooterColumns([]models.FooterColumn{ + {Entries: []models.FooterEntry{ + {Kind: models.FooterEntryHeading, Label: "Opinion", Href: "/opinion"}, + {Kind: models.FooterEntrySpacer, Label: "leftover", Href: "/stale", NewTab: true}, + }}, + }) + + spacer := columns[0].Entries[1] + if spacer.Label != "" || spacer.Href != "" || spacer.NewTab { + t.Errorf("expected the spacer to be stripped, got %+v", spacer) + } +} + +// The default menu is what an untouched install serves, so it must survive +// normalization unchanged. +func TestDefaultFooterColumns_SurviveNormalization(t *testing.T) { + defaults := defaultFooterColumns() + normalized := normalizeFooterColumns(defaults) + + if len(normalized) != len(defaults) { + t.Fatalf("expected %d columns, got %d", len(defaults), len(normalized)) + } + for i, column := range normalized { + if len(column.Entries) != len(defaults[i].Entries) { + t.Errorf("column %d: expected %d entries, got %d", i, len(defaults[i].Entries), len(column.Entries)) + } + } +} diff --git a/server/internal/handlers/settings.go b/server/internal/handlers/settings.go index 7acc223..27c5153 100644 --- a/server/internal/handlers/settings.go +++ b/server/internal/handlers/settings.go @@ -115,6 +115,58 @@ func PatchBreakingNews(conn *sql.DB) http.Handler { }) } +// @Summary Get the public-site footer menu +// @Tags settings +// @Produce json +// @Success 200 {object} models.FooterSettingsResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/settings/footer [get] +func GetFooterSettings(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + settings, err := db.GetFooterSettings(r.Context(), conn) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch footer settings") + return + } + writeJSON(w, http.StatusOK, settings) + }) +} + +// @Summary Update the public-site footer menu +// @Tags settings +// @Accept json +// @Produce json +// @Param body body models.FooterSettingsPatchRequest true "Footer menu" +// @Success 200 {object} models.FooterSettingsResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/settings/footer [patch] +func PatchFooterSettings(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body models.FooterSettingsPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if err := db.SetFooterSettings(r.Context(), conn, body); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update footer settings") + return + } + + // Read back rather than echo: the stored menu is normalized, and an + // empty save reverts to the default columns. + saved, err := db.GetFooterSettings(r.Context(), conn) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch footer settings") + return + } + activity.LogRequest(r, "settings_changed", "Footer menu updated") + writeJSON(w, http.StatusOK, saved) + }) +} + // @Summary Rebuild taxonomy article counts // @Tags settings // @Success 204 diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go index 4df1509..0261cce 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -216,6 +216,42 @@ type BreakingNewsSettingsResponse = BreakingNewsSettings type BreakingNewsSettingsPatchRequest = BreakingNewsSettings +// Footer entry kinds. A column is a flat ordered list rather than a heading +// plus children because the live footer stacks two groups in one column +// ("Columns" under "Opinion", "Special Editions" under "Comics & Puzzles"), +// separated by a blank line — which a single-heading shape cannot express. +const ( + FooterEntryLink = "link" + FooterEntryHeading = "heading" + FooterEntrySpacer = "spacer" +) + +// FooterEntry is one line in a footer column. NewTab drives target="_blank", +// which the external links (application form, print archive, The Rectangle) +// need and the internal ones must not have. Spacers carry no label or href. +type FooterEntry struct { + Kind string `json:"kind"` + Label string `json:"label"` + Href string `json:"href"` + NewTab bool `json:"new_tab"` +} + +// FooterColumn is one column of the public-site footer. +type FooterColumn struct { + Entries []FooterEntry `json:"entries"` +} + +// FooterSettings is the whole public-site footer menu. The public site keeps +// its own hardcoded copy as a fallback, so an empty Columns list is valid and +// simply means "nothing customized yet". +type FooterSettings struct { + Columns []FooterColumn `json:"columns"` +} + +type FooterSettingsResponse = FooterSettings + +type FooterSettingsPatchRequest = FooterSettings + // SEOSettings holds the site-wide SEO / social defaults. type SEOSettings struct { OGTitle string `json:"og_title"` diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go index b5887ce..70db6c7 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -73,6 +73,7 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("GET /v1/settings/site", handlers.GetSiteSettings(conn)) mux.Handle("GET /v1/settings/seo", handlers.GetSEOSettings(conn)) mux.Handle("GET /v1/settings/breaking-news", handlers.GetBreakingNews(conn)) + mux.Handle("GET /v1/settings/footer", handlers.GetFooterSettings(conn)) mux.Handle("GET /v1/seo/audit", authMW(handlers.GetSEOAudit(conn))) mux.Handle("GET /v1/activity", authMW(adminOnly(handlers.GetActivity()))) mux.Handle("GET /v1/users/me", authMW(http.HandlerFunc(handlers.GetMe))) @@ -107,6 +108,7 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("PATCH /v1/settings/site", authMW(adminOnly(handlers.PatchSiteSettings(conn)))) mux.Handle("PATCH /v1/settings/seo", authMW(adminOnly(handlers.PatchSEOSettings(conn)))) mux.Handle("PATCH /v1/settings/breaking-news", authMW(adminOnly(handlers.PatchBreakingNews(conn)))) + mux.Handle("PATCH /v1/settings/footer", authMW(adminOnly(handlers.PatchFooterSettings(conn)))) mux.Handle("POST /v1/settings/taxonomy/rebuild", authMW(adminOnly(handlers.PostRebuildTaxonomyCounts(conn)))) mux.Handle("POST /v1/taxonomy", authMW(adminOnly(handlers.PostTaxonomy(conn)))) mux.Handle("PUT /v1/taxonomy/{type}/{slug}", authMW(adminOnly(handlers.PutTaxonomyItem(conn)))) From eb3d1ca791011bf828417963e5b0975221a639ea Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 31 Jul 2026 20:36:10 -0400 Subject: [PATCH 2/4] feat(public): serve the gallery, random articles and sitemap from the CMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/docs/docs.go | 121 ++++++++++++++++++++++++ server/docs/swagger.json | 121 ++++++++++++++++++++++++ server/docs/swagger.yaml | 78 +++++++++++++++ server/internal/handlers/media.go | 46 +++++++++ server/internal/handlers/public_site.go | 84 ++++++++++++++++ server/internal/models/api_responses.go | 14 +++ server/internal/routes/routes.go | 7 ++ 7 files changed, 471 insertions(+) create mode 100644 server/internal/handlers/public_site.go diff --git a/server/docs/docs.go b/server/docs/docs.go index a6872f5..e00a8e9 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -225,6 +225,37 @@ const docTemplate = `{ } } }, + "/v1/articles/random": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "articles" + ], + "summary": "Get a random published article", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RandomArticleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/articles/{slug}": { "get": { "description": "Public. Unauthenticated callers only see published, non-archived articles; anything else answers 404, the same as an unknown slug. An authenticated editor sees drafts and archived articles too.", @@ -1454,6 +1485,46 @@ const docTemplate = `{ } } }, + "/v1/gallery": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "List gallery images for the public site", + "parameters": [ + { + "type": "integer", + "default": 50, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaGalleryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/health": { "get": { "produces": [ @@ -3168,6 +3239,34 @@ const docTemplate = `{ } } }, + "/v1/sitemap/slugs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "articles" + ], + "summary": "List slugs and modification dates for the sitemap", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SitemapSlug" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/subsections/{subsection_slug}/articles": { "get": { "produces": [ @@ -4919,6 +5018,17 @@ const docTemplate = `{ } } }, + "models.RandomArticleResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.Role": { "type": "string", "enum": [ @@ -5064,6 +5174,17 @@ const docTemplate = `{ } } }, + "models.SitemapSlug": { + "type": "object", + "properties": { + "lastmod": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, "models.SubsectionArticlesResponse": { "type": "object", "properties": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index d7976e8..34c2dc9 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -222,6 +222,37 @@ } } }, + "/v1/articles/random": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "articles" + ], + "summary": "Get a random published article", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RandomArticleResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/articles/{slug}": { "get": { "description": "Public. Unauthenticated callers only see published, non-archived articles; anything else answers 404, the same as an unknown slug. An authenticated editor sees drafts and archived articles too.", @@ -1451,6 +1482,46 @@ } } }, + "/v1/gallery": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "List gallery images for the public site", + "parameters": [ + { + "type": "integer", + "default": 50, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.MediaGalleryResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/health": { "get": { "produces": [ @@ -3165,6 +3236,34 @@ } } }, + "/v1/sitemap/slugs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "articles" + ], + "summary": "List slugs and modification dates for the sitemap", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SitemapSlug" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/subsections/{subsection_slug}/articles": { "get": { "produces": [ @@ -4916,6 +5015,17 @@ } } }, + "models.RandomArticleResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.Role": { "type": "string", "enum": [ @@ -5061,6 +5171,17 @@ } } }, + "models.SitemapSlug": { + "type": "object", + "properties": { + "lastmod": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, "models.SubsectionArticlesResponse": { "type": "object", "properties": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 01c6cbf..f5f1bcc 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -799,6 +799,13 @@ definitions: total_votes: type: integer type: object + models.RandomArticleResponse: + properties: + slug: + type: string + title: + type: string + type: object models.Role: enum: - editor @@ -894,6 +901,13 @@ definitions: site_title: type: string type: object + models.SitemapSlug: + properties: + lastmod: + type: string + slug: + type: string + type: object models.SubsectionArticlesResponse: properties: articles: @@ -1388,6 +1402,26 @@ paths: summary: Restore an archived article tags: - articles + /v1/articles/random: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.RandomArticleResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Get a random published article + tags: + - articles /v1/auth/callback: get: parameters: @@ -1910,6 +1944,32 @@ paths: summary: Add developing story tags: - developing-stories + /v1/gallery: + get: + parameters: + - default: 50 + description: Max results + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.MediaGalleryResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: List gallery images for the public site + tags: + - media /v1/health: get: produces: @@ -3003,6 +3063,24 @@ paths: summary: Rebuild taxonomy article counts tags: - settings + /v1/sitemap/slugs: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.SitemapSlug' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: List slugs and modification dates for the sitemap + tags: + - articles /v1/subsections/{subsection_slug}/articles: get: parameters: diff --git a/server/internal/handlers/media.go b/server/internal/handlers/media.go index a60739a..113105e 100644 --- a/server/internal/handlers/media.go +++ b/server/internal/handlers/media.go @@ -469,6 +469,52 @@ func GetMediaGallery(conn *sql.DB) http.Handler { }) } +// GetPublicGallery is the public site's photo-gallery feed. Unlike the +// editor-facing listing it is unauthenticated, so it is narrowed to images — +// the assets Nginx already serves publicly off the CephFS mount — and it does +// not accept the free-text search parameter, which would otherwise turn the +// endpoint into a lookup tool over editors' file names and captions. +// +// @Summary List gallery images for the public site +// @Tags media +// @Produce json +// @Param limit query int false "Max results" default(50) +// @Param offset query int false "Offset" +// @Success 200 {object} models.MediaGalleryResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/gallery [get] +func GetPublicGallery(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, limit, offset := listParams(r, 50) + items, _, err := db.ListMedia(r.Context(), conn, models.MediaListParams{ + Limit: limit, + Offset: offset, + MimeType: "image/", + SortBy: models.MediaSortBy(r.URL.Query().Get("sort_by")), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + gallery := make([]models.MediaOverview, 0, len(items)) + for _, item := range items { + gallery = append(gallery, models.MediaOverview{ + ID: item.ID, + Path: item.Path, + FileName: item.FileName, + URL: uploadURL(item.Path), + MimeType: item.MimeType, + Width: item.Width, + Height: item.Height, + AltText: item.AltText, + }) + } + + writeJSON(w, http.StatusOK, models.MediaGalleryResponse{Media: gallery}) + }) +} + // GetMediaItem returns a single library entry. // // @Summary Get a media asset diff --git a/server/internal/handlers/public_site.go b/server/internal/handlers/public_site.go new file mode 100644 index 0000000..90af7eb --- /dev/null +++ b/server/internal/handlers/public_site.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "database/sql" + "net/http" + "time" + + "server/internal/models" +) + +// Endpoints that exist purely to serve the public site, replacing the +// equivalents it used to read from the legacy WordPress install. + +// GetRandomArticle returns one randomly chosen live article. The public site +// only needs somewhere to redirect to, so this returns the slug rather than the +// whole article. +// +// @Summary Get a random published article +// @Tags articles +// @Produce json +// @Success 200 {object} models.RandomArticleResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/articles/random [get] +func GetRandomArticle(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Unauthenticated endpoint, so it is pinned to published, non-archived + // rows the same way the public article listing is. + var slug, title string + err := conn.QueryRowContext(r.Context(), "SELECT `slug`, `title` FROM `articles` WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY RAND() LIMIT 1").Scan(&slug, &title) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "no published articles") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, models.RandomArticleResponse{Slug: slug, Title: title}) + }) +} + +// GetSitemapSlugs returns every live article's slug and last-modified date, for +// the public site's year-partitioned sitemaps. It is deliberately unpaginated: +// the caller needs the whole set to bucket it by year, and both sitemap routes +// fetch it in full. +// +// @Summary List slugs and modification dates for the sitemap +// @Tags articles +// @Produce json +// @Success 200 {array} models.SitemapSlug +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/sitemap/slugs [get] +func GetSitemapSlugs(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rows, err := conn.QueryContext(r.Context(), "SELECT `slug`, COALESCE(`mod_date`, `pub_date`) FROM `articles` WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY `pub_date` DESC") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + slugs := make([]models.SitemapSlug, 0) + for rows.Next() { + var slug string + var lastmod sql.NullTime + if err := rows.Scan(&slug, &lastmod); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + entry := models.SitemapSlug{Slug: slug} + if lastmod.Valid { + entry.LastMod = lastmod.Time.UTC().Format(time.RFC3339) + } + slugs = append(slugs, entry) + } + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, slugs) + }) +} diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go index 0261cce..3d4b12f 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -197,6 +197,20 @@ type HomepageResponse struct { Columns []ArticleListItem `json:"columns"` } +// RandomArticleResponse is the "surprise me" target for the public site, which +// only needs a slug to redirect to. +type RandomArticleResponse struct { + Slug string `json:"slug"` + Title string `json:"title"` +} + +// SitemapSlug is one entry in the public site's sitemap feed. The field names +// match what its sitemap routes already consume. +type SitemapSlug struct { + Slug string `json:"slug"` + LastMod string `json:"lastmod"` +} + type SiteSettingsResponse struct { SiteTitle string `json:"site_title"` } diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go index 70db6c7..f93b38f 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -45,7 +45,14 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("GET /v1/authors/{slug}/articles", optionalAuth(handlers.GetAuthorArticles(conn))) mux.Handle("GET /v1/articles", optionalAuth(handlers.GetArticles(conn))) + // "random" is a literal segment, so Go's mux prefers it over + // /v1/articles/{slug}. + mux.Handle("GET /v1/articles/random", handlers.GetRandomArticle(conn)) mux.Handle("GET /v1/articles/{slug}", optionalAuth(handlers.GetArticle(conn))) + mux.Handle("GET /v1/sitemap/slugs", handlers.GetSitemapSlugs(conn)) + // The public photo gallery. The editor-facing catalogue at + // /v1/media/gallery stays authenticated; this one is images-only. + mux.Handle("GET /v1/gallery", handlers.GetPublicGallery(conn)) mux.Handle("GET /v1/articles/{slug}/comments", handlers.GetArticleComments(conn)) mux.Handle("POST /v1/articles/{slug}/comments", middleware.RateLimitByIP(5, time.Minute)(handlers.PostArticleComment(conn, spamChecker))) mux.Handle("GET /v1/search", handlers.GetSearch(conn)) From db96519416be98fd43d1f40d6c348be29419514e Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 31 Jul 2026 20:37:02 -0400 Subject: [PATCH 3/4] feat(classifieds): moderation queue with signed Slack approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 6 + deploy/cms.env.example | 1 + deploy/compose.cms.yml | 1 + docker-compose.yml | 1 + frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 26 +- frontend/src/pages/classifiedsView.tsx | 256 ++++++++++++ server/docs/docs.go | 392 ++++++++++++++++++ server/docs/swagger.json | 392 ++++++++++++++++++ server/docs/swagger.yaml | 253 +++++++++++ server/internal/database/classifieds.go | 225 ++++++++++ .../internal/database/schema/classifieds.sql | 17 + server/internal/database/schema_test.go | 2 +- server/internal/handlers/classifieds.go | 245 +++++++++++ server/internal/handlers/slack.go | 201 +++++++++ server/internal/handlers/slack_test.go | 113 +++++ server/internal/models/api_responses.go | 49 +++ server/internal/routes/routes.go | 13 + server/main.go | 7 + 19 files changed, 2200 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/classifiedsView.tsx create mode 100644 server/internal/database/classifieds.go create mode 100644 server/internal/database/schema/classifieds.sql create mode 100644 server/internal/handlers/classifieds.go create mode 100644 server/internal/handlers/slack.go create mode 100644 server/internal/handlers/slack_test.go diff --git a/.env.example b/.env.example index 0d50d7c..7244203 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false +# Slack app signing secret, from the Slack app's Basic Information page. It is +# the only thing authorizing the Approve/Reject buttons on classified +# notifications; without it POST /v1/integrations/slack/classifieds refuses +# every request. +SLACK_SIGNING_SECRET= + # Delta production variables live in deploy/cms.env.example. Do not put # production server addresses, DB passwords, OIDC secrets, runner tokens, # certificates, or deployment env files in git. diff --git a/deploy/cms.env.example b/deploy/cms.env.example index ab242d9..583cf5c 100644 --- a/deploy/cms.env.example +++ b/deploy/cms.env.example @@ -13,6 +13,7 @@ FRONTEND_ORIGIN= OIDC_REDIRECT_URI= CMS_SESSION_TTL_SECONDS= CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP= +SLACK_SIGNING_SECRET= AKISMET_API_KEY= AKISMET_BLOG_URL= MEDIA_HOST_PATH= diff --git a/deploy/compose.cms.yml b/deploy/compose.cms.yml index c707afa..c7e94a3 100644 --- a/deploy/compose.cms.yml +++ b/deploy/compose.cms.yml @@ -28,6 +28,7 @@ x-backend-base: &backend-base OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:?OIDC_REDIRECT_URI is required} CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} AKISMET_API_KEY: ${AKISMET_API_KEY:-} AKISMET_BLOG_URL: ${AKISMET_BLOG_URL:-} # Media: legacy WP uploads migrated to CephFS. The upload endpoint writes new diff --git a/docker-compose.yml b/docker-compose.yml index 73b2dbf..302bf9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,7 @@ services: TLS_KEY_FILE: /app/certs/localhost.key OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} depends_on: mariadb: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96fd684..39d5d01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import AuthorsView from "./pages/authorsView" import SectionsView from "./pages/sectionsView" import UsersView from "./pages/usersView" import CommentsView from "./pages/commentsView" +import ClassifiedsView from "./pages/classifiedsView" import ActivityView from "./pages/activityView" import NewsletterView from "./pages/newsletterView" import SeoView from "./pages/seoView" @@ -115,6 +116,7 @@ export default function App() { } /> } /> } /> + } /> } /> >({ "/articles": true, }) @@ -119,7 +122,23 @@ export default function Sidebar() { } } + const loadPendingClassifiedCount = async () => { + try { + const response = await apiFetch("/v1/classifieds/manage?limit=1&status=pending") + if (!response.ok) return + const body = await response.json() as { counts?: { pending?: number } } + if (!cancelled) { + setPendingClassifiedCount(body.counts?.pending ?? 0) + } + } catch { + if (!cancelled) { + setPendingClassifiedCount(0) + } + } + } + void loadPendingCommentCount() + void loadPendingClassifiedCount() return () => { cancelled = true } @@ -160,7 +179,12 @@ export default function Sidebar() { const active = isActive(item) const hasChildren = item.children && item.children.length > 0 const open = openGroups[item.path] - const badge = item.path === "/comments" ? pendingCommentCount : item.badge + const badge = + item.path === "/comments" + ? pendingCommentCount + : item.path === "/classifieds" + ? pendingClassifiedCount + : item.badge return (

diff --git a/frontend/src/pages/classifiedsView.tsx b/frontend/src/pages/classifiedsView.tsx new file mode 100644 index 0000000..fff507c --- /dev/null +++ b/frontend/src/pages/classifiedsView.tsx @@ -0,0 +1,256 @@ +import { Check, Clipboard, Mail, MessageSquare, Trash2, X } from "lucide-react" +import { useCallback, useEffect, useState } from "react" +import { useApiFetch } from "../hooks/useApiFetch" +import { useCurrentUserRole } from "../hooks/useCurrentUserRole" + +type ClassifiedStatus = "pending" | "approved" | "rejected" +type StatusFilter = "all" | ClassifiedStatus + +type Classified = { + id: number + name: string + email: string + label: string + message: string + end_date: string + status: ClassifiedStatus + decided_at?: string + decided_by?: string + decided_via?: string + created_at?: string +} + +type ManageResponse = { + classifieds?: Classified[] + counts?: Partial> +} + +const STATUS_FILTERS: StatusFilter[] = ["all", "pending", "approved", "rejected"] + +const STATUS_STYLES: Record = { + pending: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300", + approved: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300", + rejected: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", +} + +function formatDate(value?: string) { + if (!value) return "—" + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return "—" + return parsed.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + +function formatEndDate(value: string) { + if (!value) return "No end date" + const parsed = new Date(`${value}T00:00:00`) + if (Number.isNaN(parsed.getTime())) return value + return parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +} + +// A classified stops showing on the public site the day after its end date, +// which is worth surfacing here: approving an expired one does nothing visible. +function isExpired(value: string) { + if (!value) return false + const parsed = new Date(`${value}T23:59:59`) + return !Number.isNaN(parsed.getTime()) && parsed.getTime() < Date.now() +} + +export default function ClassifiedsView() { + const apiFetch = useApiFetch() + const { isAdmin } = useCurrentUserRole() + const [filter, setFilter] = useState("pending") + const [items, setItems] = useState([]) + const [counts, setCounts] = useState>>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await apiFetch(`/v1/classifieds/manage?status=${filter}&limit=100`) + if (!res.ok) throw new Error(`Failed to load classifieds (${res.status})`) + const body = (await res.json()) as ManageResponse + setItems(body.classifieds ?? []) + setCounts(body.counts ?? {}) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load classifieds") + } finally { + setLoading(false) + } + }, [apiFetch, filter]) + + useEffect(() => { + void load() + }, [load]) + + async function setStatus(id: number, status: ClassifiedStatus) { + setBusyId(id) + setError(null) + try { + const res = await apiFetch(`/v1/classifieds/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }) + if (!res.ok) throw new Error(`Failed to update classified (${res.status})`) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update classified") + } finally { + setBusyId(null) + } + } + + async function remove(id: number) { + if (!window.confirm("Delete this classified permanently?")) return + setBusyId(id) + setError(null) + try { + const res = await apiFetch(`/v1/classifieds/${id}`, { method: "DELETE" }) + if (!res.ok) throw new Error(`Failed to delete classified (${res.status})`) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete classified") + } finally { + setBusyId(null) + } + } + + return ( +
+
+

Classifieds

+

+ Reader submissions awaiting review. Approving here and clicking Approve on the Slack + notification do the same thing. +

+
+ +
+ {STATUS_FILTERS.map((status) => ( + + ))} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +

Loading…

+ ) : items.length === 0 ? ( +

Nothing here.

+ ) : ( +
+ {items.map((item) => ( +
+
+
+ + {item.status} + + {item.label && ( + + {item.label} + + )} + + {isExpired(item.end_date) ? `Expired ${formatEndDate(item.end_date)}` : `Runs until ${formatEndDate(item.end_date)}`} + +
+ Submitted {formatDate(item.created_at)} +
+ +

{item.message}

+ +
+
+ {item.name} + + + + {item.decided_by && ( + + {item.decided_via === "slack" && + )} +
+ +
+ {item.status !== "approved" && ( + + )} + {item.status !== "rejected" && ( + + )} + {isAdmin && ( + + )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/server/docs/docs.go b/server/docs/docs.go index e00a8e9..795e3c2 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1182,6 +1182,260 @@ const docTemplate = `{ } } }, + "/v1/classifieds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "List published classifieds", + "parameters": [ + { + "type": "integer", + "default": 50, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ClassifiedsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Submit a classified", + "parameters": [ + { + "description": "Classified submission", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClassifiedSubmitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Classified" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/v1/classifieds/manage": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "List classifieds for moderation", + "parameters": [ + { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "default": 25, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ClassifiedsManageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/v1/classifieds/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Delete a classified", + "parameters": [ + { + "type": "integer", + "description": "Classified ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Set a classified's status", + "parameters": [ + { + "type": "integer", + "description": "Classified ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New status", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClassifiedStatusPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Classified" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/comments": { "get": { "security": [ @@ -1603,6 +1857,47 @@ const docTemplate = `{ } } }, + "/v1/integrations/slack/classifieds": { + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Slack interactivity callback for classified moderation", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/media": { "get": { "security": [ @@ -4431,6 +4726,103 @@ const docTemplate = `{ } } }, + "models.Classified": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "decided_at": { + "type": "string" + }, + "decided_by": { + "type": "string" + }, + "decided_via": { + "type": "string" + }, + "email": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.ClassifiedStatusPatchRequest": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "models.ClassifiedSubmitRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ClassifiedsManageResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "pagination": { + "$ref": "#/definitions/models.Pagination" + } + } + }, + "models.ClassifiedsResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + } + } + }, "models.CommentInput": { "type": "object", "properties": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 34c2dc9..2194b46 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -1179,6 +1179,260 @@ } } }, + "/v1/classifieds": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "List published classifieds", + "parameters": [ + { + "type": "integer", + "default": 50, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ClassifiedsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Submit a classified", + "parameters": [ + { + "description": "Classified submission", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClassifiedSubmitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Classified" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/v1/classifieds/manage": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "List classifieds for moderation", + "parameters": [ + { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "default": 25, + "description": "Max results", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.ClassifiedsManageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/v1/classifieds/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Delete a classified", + "parameters": [ + { + "type": "integer", + "description": "Classified ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Set a classified's status", + "parameters": [ + { + "type": "integer", + "description": "Classified ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New status", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ClassifiedStatusPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Classified" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/comments": { "get": { "security": [ @@ -1600,6 +1854,47 @@ } } }, + "/v1/integrations/slack/classifieds": { + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "classifieds" + ], + "summary": "Slack interactivity callback for classified moderation", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/media": { "get": { "security": [ @@ -4428,6 +4723,103 @@ } } }, + "models.Classified": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "decided_at": { + "type": "string" + }, + "decided_by": { + "type": "string" + }, + "decided_via": { + "type": "string" + }, + "email": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.ClassifiedStatusPatchRequest": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "models.ClassifiedSubmitRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ClassifiedsManageResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "pagination": { + "$ref": "#/definitions/models.Pagination" + } + } + }, + "models.ClassifiedsResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + } + } + }, "models.CommentInput": { "type": "object", "properties": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index f5f1bcc..198786e 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -419,6 +419,69 @@ definitions: slug: type: string type: object + models.Classified: + properties: + created_at: + type: string + decided_at: + type: string + decided_by: + type: string + decided_via: + type: string + email: + type: string + end_date: + type: string + id: + type: integer + label: + type: string + message: + type: string + name: + type: string + status: + type: string + type: object + models.ClassifiedStatusPatchRequest: + properties: + status: + type: string + type: object + models.ClassifiedSubmitRequest: + properties: + email: + type: string + end_date: + type: string + label: + type: string + message: + type: string + name: + type: string + type: object + models.ClassifiedsManageResponse: + properties: + classifieds: + items: + $ref: '#/definitions/models.Classified' + type: array + counts: + additionalProperties: + type: integer + type: object + pagination: + $ref: '#/definitions/models.Pagination' + type: object + models.ClassifiedsResponse: + properties: + classifieds: + items: + $ref: '#/definitions/models.Classified' + type: array + type: object models.CommentInput: properties: author_email: @@ -1751,6 +1814,169 @@ paths: summary: Restore an archived author tags: - authors + /v1/classifieds: + get: + parameters: + - default: 50 + description: Max results + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.ClassifiedsResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: List published classifieds + tags: + - classifieds + post: + consumes: + - application/json + parameters: + - description: Classified submission + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.ClassifiedSubmitRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Classified' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Submit a classified + tags: + - classifieds + /v1/classifieds/{id}: + delete: + parameters: + - description: Classified ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a classified + tags: + - classifieds + patch: + consumes: + - application/json + parameters: + - description: Classified ID + in: path + name: id + required: true + type: integer + - description: New status + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.ClassifiedStatusPatchRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Classified' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Set a classified's status + tags: + - classifieds + /v1/classifieds/manage: + get: + parameters: + - description: Filter by status + enum: + - pending + - approved + - rejected + in: query + name: status + type: string + - default: 25 + description: Max results + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.ClassifiedsManageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: List classifieds for moderation + tags: + - classifieds /v1/comments: get: parameters: @@ -2020,6 +2246,33 @@ paths: summary: Get homepage data tags: - homepage + /v1/integrations/slack/classifieds: + post: + consumes: + - application/x-www-form-urlencoded + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Slack interactivity callback for classified moderation + tags: + - classifieds /v1/media: get: parameters: diff --git a/server/internal/database/classifieds.go b/server/internal/database/classifieds.go new file mode 100644 index 0000000..8cf9c20 --- /dev/null +++ b/server/internal/database/classifieds.go @@ -0,0 +1,225 @@ +package database + +import ( + "context" + "database/sql" + "strings" + "time" + + "server/internal/models" +) + +// Classified statuses. A submission lands as pending and is moved by an editor, +// either from the CMS queue or by clicking a button on the Slack notification. +var ValidClassifiedStatuses = map[string]bool{ + models.ClassifiedStatusPending: true, + models.ClassifiedStatusApproved: true, + models.ClassifiedStatusRejected: true, +} + +const classifiedColumns = "id, contact_name, contact_email, label, message, end_date, status, decided_at, decided_by, decided_via, created_at" + +func EnsureClassifiedsTable(ctx context.Context, conn *sql.DB) error { + if _, err := conn.ExecContext(ctx, TableSchema("classifieds")); err != nil { + return err + } + + // Expand-only migration for databases that already have the table: the + // CREATE above is a no-op there, so a column added to + // schema/classifieds.sql reaches them only through this block. Keep the two + // in step. + _, err := conn.ExecContext(ctx, ` + ALTER TABLE classifieds + ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255) NOT NULL, + ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255) NOT NULL, + ADD COLUMN IF NOT EXISTS label VARCHAR(64) NOT NULL, + ADD COLUMN IF NOT EXISTS message LONGTEXT NOT NULL, + ADD COLUMN IF NOT EXISTS end_date DATE NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS submitter_ip VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS decided_at DATETIME NULL, + ADD COLUMN IF NOT EXISTS decided_by VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS decided_via VARCHAR(32) NULL, + ADD COLUMN IF NOT EXISTS created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + `) + return err +} + +// InsertClassified stores a new submission as pending and returns it. +func InsertClassified(ctx context.Context, conn *sql.DB, c models.ClassifiedSubmitRequest, ip string) (models.Classified, error) { + var endDate any + if parsed := ParseClassifiedEndDate(c.EndDate); parsed != nil { + endDate = parsed.Format("2006-01-02") + } + + res, err := conn.ExecContext(ctx, ` + INSERT INTO classifieds (contact_name, contact_email, label, message, end_date, status, submitter_ip) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, c.Name, c.Email, c.Label, c.Message, endDate, models.ClassifiedStatusPending, ip) + if err != nil { + return models.Classified{}, err + } + + id, err := res.LastInsertId() + if err != nil { + return models.Classified{}, err + } + return GetClassified(ctx, conn, id) +} + +// GetClassified reads a single classified by id. +func GetClassified(ctx context.Context, conn *sql.DB, id int64) (models.Classified, error) { + row := conn.QueryRowContext(ctx, "SELECT "+classifiedColumns+" FROM classifieds WHERE id = ? LIMIT 1", id) + return scanClassifiedRow(row) +} + +// ListPublicClassifieds returns the classifieds the public site shows: approved +// and not past their end date. A row with no end date never expires. +func ListPublicClassifieds(ctx context.Context, conn *sql.DB, limit, offset int) ([]models.Classified, error) { + rows, err := conn.QueryContext(ctx, ` + SELECT `+classifiedColumns+` FROM classifieds + WHERE status = ? AND (end_date IS NULL OR end_date >= CURDATE()) + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `, models.ClassifiedStatusApproved, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + return collectClassifieds(rows) +} + +// ListClassifieds is the editor-facing listing: every status, newest first, +// optionally filtered to one status. +func ListClassifieds(ctx context.Context, conn *sql.DB, status string, limit, offset int) ([]models.Classified, int, error) { + where := "" + args := []any{} + if status != "" { + where = " WHERE status = ?" + args = append(args, status) + } + + var totalCount int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM classifieds"+where, args...).Scan(&totalCount); err != nil { + return nil, 0, err + } + + rows, err := conn.QueryContext(ctx, "SELECT "+classifiedColumns+" FROM classifieds"+where+" ORDER BY created_at DESC LIMIT ? OFFSET ?", append(args, limit, offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items, err := collectClassifieds(rows) + if err != nil { + return nil, 0, err + } + return items, totalCount, nil +} + +// CountClassifiedsByStatus powers the filter tabs in the moderation queue. +func CountClassifiedsByStatus(ctx context.Context, conn *sql.DB) (map[string]int, error) { + rows, err := conn.QueryContext(ctx, "SELECT status, COUNT(*) FROM classifieds GROUP BY status") + if err != nil { + return nil, err + } + defer rows.Close() + + counts := map[string]int{} + total := 0 + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, err + } + counts[status] = count + total += count + } + counts["all"] = total + return counts, rows.Err() +} + +// SetClassifiedStatus records a moderation decision. decidedVia distinguishes a +// click in the CMS queue from one on the Slack notification, which is worth +// keeping: the two paths are authenticated completely differently. +func SetClassifiedStatus(ctx context.Context, conn *sql.DB, id int64, status, decidedBy, decidedVia string) error { + _, err := conn.ExecContext(ctx, ` + UPDATE classifieds + SET status = ?, decided_at = ?, decided_by = ?, decided_via = ? + WHERE id = ? + `, status, time.Now().UTC(), decidedBy, decidedVia, id) + return err +} + +func DeleteClassified(ctx context.Context, conn *sql.DB, id int64) (bool, error) { + res, err := conn.ExecContext(ctx, "DELETE FROM classifieds WHERE id = ?", id) + if err != nil { + return false, err + } + affected, err := res.RowsAffected() + return affected > 0, err +} + +// ParseClassifiedEndDate accepts the date formats the public submission form +// can produce. An unparseable or empty value means "no expiry" rather than an +// error, so a malformed date never costs the submission. +func ParseClassifiedEndDate(raw string) *time.Time { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02 15:04:05", "01/02/2006"} { + if parsed, err := time.Parse(layout, value); err == nil { + return &parsed + } + } + return nil +} + +type classifiedScanner interface { + Scan(dest ...any) error +} + +func scanClassifiedRow(row classifiedScanner) (models.Classified, error) { + var ( + c models.Classified + endDate sql.NullTime + decidedAt sql.NullTime + decidedBy sql.NullString + decidedVia sql.NullString + createdAt sql.NullTime + ) + + if err := row.Scan(&c.ID, &c.Name, &c.Email, &c.Label, &c.Message, &endDate, &c.Status, &decidedAt, &decidedBy, &decidedVia, &createdAt); err != nil { + return models.Classified{}, err + } + + if endDate.Valid { + c.EndDate = endDate.Time.Format("2006-01-02") + } + if decidedAt.Valid { + decided := decidedAt.Time.UTC() + c.DecidedAt = &decided + } + c.DecidedBy = decidedBy.String + c.DecidedVia = decidedVia.String + if createdAt.Valid { + created := createdAt.Time.UTC() + c.CreatedAt = &created + } + return c, nil +} + +func collectClassifieds(rows *sql.Rows) ([]models.Classified, error) { + items := make([]models.Classified, 0) + for rows.Next() { + item, err := scanClassifiedRow(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} diff --git a/server/internal/database/schema/classifieds.sql b/server/internal/database/schema/classifieds.sql new file mode 100644 index 0000000..d15c35a --- /dev/null +++ b/server/internal/database/schema/classifieds.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS classifieds ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + contact_name VARCHAR(255) NOT NULL, + contact_email VARCHAR(255) NOT NULL, + label VARCHAR(64) NOT NULL, + message LONGTEXT NOT NULL, + end_date DATE NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + submitter_ip VARCHAR(255) NULL, + decided_at DATETIME NULL, + decided_by VARCHAR(255) NULL, + decided_via VARCHAR(32) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_classifieds_status_end_date (status, end_date), + INDEX idx_classifieds_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 diff --git a/server/internal/database/schema_test.go b/server/internal/database/schema_test.go index 411271c..7f1e96b 100644 --- a/server/internal/database/schema_test.go +++ b/server/internal/database/schema_test.go @@ -15,7 +15,7 @@ import ( // local seed. A rename here silently breaks the seed generator, which has no // tests of its own, so this is the guard for both consumers. func TestTableSchema_CoversEveryCMSOwnedTable(t *testing.T) { - for _, table := range []string{"comments", "cms_poll_counts", "site_taxonomy"} { + for _, table := range []string{"classifieds", "comments", "cms_poll_counts", "site_taxonomy"} { t.Run(table, func(t *testing.T) { got := TableSchema(table) diff --git a/server/internal/handlers/classifieds.go b/server/internal/handlers/classifieds.go new file mode 100644 index 0000000..c0ac400 --- /dev/null +++ b/server/internal/handlers/classifieds.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + + "server/internal/activity" + db "server/internal/database" + "server/internal/middleware" + "server/internal/models" +) + +const ( + maxClassifiedMessageLength = 2000 + maxClassifiedFieldLength = 255 +) + +// GetClassifieds is the public listing: approved and unexpired only. +// +// @Summary List published classifieds +// @Tags classifieds +// @Produce json +// @Param limit query int false "Max results" default(50) +// @Param offset query int false "Offset" +// @Success 200 {object} models.ClassifiedsResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/classifieds [get] +func GetClassifieds(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, limit, offset := listParams(r, 50) + items, err := db.ListPublicClassifieds(r.Context(), conn, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, models.ClassifiedsResponse{Classifieds: items}) + }) +} + +// PostClassified accepts a submission from the public form. It always lands as +// pending: nothing a reader posts reaches the site without a moderator, whether +// that moderator clicks in the CMS or in Slack. +// +// @Summary Submit a classified +// @Tags classifieds +// @Accept json +// @Produce json +// @Param body body models.ClassifiedSubmitRequest true "Classified submission" +// @Success 201 {object} models.Classified +// @Failure 400 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/classifieds [post] +func PostClassified(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body models.ClassifiedSubmitRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + body.Name = strings.TrimSpace(body.Name) + body.Email = strings.TrimSpace(body.Email) + body.Label = strings.TrimSpace(body.Label) + body.Message = strings.TrimSpace(body.Message) + body.EndDate = strings.TrimSpace(body.EndDate) + + switch { + case body.Name == "": + writeError(w, http.StatusBadRequest, "name is required") + return + case body.Email == "" || !strings.Contains(body.Email, "@"): + writeError(w, http.StatusBadRequest, "a valid email is required") + return + case body.Message == "": + writeError(w, http.StatusBadRequest, "message is required") + return + case len(body.Message) > maxClassifiedMessageLength: + writeError(w, http.StatusBadRequest, "message is too long") + return + case len(body.Name) > maxClassifiedFieldLength || len(body.Email) > maxClassifiedFieldLength: + writeError(w, http.StatusBadRequest, "name or email is too long") + return + case len(body.Label) > 64: + writeError(w, http.StatusBadRequest, "category is too long") + return + } + + created, err := db.InsertClassified(r.Context(), conn, body, clientIP(r)) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, created) + }) +} + +// GetClassifiedsManage is the moderation queue's listing: every status, with +// per-status counts for the filter tabs. +// +// @Summary List classifieds for moderation +// @Tags classifieds +// @Produce json +// @Param status query string false "Filter by status" Enums(pending, approved, rejected) +// @Param limit query int false "Max results" default(25) +// @Param offset query int false "Offset" +// @Success 200 {object} models.ClassifiedsManageResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/classifieds/manage [get] +func GetClassifiedsManage(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status"))) + if status != "" && status != "all" && !db.ValidClassifiedStatuses[status] { + writeError(w, http.StatusBadRequest, "invalid status") + return + } + if status == "all" { + status = "" + } + + page, limit, offset := listParams(r, 25) + items, totalCount, err := db.ListClassifieds(r.Context(), conn, status, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + counts, err := db.CountClassifiedsByStatus(r.Context(), conn) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, models.ClassifiedsManageResponse{ + Classifieds: items, + Pagination: paginationResponse(page, limit, offset, offset+len(items) < totalCount, totalCount), + Counts: counts, + }) + }) +} + +// PatchClassified moves a classified between statuses from the CMS queue. +// +// @Summary Set a classified's status +// @Tags classifieds +// @Accept json +// @Produce json +// @Param id path int true "Classified ID" +// @Param body body models.ClassifiedStatusPatchRequest true "New status" +// @Success 200 {object} models.Classified +// @Failure 400 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/classifieds/{id} [patch] +func PatchClassified(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := classifiedIDParam(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + var body models.ClassifiedStatusPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + status := strings.ToLower(strings.TrimSpace(body.Status)) + if !db.ValidClassifiedStatuses[status] { + writeError(w, http.StatusBadRequest, "invalid status") + return + } + + if _, err := db.GetClassified(r.Context(), conn, id); err != nil { + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "classified not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + decidedBy := "unknown" + if user, ok := middleware.UserFromContext(r.Context()); ok && user != nil { + decidedBy = user.Email + } + + if err := db.SetClassifiedStatus(r.Context(), conn, id, status, decidedBy, "cms"); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + updated, err := db.GetClassified(r.Context(), conn, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + activity.LogRequest(r, "classified_moderated", "Classified "+strconv.FormatInt(id, 10)+" "+status, "status", status) + writeJSON(w, http.StatusOK, updated) + }) +} + +// @Summary Delete a classified +// @Tags classifieds +// @Produce json +// @Param id path int true "Classified ID" +// @Success 204 +// @Failure 400 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/classifieds/{id} [delete] +func DeleteClassified(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := classifiedIDParam(r) + if !ok { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + deleted, err := db.DeleteClassified(r.Context(), conn, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !deleted { + writeError(w, http.StatusNotFound, "classified not found") + return + } + activity.LogRequest(r, "classified_deleted", "Classified "+strconv.FormatInt(id, 10)+" deleted") + w.WriteHeader(http.StatusNoContent) + }) +} + +func classifiedIDParam(r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} diff --git a/server/internal/handlers/slack.go b/server/internal/handlers/slack.go new file mode 100644 index 0000000..eeb97d4 --- /dev/null +++ b/server/internal/handlers/slack.go @@ -0,0 +1,201 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "server/internal/activity" + db "server/internal/database" + "server/internal/models" +) + +// Slack's interactivity contract: the request is signed with the app's signing +// secret over the raw body, and anything older than five minutes is refused as +// a replay. See https://api.slack.com/authentication/verifying-requests-from-slack +const ( + slackSignatureVersion = "v0" + slackMaxRequestAge = 5 * time.Minute + // Slack truncates a request body it considers oversized, and an unbounded + // read here would be a free memory sink on an unauthenticated endpoint. + slackMaxBodyBytes = 1 << 20 +) + +// slackInteractionPayload is the subset of Slack's block_actions payload this +// endpoint uses. +type slackInteractionPayload struct { + Type string `json:"type"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + } `json:"user"` + Actions []struct { + ActionID string `json:"action_id"` + Value string `json:"value"` + } `json:"actions"` +} + +// PostSlackClassifiedAction handles Approve/Reject clicks on the Slack +// notification posted when a classified is submitted. It is unauthenticated in +// the CMS's own terms — Slack has no session — so the signature check is the +// entire authorization story and must run before anything else touches the body. +// +// @Summary Slack interactivity callback for classified moderation +// @Tags classifieds +// @Accept x-www-form-urlencoded +// @Produce json +// @Success 200 {object} map[string]any +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 503 {object} models.ErrorResponse +// @Router /v1/integrations/slack/classifieds [post] +func PostSlackClassifiedAction(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := strings.TrimSpace(os.Getenv("SLACK_SIGNING_SECRET")) + if secret == "" { + // Without the secret every request is unverifiable, so the endpoint + // refuses to act rather than trusting the caller. + writeError(w, http.StatusServiceUnavailable, "slack integration is not configured") + return + } + + rawBody, err := io.ReadAll(io.LimitReader(r.Body, slackMaxBodyBytes)) + if err != nil { + writeError(w, http.StatusBadRequest, "could not read request body") + return + } + + if !verifySlackRequest(secret, r.Header.Get("X-Slack-Request-Timestamp"), string(rawBody), r.Header.Get("X-Slack-Signature"), time.Now()) { + writeError(w, http.StatusUnauthorized, "invalid slack signature") + return + } + + form, err := url.ParseQuery(string(rawBody)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid form body") + return + } + + var payload slackInteractionPayload + if err := json.Unmarshal([]byte(form.Get("payload")), &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid payload") + return + } + if len(payload.Actions) == 0 { + writeError(w, http.StatusBadRequest, "no action in payload") + return + } + + action := payload.Actions[0] + var status string + switch strings.ToLower(strings.TrimSpace(action.ActionID)) { + case "approved", "approve": + status = models.ClassifiedStatusApproved + case "rejected", "reject": + status = models.ClassifiedStatusRejected + default: + writeError(w, http.StatusBadRequest, "unknown action") + return + } + + // The button value is the classified's row id. Messages posted by the + // old flow carried a base64 blob instead, and those submissions were + // never persisted anywhere this server can reach. + id, err := strconv.ParseInt(strings.TrimSpace(action.Value), 10, 64) + if err != nil || id <= 0 { + slackReply(w, ":warning: This message predates the CMS classifieds queue — approve it from the CMS instead.") + return + } + + classified, err := db.GetClassified(r.Context(), conn, id) + if err != nil { + if err == sql.ErrNoRows { + slackReply(w, ":warning: That classified no longer exists.") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Clicking a button on a stale message must not silently reverse a + // decision someone already made in the CMS. + if classified.Status != models.ClassifiedStatusPending { + slackReply(w, ":information_source: Already "+classified.Status+" by "+decidedByLabel(classified)+".") + return + } + + decidedBy := strings.TrimSpace(payload.User.Username) + if decidedBy == "" { + decidedBy = strings.TrimSpace(payload.User.Name) + } + if decidedBy == "" { + decidedBy = payload.User.ID + } + + if err := db.SetClassifiedStatus(r.Context(), conn, id, status, decidedBy, "slack"); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + activity.LogRequest(r, "classified_moderated", "Classified "+strconv.FormatInt(id, 10)+" "+status+" via Slack", "status", status, "actor", decidedBy) + + emoji := ":white_check_mark:" + if status == models.ClassifiedStatusRejected { + emoji = ":x:" + } + slackReply(w, emoji+" *"+status+"* by @"+decidedBy+"\n>"+strings.ReplaceAll(classified.Message, "\n", "\n>")) + }) +} + +func decidedByLabel(c models.Classified) string { + if strings.TrimSpace(c.DecidedBy) == "" { + return "someone else" + } + return c.DecidedBy +} + +// slackReply replaces the original Slack message with the outcome, so the +// buttons cannot be clicked twice and the channel shows who decided what. +func slackReply(w http.ResponseWriter, text string) { + writeJSON(w, http.StatusOK, map[string]any{ + "replace_original": true, + "text": text, + }) +} + +// verifySlackRequest checks Slack's request signature. It is a pure function so +// the signing rules can be tested without standing up an HTTP server. +func verifySlackRequest(secret, timestamp, body, signature string, now time.Time) bool { + if secret == "" || timestamp == "" || signature == "" { + return false + } + + ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) + if err != nil { + return false + } + age := now.Sub(time.Unix(ts, 0)) + if age < 0 { + age = -age + } + if age > slackMaxRequestAge { + return false + } + + mac := hmac.New(sha256.New, []byte(secret)) + io.WriteString(mac, slackSignatureVersion+":"+timestamp+":"+body) + expected := slackSignatureVersion + "=" + hex.EncodeToString(mac.Sum(nil)) + + // Constant-time: a byte-by-byte comparison would leak the expected digest + // to a caller who can time the response. + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/server/internal/handlers/slack_test.go b/server/internal/handlers/slack_test.go new file mode 100644 index 0000000..cd916df --- /dev/null +++ b/server/internal/handlers/slack_test.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http/httptest" + "testing" + "time" +) + +const testSigningSecret = "8f742231b10e8888abcd99yyyzzz85a5" + +func signSlackBody(secret, timestamp, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + io.WriteString(mac, "v0:"+timestamp+":"+body) + return "v0=" + hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifySlackRequest_AcceptsAValidSignature(t *testing.T) { + now := time.Unix(1700000000, 0) + ts := "1700000000" + body := "payload=%7B%22type%22%3A%22block_actions%22%7D" + + if !verifySlackRequest(testSigningSecret, ts, body, signSlackBody(testSigningSecret, ts, body), now) { + t.Fatal("expected a correctly signed request to verify") + } +} + +func TestVerifySlackRequest_RejectsTamperedBody(t *testing.T) { + now := time.Unix(1700000000, 0) + ts := "1700000000" + signature := signSlackBody(testSigningSecret, ts, "payload=original") + + if verifySlackRequest(testSigningSecret, ts, "payload=tampered", signature, now) { + t.Fatal("expected a body that does not match the signature to be rejected") + } +} + +func TestVerifySlackRequest_RejectsWrongSecret(t *testing.T) { + now := time.Unix(1700000000, 0) + ts := "1700000000" + body := "payload=x" + + if verifySlackRequest(testSigningSecret, ts, body, signSlackBody("not-the-secret", ts, body), now) { + t.Fatal("expected a signature from another secret to be rejected") + } +} + +// A captured request must not stay valid forever, in either time direction. +func TestVerifySlackRequest_RejectsStaleAndFutureTimestamps(t *testing.T) { + body := "payload=x" + for name, skew := range map[string]time.Duration{ + "stale": -10 * time.Minute, + "future": 10 * time.Minute, + } { + t.Run(name, func(t *testing.T) { + signedAt := time.Unix(1700000000, 0) + ts := "1700000000" + now := signedAt.Add(-skew) + + if verifySlackRequest(testSigningSecret, ts, body, signSlackBody(testSigningSecret, ts, body), now) { + t.Fatalf("expected a %s timestamp to be rejected", name) + } + }) + } +} + +func TestVerifySlackRequest_RejectsMissingParts(t *testing.T) { + now := time.Unix(1700000000, 0) + cases := map[string][4]string{ + "no secret": {"", "1700000000", "b", "v0=abc"}, + "no timestamp": {testSigningSecret, "", "b", "v0=abc"}, + "no signature": {testSigningSecret, "1700000000", "b", ""}, + "bad timestamp": {testSigningSecret, "not-a-number", "b", "v0=abc"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if verifySlackRequest(c[0], c[1], c[2], c[3], now) { + t.Fatal("expected verification to fail") + } + }) + } +} + +// Without a signing secret nothing can be verified, so the endpoint must refuse +// rather than fall open. +func TestPostSlackClassifiedAction_RefusesWhenUnconfigured(t *testing.T) { + t.Setenv("SLACK_SIGNING_SECRET", "") + + req := httptest.NewRequest("POST", "/v1/integrations/slack/classifieds", nil) + rec := httptest.NewRecorder() + PostSlackClassifiedAction(nil).ServeHTTP(rec, req) + + if rec.Code != 503 { + t.Fatalf("expected 503 when the signing secret is unset, got %d", rec.Code) + } +} + +// An unsigned request must be turned away before the body is parsed or the +// database is touched — the nil *sql.DB here would panic if it were not. +func TestPostSlackClassifiedAction_RejectsUnsignedRequest(t *testing.T) { + t.Setenv("SLACK_SIGNING_SECRET", testSigningSecret) + + req := httptest.NewRequest("POST", "/v1/integrations/slack/classifieds", nil) + rec := httptest.NewRecorder() + PostSlackClassifiedAction(nil).ServeHTTP(rec, req) + + if rec.Code != 401 { + t.Fatalf("expected 401 for an unsigned request, got %d", rec.Code) + } +} diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go index 3d4b12f..0b07816 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -197,6 +197,55 @@ type HomepageResponse struct { Columns []ArticleListItem `json:"columns"` } +// Classified moderation statuses. +const ( + ClassifiedStatusPending = "pending" + ClassifiedStatusApproved = "approved" + ClassifiedStatusRejected = "rejected" +) + +// Classified is a reader-submitted classified ad. Name and email are shown +// publicly once approved — that is the point of the listing, it is how readers +// answer an ad. +type Classified struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Label string `json:"label"` + Message string `json:"message"` + EndDate string `json:"end_date"` + Status string `json:"status"` + DecidedAt *time.Time `json:"decided_at,omitempty"` + DecidedBy string `json:"decided_by,omitempty"` + DecidedVia string `json:"decided_via,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// ClassifiedSubmitRequest is what the public submission form posts. +type ClassifiedSubmitRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Label string `json:"label"` + Message string `json:"message"` + EndDate string `json:"end_date"` +} + +type ClassifiedsResponse struct { + Classifieds []Classified `json:"classifieds"` +} + +// ClassifiedsManageResponse is the editor-facing listing, with the per-status +// counts the moderation queue's filter tabs display. +type ClassifiedsManageResponse struct { + Classifieds []Classified `json:"classifieds"` + Pagination Pagination `json:"pagination"` + Counts map[string]int `json:"counts"` +} + +type ClassifiedStatusPatchRequest struct { + Status string `json:"status"` +} + // RandomArticleResponse is the "surprise me" target for the public site, which // only needs a slug to redirect to. type RandomArticleResponse struct { diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go index f93b38f..3aaa1f7 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -50,6 +50,19 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("GET /v1/articles/random", handlers.GetRandomArticle(conn)) mux.Handle("GET /v1/articles/{slug}", optionalAuth(handlers.GetArticle(conn))) mux.Handle("GET /v1/sitemap/slugs", handlers.GetSitemapSlugs(conn)) + + // Classifieds. Submission is public and rate limited like comments; + // everything a reader submits lands as pending. Moderation happens either + // in the CMS queue (authenticated) or from the buttons on the Slack + // notification, which authenticates by Slack request signature instead of a + // session — hence no authMW on that route. "manage" is a literal segment, + // so Go's mux prefers it over /v1/classifieds/{id}. + mux.Handle("GET /v1/classifieds", handlers.GetClassifieds(conn)) + mux.Handle("POST /v1/classifieds", middleware.RateLimitByIP(5, time.Minute)(handlers.PostClassified(conn))) + mux.Handle("GET /v1/classifieds/manage", authMW(handlers.GetClassifiedsManage(conn))) + mux.Handle("PATCH /v1/classifieds/{id}", authMW(handlers.PatchClassified(conn))) + mux.Handle("DELETE /v1/classifieds/{id}", authMW(adminOnly(handlers.DeleteClassified(conn)))) + mux.Handle("POST /v1/integrations/slack/classifieds", middleware.RateLimitByIP(30, time.Minute)(handlers.PostSlackClassifiedAction(conn))) // The public photo gallery. The editor-facing catalogue at // /v1/media/gallery stays authenticated; this one is images-only. mux.Handle("GET /v1/gallery", handlers.GetPublicGallery(conn)) diff --git a/server/main.go b/server/main.go index 872d3e4..4aa8be7 100644 --- a/server/main.go +++ b/server/main.go @@ -162,6 +162,10 @@ func main() { slog.Error("failed to create taxonomy table", "error", err) os.Exit(1) } + if err := database.EnsureClassifiedsTable(context.Background(), db); err != nil { + slog.Error("failed to create classifieds table", "error", err) + os.Exit(1) + } if strings.EqualFold(strings.TrimSpace(os.Getenv("CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP")), "true") { if err := database.RebuildTaxonomyArticleCounts(context.Background(), db); err != nil { slog.Error("failed to rebuild taxonomy article counts", "error", err) @@ -232,6 +236,9 @@ func main() { slog.Error("invalid Akismet configuration", "error", err) os.Exit(1) } + if strings.TrimSpace(os.Getenv("SLACK_SIGNING_SECRET")) == "" { + slog.Warn("Slack classified moderation disabled: SLACK_SIGNING_SECRET not set; the interactivity endpoint will refuse requests") + } if spamChecker == nil { slog.Warn("Akismet comment spam filtering disabled: AKISMET_API_KEY not set") } else { From bd14c95c4c2dcff47ff454b231f44d89e9f0984f Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 31 Jul 2026 20:47:33 -0400 Subject: [PATCH 4/4] feat(classifieds): make a broken Slack integration announce itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/classifiedsView.tsx | 18 +++++- server/docs/docs.go | 3 + server/docs/swagger.json | 3 + server/docs/swagger.yaml | 2 + server/internal/handlers/classifieds.go | 7 ++- server/internal/handlers/slack.go | 83 ++++++++++++++++++++++--- server/internal/handlers/slack_test.go | 55 ++++++++++++++++ server/internal/models/api_responses.go | 11 ++-- 8 files changed, 165 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/classifiedsView.tsx b/frontend/src/pages/classifiedsView.tsx index fff507c..02a2356 100644 --- a/frontend/src/pages/classifiedsView.tsx +++ b/frontend/src/pages/classifiedsView.tsx @@ -23,6 +23,7 @@ type Classified = { type ManageResponse = { classifieds?: Classified[] counts?: Partial> + slack_configured?: boolean } const STATUS_FILTERS: StatusFilter[] = ["all", "pending", "approved", "rejected"] @@ -67,6 +68,9 @@ export default function ClassifiedsView() { const [filter, setFilter] = useState("pending") const [items, setItems] = useState([]) const [counts, setCounts] = useState>>({}) + // Assume Slack works until told otherwise, so a failed load does not flash a + // scary banner that has nothing to do with what went wrong. + const [slackConfigured, setSlackConfigured] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [busyId, setBusyId] = useState(null) @@ -80,6 +84,7 @@ export default function ClassifiedsView() { const body = (await res.json()) as ManageResponse setItems(body.classifieds ?? []) setCounts(body.counts ?? {}) + setSlackConfigured(body.slack_configured !== false) } catch (err) { setError(err instanceof Error ? err.message : "Failed to load classifieds") } finally { @@ -129,11 +134,20 @@ export default function ClassifiedsView() {

Classifieds

- Reader submissions awaiting review. Approving here and clicking Approve on the Slack - notification do the same thing. + {slackConfigured + ? "Reader submissions awaiting review. Approving here and clicking Approve on the Slack notification do the same thing." + : "Reader submissions awaiting review."}

+ {!slackConfigured && ( +
+ Slack approvals are unavailable — the server has no SLACK_SIGNING_SECRET, so + the Approve/Reject buttons on classified notifications will not work. Moderate here + instead; nothing is lost either way. +
+ )} +
{STATUS_FILTERS.map((status) => (