Commit527e1e3
fix(casing): case-insensitive repo pages via canonical-casing redirect
fix(casing): case-insensitive repo pages via canonical-casing redirect Stage 2 of the staged conversion — the repo-page gate in web.tsx (assertRepoReadable, shared by all 8 repo-page handlers). Now matches owner/repo case-insensitively and, after the access check passes, 302s to the CANONICAL URL when the casing differs (/ccantynz/gluecron.com -> /ccantynz/Gluecron.com). The redirect sidesteps the on-disk casing trap entirely: every downstream git-on-disk op (16 call sites) receives the real casing from the redirected request rather than the caller's, so nothing 404s on the case-sensitive filesystem. Redirect is emitted only after the read-access check, so a private repo's exact casing is never revealed to a caller who can't read it. Typecheck clean; suite 3116 pass / 4 pre-existing fails. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 file changed+29−4527e1e3b07c2b1efacde720ad10c4c452e4c2d74
1 changed file+29−4
Modifiedsrc/routes/web.tsx+29−4View fileUnifiedSplit
@@ -133,18 +133,28 @@ async function assertRepoReadable(
133133 if (!config.databaseUrl) return null;
134134
135135 try {
136 // Case-insensitive slug match; carry back the CANONICAL owner/repo so we
137 // can (a) never feed the caller's raw casing to the git layer and (b)
138 // redirect to the canonical URL below.
136139 const [ownerRow] = await db
137 .select({ id: users.id })
140 .select({ id: users.id, username: users.username })
138141 .from(users)
139 .where(eq(users.username, owner))
142 .where(sql`lower(${users.username}) = lower(${owner})`)
140143 .limit(1);
141144 if (!ownerRow) return notFound();
142145
143146 const [repoRow] = await db
144 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
147 .select({
148 id: repositories.id,
149 name: repositories.name,
150 isPrivate: repositories.isPrivate,
151 })
145152 .from(repositories)
146153 .where(
147 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
154 and(
155 eq(repositories.ownerId, ownerRow.id),
156 sql`lower(${repositories.name}) = lower(${repo})`
157 )
148158 )
149159 .limit(1);
150160 if (!repoRow) return notFound();
@@ -158,6 +168,21 @@ async function assertRepoReadable(
158168 // Return 404 (not 403) so we never confirm a private repo's existence.
159169 if (!satisfiesAccess(access, "read")) return notFound();
160170
171 // Canonical-casing redirect. If the URL used a different casing than the
172 // stored slug (e.g. /ccantynz/gluecron.com for Gluecron.com), 302 to the
173 // canonical path so every downstream git-on-disk op receives the real
174 // casing and never 404s on the case-sensitive filesystem (the "R3" trap).
175 // Done AFTER the access check so a private repo's exact casing is never
176 // revealed to a caller who can't read it.
177 if (ownerRow.username !== owner || repoRow.name !== repo) {
178 const segs = c.req.path.split("/");
179 // segs = ["", "<owner>", "<repo>", ...rest]
180 segs[1] = encodeURIComponent(ownerRow.username);
181 segs[2] = encodeURIComponent(repoRow.name);
182 const search = new URL(c.req.url).search;
183 return c.redirect(segs.join("/") + search, 302);
184 }
185
161186 return null;
162187 } catch (err) {
163188 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
164189