feat(comments): Akismet spam filtering, and one canonical source for CMS table schemas - #114
Merged
Merged
Conversation
New comments are checked against Akismet before they are stored, so the public comment form stops being an open write path to published content. - internal/akismet: a small Checker over the Akismet comment-check API, with the key and blog URL supplied by AKISMET_API_KEY / AKISMET_BLOG_URL. - PostArticleComment takes a Checker and maps the verdict to the stored status: spam is filed as "spam", a clean comment stays "approved". - A checker ERROR does not drop the comment and does not approve it either — the comment is stored as "pending" and logged, so an Akismet outage degrades to human moderation rather than to either data loss or an open door. - The Checker is optional: with no API key configured the checker is nil and behaviour is exactly as before, which keeps local dev and CI unchanged. Also seeds a comments table from the ETL (08-comments.sql, with a placeholder schema when the ETL artifact is absent) so the comment endpoints have something to read locally. Rebased onto post-#112 main. The routes.go conflict was resolved by keeping both sides: the spamChecker argument here, and #112's un-gating of search plus OptionalAuth on the section/subsection routes. The Akismet env vars are documented in cms.env.example and deploy/README.md only; they were deliberately kept out of cms.env.dryrun.example, which #113 deletes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DDL for CMS-owned tables was maintained in two places by hand: Go string literals in the Ensure* functions, and Python constants in scripts/generate_wordpress_sql.py. Nothing checked that the two agreed, and they had already drifted -- the seeded site_taxonomy had a SIGNED id, no ENGINE/CHARSET, and was missing idx_site_taxonomy_kind and idx_site_taxonomy_parent_slug, so a dev database did not match what production converges to at startup. Each definition now lives once, in server/internal/database/schema/*.sql: - Go embeds them (schema.go, //go:embed) and TableSchema() feeds EnsureCommentsTable, EnsurePollsTable and EnsureTaxonomyTable. - generate_wordpress_sql.py reads the same files via canonical_schema() and exits if one is missing, instead of carrying its own copies. There is no second copy left to drift. The Go definitions were taken as canonical, so the seed gains the columns and indexes it was missing. The expand-only ALTER blocks stay: CREATE TABLE is a no-op on an existing database, so a new column still has to be added in both the .sql file and the ALTER. Also hardens the id=0 preservation that the seed depends on. `articles` has a real row with id = 0, which survives only under NO_AUTO_VALUE_ON_ZERO; without it MariaDB renumbers the row to 1 and orphans the articles_authors and seo rows that reference it (verified against 11.7). The generator decided whether to prepend the preamble with a bare substring test, which a mere mention of the name in a comment would satisfy, silently skipping it. It now matches an actual SET statement, and re-reads what it wrote to refuse emitting any file that inserts rows without the mode in effect. Tests: TableSchema is covered for every CMS-owned table, including that the files stay statement-shaped and that PollTableName resolves to its file. The id=0 behaviour is server-side, so it gets a CMS_TEST_DSN-gated integration test following the existing convention; verified against MariaDB 11.7 both ways -- it passes with the preamble and the same insert yields MIN(id)=1 without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/server/**/*.sql in .gitignore exists to keep the generated ETL seed out of the repo, and it silently swallowed server/internal/database/schema/*.sql when they were added. The tree built locally because the files were on disk, but a clean checkout has no schema/ directory and go:embed fails at compile time: internal/database/schema.go:15:12: pattern schema/*.sql: no matching files found These files are source, not generated output, so the ignore rule now has a negation for that directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits, kept separate: the Akismet feature, then a schema refactor the feature exposed the need for.
1. Akismet comment spam filtering (
5288bf4)New comments are checked against Akismet before they are stored, so the public comment form stops being an unfiltered write path against published content.
internal/akismet— a smallCheckerover the comment-check API, keyed byAKISMET_API_KEY/AKISMET_BLOG_URL.PostArticleCommentmaps the verdict onto the stored status: spam is filed asspam, clean staysapproved.pendingand logged, so an Akismet outage degrades to human moderation rather than to data loss or an open door.Also seeds a
commentstable from the ETL (08-comments.sql, with a placeholder when the ETL artifact is absent).2. One canonical source for CMS table schemas (
86a9278)The DDL for CMS-owned tables was maintained twice by hand — Go string literals in the
Ensure*functions, and Python constants inscripts/generate_wordpress_sql.py. Nothing checked that the two agreed, and they had already drifted: the seededsite_taxonomyhad a signedid, noENGINE/CHARSET, and was missingidx_site_taxonomy_kindandidx_site_taxonomy_parent_slug. A dev database did not match what production converges to at startup.Each definition now lives once, in
server/internal/database/schema/*.sql:schema.go,go:embed) andTableSchema()feedsEnsureCommentsTable,EnsurePollsTable,EnsureTaxonomyTable.generate_wordpress_sql.pyreads the same files and exits if one is missing.There is no second copy left to drift. The Go definitions were taken as canonical, so the seed gains the columns and indexes it was missing. The expand-only
ALTERblocks stay:CREATE TABLEis a no-op against an existing database, so a new column still has to be added in both places — documented inschema/README.md.id=0 hardening
articleshas a real row withid = 0, which survives a load only underNO_AUTO_VALUE_ON_ZERO; without it MariaDB renumbers the row and orphans everyarticles_authorsandseorow referencing it. The generator decided whether to prepend the preamble with a bare substring test, which a mere mention of the name in a comment satisfies — silently skipping it. It now matches an actualSETstatement, and re-reads what it wrote to refuse emitting any file that inserts rows without the mode in effect.Verification
go build,go vet,go test ./...pass.TableSchemais covered for every CMS-owned table, including that the files stay statement-shaped and thatPollTableNameresolves to its file.The id=0 behaviour is server-side, so it gets a
CMS_TEST_DSN-gated integration test per the existing convention. Verified against MariaDB 11.7 both ways: it passes with the preamble, and the same insert yieldsMIN(id) = 1without it — so the test actually bites.Note for reviewers
This branch was rebased onto post-#112
main. Theroutes.goconflict was resolved by keeping both sides: thespamCheckerargument here, plus #112's un-gating of search andOptionalAuthon the section/subsection routes. The Akismet env vars are documented incms.env.exampleanddeploy/README.mdonly — deliberately kept out ofcms.env.dryrun.example, which #113 deletes.🤖 Generated with Claude Code