Skip to content

feat(comments): Akismet spam filtering, and one canonical source for CMS table schemas - #114

Merged
ssavutu merged 3 commits into
mainfrom
feature/akismet-comment-spam-filtering
Jul 30, 2026
Merged

feat(comments): Akismet spam filtering, and one canonical source for CMS table schemas#114
ssavutu merged 3 commits into
mainfrom
feature/akismet-comment-spam-filtering

Conversation

@ssavutu

@ssavutu ssavutu commented Jul 30, 2026

Copy link
Copy Markdown
Member

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 small Checker over the comment-check API, keyed by AKISMET_API_KEY / AKISMET_BLOG_URL.
  • PostArticleComment maps the verdict onto the stored status: spam is filed as spam, clean stays approved.
  • A checker error neither drops nor approves the comment — it is stored pending and logged, so an Akismet outage degrades to human moderation rather than to data loss or an open door.
  • Optional: with no API key the checker is nil and behaviour is exactly as today, so local dev and CI are unchanged.

Also seeds a comments table 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 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. 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, EnsureTaxonomyTable.
  • generate_wordpress_sql.py reads 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 ALTER blocks stay: CREATE TABLE is a no-op against an existing database, so a new column still has to be added in both places — documented in schema/README.md.

id=0 hardening

articles has a real row with id = 0, which survives a load only under NO_AUTO_VALUE_ON_ZERO; without it MariaDB renumbers the row and orphans every articles_authors and seo row 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 actual SET statement, 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. 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 per 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 — so the test actually bites.

Note for reviewers

This branch was rebased onto post-#112 main. The routes.go conflict was resolved by keeping both sides: the spamChecker argument here, plus #112's un-gating of search and OptionalAuth on the section/subsection routes. The Akismet env vars are documented in cms.env.example and deploy/README.md only — deliberately kept out of cms.env.dryrun.example, which #113 deletes.

🤖 Generated with Claude Code

ssavutu and others added 3 commits July 30, 2026 01:59
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>
@ssavutu
ssavutu merged commit 16d1368 into main Jul 30, 2026
5 checks passed
@ssavutu
ssavutu deleted the feature/akismet-comment-spam-filtering branch July 30, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant