Repository navigation
Stop re-escaping post slugs on every save - #646
Conversation
The admin sends the stored, already escaped slug back on each save, and UpdatePost escaped it again: a colon became %3A on create, %253A after one edit, %25253A after two. The canonical URL is built from the stored slug, so every edit of such a post moved its canonical URL. - safeSlug unescapes fully before escaping, so saving is idempotent. - Post lookups match the canonical slug exactly instead of with LIKE, where the slug's own % escapes acted as wildcards and a URL could find a different post. Lookups canonicalize the requested slug, so the over-escaped URLs already in circulation still resolve. - A migration rewrites over-escaped slugs to the once-escaped form without touching UpdatedAt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It rewrites stored slug data and changes public canonical URLs via a migration whose cross-database SQL behavior (LOWER(slug) = LOWER(?)) was explicitly not tested on MySQL or Postgres, warranting human review.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
This PR fixes a bug where post slugs containing URL-escaped characters (e.g. :, ?, &) were re-escaped on every save (%3A → %253A → %25253A), because the admin UI sends the stored slug back and both safeSlug and the post lookups ran it through url.QueryEscape unconditionally. The accumulating escaping shifted each post's canonical URL (and sitemap entry) on every edit, and the old slug LIKE ? lookup silently masked the problem because % acted as a wildcard. The change introduces a canonical, escaped-exactly-once slug form, makes saving idempotent, switches the three post lookups to exact case-insensitive matching, and adds a one-time repair migration for already-corrupted slugs.
Changes:
- Add
UnescapeSlug(strip all escape layers) andCanonicalSlug(QueryEscapeof the fully-unescaped slug) inblog/post.go; makesafeSlugunescape before escaping so saves are idempotent. - Change the three post lookups to canonicalize the requested slug and match
LOWER(slug) = LOWER(?)exactly instead ofslug LIKE ?, so%is no longer a wildcard while keeping case-insensitivity across databases. - Add the
repairOverEscapedSlugsmigration (usingUpdateColumnto leaveUpdatedAtuntouched) plus unit tests for canonicalization, lookup resolution, idempotent saves, and the repair.
| File | Description |
|---|---|
| blog/post.go | Adds UnescapeSlug/CanonicalSlug helpers defining the canonical once-escaped slug form. |
| admin/admin.go | safeSlug now unescapes before re-escaping, making repeated saves idempotent. |
| blog/blog.go | Three post lookups canonicalize the slug and use exact LOWER(slug) = LOWER(?) matching. |
| tools/migrate.go | New repairOverEscapedSlugs migration rewrites over-escaped slugs without touching UpdatedAt. |
| blog/blog_test.go | Adds TestCanonicalSlug and TestPostLookupBySlug covering canonicalization and URL resolution. |
| admin/admin_test.go | Adds TestUpdatePostKeepsSlug verifying saves no longer grow the slug. |
| tools/migrate_test.go | Adds TestMigrationRepairsOverEscapedSlugs validating repair and UpdatedAt preservation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Problem
A post whose title contains a character that gets URL-escaped (
:,?,&,,...) had its slug escaped again on every save. The admin sends the stored slug back, andUpdatePostran it throughurl.QueryEscapeeach time:Foo:-bar→Foo%3A-bar(create) →Foo%253A-bar(first edit) →Foo%25253A-bar(second edit)The canonical URL and the sitemap are built from the stored slug, so each edit moved the post's canonical URL.
Nothing visibly broke because posts were looked up with
slug LIKE ?, and the%in the escaped slug is a LIKE wildcard, so every variant happened to match. That also meant a URL could resolve to a different post (/foo%25-barfoundfoo-25-bar).Changes
safeSlugunescapes fully before escaping, so saving a post is idempotent and an already over-escaped slug comes back to one layer.LOWER(slug) = LOWER(?), which keeps the case-insensitivity sqlite'sLIKEgave). Over-escaped URLs already in circulation still resolve.repairOverEscapedSlugsmigration rewrites existing over-escaped slugs to the once-escaped form, leavingUpdatedAtalone.Behaviour change
Repaired posts get a new canonical URL (the
%3Aform). The old URLs keep working and their canonical tag points at the new one; they are not 301-redirected.Testing
New tests:
TestCanonicalSlug,TestPostLookupBySlug(canonical, over-escaped and mixed-case URLs resolve; the wildcard case does not),TestUpdatePostKeepsSlug,TestMigrationRepairsOverEscapedSlugs.go test ./...passes. Tested on sqlite only; not run against MySQL or Postgres.🤖 Generated with Claude Code