Skip to content

Stop re-escaping post slugs on every save - #646

Merged
compscidr merged 2 commits into
mainfrom
fix/slug-reescape
Oct 3, 2026
Merged

compscidr merged 2 commits into
mainfrom
fix/slug-reescape

Conversation

@compscidr

Copy link
Copy Markdown
Collaborator

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, and UpdatePost ran it through url.QueryEscape each 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-bar found foo-25-bar).

Changes

  • safeSlug unescapes fully before escaping, so saving a post is idempotent and an already over-escaped slug comes back to one layer.
  • The three post lookups canonicalize the requested slug and match it exactly (LOWER(slug) = LOWER(?), which keeps the case-insensitivity sqlite's LIKE gave). Over-escaped URLs already in circulation still resolve.
  • repairOverEscapedSlugs migration rewrites existing over-escaped slugs to the once-escaped form, leaving UpdatedAt alone.

Behaviour change

Repaired posts get a new canonical URL (the %3A form). 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

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>
Copilot AI balanced review requested due to automatic review settings October 3, 2026 06:50
@codecov

codecov Bot commented Oct 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
blog/blog.go 50.00% 2 Missing and 1 partial ⚠️
tools/migrate.go 75.00% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

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) and CanonicalSlug (QueryEscape of the fully-unescaped slug) in blog/post.go; make safeSlug unescape before escaping so saves are idempotent.
  • Change the three post lookups to canonicalize the requested slug and match LOWER(slug) = LOWER(?) exactly instead of slug LIKE ?, so % is no longer a wildcard while keeping case-insensitivity across databases.
  • Add the repairOverEscapedSlugs migration (using UpdateColumn to leave UpdatedAt untouched) 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.

Comment thread tools/migrate.go Outdated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@compscidr
compscidr merged commit eca1de0 into main Oct 3, 2026
1 check passed
@compscidr
compscidr deleted the fix/slug-reescape branch October 3, 2026 07:28
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.

2 participants