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() { } /> } /> } /> + } /> } /> + 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/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5f4eb1a..997bc9c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { MessageSquare, Activity, BarChart3, + ClipboardList, } from "lucide-react" import { cn } from "@/lib/utils" import { Separator } from "@/components/ui/separator" @@ -63,6 +64,7 @@ const navGroups: NavGroup[] = [ { icon: Users, label: "Authors", path: "/authors" }, { icon: Layers, label: "Sections", path: "/sections" }, { icon: MessageSquare, label: "Comments", path: "/comments" }, + { icon: ClipboardList, label: "Classifieds", path: "/classifieds" }, { icon: Search, label: "SEO", path: "/seo" }, ], }, @@ -82,6 +84,7 @@ export default function Sidebar() { const apiFetch = useApiFetch() const [collapsed, setCollapsed] = useState(false) const [pendingCommentCount, setPendingCommentCount] = useState(0) + const [pendingClassifiedCount, setPendingClassifiedCount] = useState(0) const [openGroups, setOpenGroups] = useState>({ "/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..02a2356 --- /dev/null +++ b/frontend/src/pages/classifiedsView.tsx @@ -0,0 +1,270 @@ +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> + slack_configured?: boolean +} + +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>>({}) + // 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) + + 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 ?? {}) + setSlackConfigured(body.slack_configured !== false) + } 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

+

+ {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) => ( + + ))} +
+ + {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/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..2f2822a 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.", @@ -1151,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": [ @@ -1454,6 +1739,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": [ @@ -1520,11 +1845,52 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/models.HomepageResponse" + "$ref": "#/definitions/models.HomepageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/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 } }, - "500": { - "description": "Internal Server Error", + "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" } @@ -2925,6 +3291,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": [ @@ -3095,6 +3534,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": [ @@ -4259,6 +4726,106 @@ 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" + }, + "slack_configured": { + "type": "boolean" + } + } + }, + "models.ClassifiedsResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + } + } + }, "models.CommentInput": { "type": "object", "properties": { @@ -4349,6 +4916,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": { @@ -4796,6 +5413,17 @@ const docTemplate = `{ } } }, + "models.RandomArticleResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.Role": { "type": "string", "enum": [ @@ -4941,6 +5569,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 a7bf3f6..0435ced 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.", @@ -1148,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": [ @@ -1451,6 +1736,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": [ @@ -1517,11 +1842,52 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/models.HomepageResponse" + "$ref": "#/definitions/models.HomepageResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/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 } }, - "500": { - "description": "Internal Server Error", + "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" } @@ -2922,6 +3288,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": [ @@ -3092,6 +3531,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": [ @@ -4256,6 +4723,106 @@ } } }, + "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" + }, + "slack_configured": { + "type": "boolean" + } + } + }, + "models.ClassifiedsResponse": { + "type": "object", + "properties": { + "classifieds": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Classified" + } + } + } + }, "models.CommentInput": { "type": "object", "properties": { @@ -4346,6 +4913,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": { @@ -4793,6 +5410,17 @@ } } }, + "models.RandomArticleResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.Role": { "type": "string", "enum": [ @@ -4938,6 +5566,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 1b77211..25a91d8 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -419,6 +419,71 @@ 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' + slack_configured: + type: boolean + type: object + models.ClassifiedsResponse: + properties: + classifieds: + items: + $ref: '#/definitions/models.Classified' + type: array + type: object models.CommentInput: properties: author_email: @@ -477,6 +542,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: @@ -767,6 +864,13 @@ definitions: total_votes: type: integer type: object + models.RandomArticleResponse: + properties: + slug: + type: string + title: + type: string + type: object models.Role: enum: - editor @@ -862,6 +966,13 @@ definitions: site_title: type: string type: object + models.SitemapSlug: + properties: + lastmod: + type: string + slug: + type: string + type: object models.SubsectionArticlesResponse: properties: articles: @@ -1356,6 +1467,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: @@ -1685,6 +1816,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: @@ -1878,6 +2172,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: @@ -1928,6 +2248,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: @@ -2819,6 +3166,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: @@ -2925,6 +3318,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/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/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/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..45054e3 --- /dev/null +++ b/server/internal/handlers/classifieds.go @@ -0,0 +1,246 @@ +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, + SlackConfigured: SlackInteractivityConfigured(), + }) + }) +} + +// 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/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/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/handlers/slack.go b/server/internal/handlers/slack.go new file mode 100644 index 0000000..65bd3ad --- /dev/null +++ b/server/internal/handlers/slack.go @@ -0,0 +1,268 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "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. + logSlackRejection(r, slackRejectionUnconfigured) + 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 reason := slackVerificationFailure(secret, r.Header.Get("X-Slack-Request-Timestamp"), string(rawBody), r.Header.Get("X-Slack-Signature"), time.Now()); reason != "" { + logSlackRejection(r, reason) + 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, + }) +} + +// Why a request was turned away. These are logged rather than returned to the +// caller: the response stays a flat "invalid slack signature" so a prober +// learns nothing about which check it failed. +const ( + slackRejectionUnconfigured = "signing_secret_not_set" + slackRejectionMissingParts = "missing_signature_headers" + slackRejectionBadTimestamp = "unparseable_timestamp" + slackRejectionStale = "timestamp_outside_replay_window" + slackRejectionBadDigest = "signature_mismatch" +) + +// verifySlackRequest checks Slack's request signature. +func verifySlackRequest(secret, timestamp, body, signature string, now time.Time) bool { + return slackVerificationFailure(secret, timestamp, body, signature, now) == "" +} + +// slackVerificationFailure returns "" when the request verifies, otherwise the +// reason it did not. It is a pure function so the signing rules can be tested +// without standing up an HTTP server. +func slackVerificationFailure(secret, timestamp, body, signature string, now time.Time) string { + if secret == "" { + return slackRejectionUnconfigured + } + if timestamp == "" || signature == "" { + return slackRejectionMissingParts + } + + ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) + if err != nil { + return slackRejectionBadTimestamp + } + age := now.Sub(time.Unix(ts, 0)) + if age < 0 { + age = -age + } + if age > slackMaxRequestAge { + return slackRejectionStale + } + + 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. + if !hmac.Equal([]byte(expected), []byte(signature)) { + return slackRejectionBadDigest + } + return "" +} + +// SlackInteractivityConfigured reports whether the signing secret is present, +// i.e. whether the Approve/Reject buttons can work at all. The CMS surfaces +// this so the moderation queue does not promise Slack approvals that would +// silently time out in the channel. +func SlackInteractivityConfigured() bool { + return strings.TrimSpace(os.Getenv("SLACK_SIGNING_SECRET")) != "" +} + +// A rejected request is worth knowing about — this is the one public write +// path that no session guards — but it is also trivially floodable, so the +// warnings are throttled and the ones dropped in between are counted into the +// next line rather than lost. +const slackRejectionLogInterval = time.Minute + +var slackRejectionLog struct { + mu sync.Mutex + lastAt time.Time + suppressed int +} + +func logSlackRejection(r *http.Request, reason string) { + slackRejectionLog.mu.Lock() + now := time.Now() + if !slackRejectionLog.lastAt.IsZero() && now.Sub(slackRejectionLog.lastAt) < slackRejectionLogInterval { + slackRejectionLog.suppressed++ + slackRejectionLog.mu.Unlock() + return + } + suppressed := slackRejectionLog.suppressed + slackRejectionLog.suppressed = 0 + slackRejectionLog.lastAt = now + slackRejectionLog.mu.Unlock() + + slog.Warn("rejected Slack interaction", + "reason", reason, + "ip", clientIP(r), + "suppressed_since_last", suppressed, + ) +} diff --git a/server/internal/handlers/slack_test.go b/server/internal/handlers/slack_test.go new file mode 100644 index 0000000..97309b4 --- /dev/null +++ b/server/internal/handlers/slack_test.go @@ -0,0 +1,168 @@ +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") + } + }) + } +} + +// The rejection reason is what makes a misconfiguration diagnosable in the +// logs, so each failure mode has to be distinguishable from the others. +func TestSlackVerificationFailure_ReportsWhichCheckFailed(t *testing.T) { + now := time.Unix(1700000000, 0) + ts := "1700000000" + body := "payload=x" + valid := signSlackBody(testSigningSecret, ts, body) + + cases := []struct { + name string + secret, timestamp, body, signature string + want string + }{ + {"valid", testSigningSecret, ts, body, valid, ""}, + {"no secret", "", ts, body, valid, slackRejectionUnconfigured}, + {"no signature header", testSigningSecret, ts, body, "", slackRejectionMissingParts}, + {"no timestamp header", testSigningSecret, "", body, valid, slackRejectionMissingParts}, + {"garbage timestamp", testSigningSecret, "not-a-number", body, valid, slackRejectionBadTimestamp}, + {"stale timestamp", testSigningSecret, "1699999000", body, valid, slackRejectionStale}, + {"wrong digest", testSigningSecret, ts, body, "v0=deadbeef", slackRejectionBadDigest}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := slackVerificationFailure(c.secret, c.timestamp, c.body, c.signature, now); got != c.want { + t.Fatalf("expected reason %q, got %q", c.want, got) + } + }) + } +} + +// Rejections are floodable by anyone who can reach the endpoint, so the +// warnings are throttled — but nothing may be silently dropped: what is +// suppressed has to be counted into the next line. +func TestLogSlackRejection_ThrottlesAndCountsSuppressed(t *testing.T) { + slackRejectionLog.mu.Lock() + slackRejectionLog.lastAt = time.Time{} + slackRejectionLog.suppressed = 0 + slackRejectionLog.mu.Unlock() + + req := httptest.NewRequest("POST", "/v1/integrations/slack/classifieds", nil) + logSlackRejection(req, slackRejectionBadDigest) // logs, starting the window + for i := 0; i < 5; i++ { + logSlackRejection(req, slackRejectionBadDigest) // suppressed + } + + slackRejectionLog.mu.Lock() + suppressed := slackRejectionLog.suppressed + slackRejectionLog.mu.Unlock() + + if suppressed != 5 { + t.Fatalf("expected 5 suppressed rejections to be counted, got %d", suppressed) + } +} + +// 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 4df1509..88ddd67 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -197,6 +197,72 @@ 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. SlackConfigured tells the +// queue whether the Approve/Reject buttons on Slack notifications can work, so +// it does not point editors at a path that would time out in the channel. +type ClassifiedsManageResponse struct { + Classifieds []Classified `json:"classifieds"` + Pagination Pagination `json:"pagination"` + Counts map[string]int `json:"counts"` + SlackConfigured bool `json:"slack_configured"` +} + +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 { + 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"` } @@ -216,6 +282,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..3aaa1f7 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -45,7 +45,27 @@ 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)) + + // 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)) 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)) @@ -73,6 +93,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 +128,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)))) 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 {