Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pages.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pages.tsxBlame518 lines · 1 contributor
25a91a6Claude1/**
2 * Block C3 — Pages / static hosting routes.
3 *
4 * GET /:owner/:repo/pages/* — serve a static file from the
5 * latest successful gh-pages
6 * deployment
7 * GET /:owner/:repo/settings/pages — settings UI (owner-only)
8 * POST /:owner/:repo/settings/pages — upsert settings
9 * POST /:owner/:repo/settings/pages/redeploy — manual redeploy trigger
10 *
11 * The serving endpoint reads blobs directly out of the bare git repo at the
12 * commit sha of the most recent pages_deployments row for that repo. There is
13 * no on-disk export — the git store IS the CDN.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq } from "drizzle-orm";
18import { db } from "../db";
19import {
20 pagesDeployments,
21 pagesSettings,
22 repositories,
23 users,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getBlob, getRawBlob, resolveRef } from "../git/repository";
30import { audit } from "../lib/notify";
31import { getUnreadCount } from "../lib/unread";
32import { config } from "../lib/config";
33import {
34 contentTypeFor,
35 onPagesPush,
36 resolvePagesPath,
37} from "../lib/pages";
38
39const pagesRoute = new Hono<AuthEnv>();
40pagesRoute.use("*", softAuth);
41
42interface LoadedRepo {
43 id: string;
44 name: string;
45 ownerId: string;
46 ownerUsername: string;
47}
48
49async function loadRepo(
50 owner: string,
51 repo: string
52): Promise<LoadedRepo | null> {
53 try {
54 const [row] = await db
55 .select({
56 id: repositories.id,
57 name: repositories.name,
58 ownerId: repositories.ownerId,
59 ownerUsername: users.username,
60 })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
64 .limit(1);
65 return row || null;
66 } catch {
67 return null;
68 }
69}
70
71async function getEffectiveSettings(repositoryId: string) {
72 try {
73 const [row] = await db
74 .select()
75 .from(pagesSettings)
76 .where(eq(pagesSettings.repositoryId, repositoryId))
77 .limit(1);
78 if (row) return row;
79 } catch {
80 /* fall through to defaults */
81 }
82 // Synthesise defaults when the row doesn't exist.
83 return {
84 repositoryId,
85 enabled: true,
86 sourceBranch: "gh-pages",
87 sourceDir: "/",
88 customDomain: null as string | null,
89 updatedAt: new Date(),
90 };
91}
92
93// ---------------------------------------------------------------------------
94// Serve: GET /:owner/:repo/pages/*
95// ---------------------------------------------------------------------------
96
97pagesRoute.get("/:owner/:repo/pages/*", async (c) => {
98 const { owner, repo } = c.req.param();
99
100 // Hono gives us the full path via c.req.path; extract whatever sits after
101 // the "/pages/" segment. This is the only path component we treat as the
102 // user-facing URL.
103 const full = c.req.path;
104 const marker = `/${owner}/${repo}/pages/`;
105 const idx = full.indexOf(marker);
106 const urlRest = idx >= 0 ? full.slice(idx + marker.length) : "";
107
108 const repoRow = await loadRepo(owner, repo);
109 if (!repoRow) {
110 return c.text("No Pages site published for this repository.", 404);
111 }
112
113 const settings = await getEffectiveSettings(repoRow.id);
114 if (!settings.enabled) {
115 return c.text("No Pages site published for this repository.", 404);
116 }
117
118 let deployment:
119 | { commitSha: string; createdAt: Date; status: string }
120 | null = null;
121 try {
122 const [row] = await db
123 .select({
124 commitSha: pagesDeployments.commitSha,
125 createdAt: pagesDeployments.createdAt,
126 status: pagesDeployments.status,
127 })
128 .from(pagesDeployments)
129 .where(
130 and(
131 eq(pagesDeployments.repositoryId, repoRow.id),
132 eq(pagesDeployments.status, "success")
133 )
134 )
135 .orderBy(desc(pagesDeployments.createdAt))
136 .limit(1);
137 deployment = row || null;
138 } catch {
139 return c.text("Service unavailable", 503);
140 }
141
142 if (!deployment) {
143 return c.text(
144 "No Pages site published for this repository. Push to the configured source branch to publish.",
145 404
146 );
147 }
148
149 const candidates = resolvePagesPath(urlRest, settings.sourceDir);
150
151 for (const candidate of candidates) {
152 // Try as text first — getBlob fills in isBinary for us.
153 const blob = await getBlob(owner, repo, deployment.commitSha, candidate);
154 if (!blob) continue;
155
156 const headers: Record<string, string> = {
157 "Content-Type": contentTypeFor(candidate),
158 "Cache-Control": "public, max-age=60",
159 "X-Gluecron-Pages-Sha": deployment.commitSha.slice(0, 7),
160 };
161
162 if (blob.isBinary) {
163 // getBlob blanks the content for binary — re-read the raw bytes.
164 const raw = await getRawBlob(
165 owner,
166 repo,
167 deployment.commitSha,
168 candidate
169 );
170 if (!raw) continue;
772a24fClaude171 return new Response(raw as BodyInit, { status: 200, headers });
25a91a6Claude172 }
173
174 return new Response(blob.content, { status: 200, headers });
175 }
176
177 return c.text("Not found in Pages site.", 404);
178});
179
180// ---------------------------------------------------------------------------
181// Settings UI: GET /:owner/:repo/settings/pages
182// ---------------------------------------------------------------------------
183
184pagesRoute.get(
185 "/:owner/:repo/settings/pages",
186 requireAuth,
187 async (c) => {
188 const { owner: ownerName, repo: repoName } = c.req.param();
189 const user = c.get("user")!;
190 const success = c.req.query("success");
191 const error = c.req.query("error");
192 const info = c.req.query("info");
193
194 const repoRow = await loadRepo(ownerName, repoName);
195 if (!repoRow) return c.notFound();
196 if (repoRow.ownerId !== user.id) {
197 return c.html(
198 <Layout title="Unauthorized" user={user}>
199 <div class="empty-state">
200 <h2>Unauthorized</h2>
201 <p>Only the repository owner can configure Pages.</p>
202 </div>
203 </Layout>,
204 403
205 );
206 }
207
208 const settings = await getEffectiveSettings(repoRow.id);
209
210 let recent: Array<{
211 id: string;
212 ref: string;
213 commitSha: string;
214 status: string;
215 createdAt: Date;
216 }> = [];
217 try {
218 recent = await db
219 .select({
220 id: pagesDeployments.id,
221 ref: pagesDeployments.ref,
222 commitSha: pagesDeployments.commitSha,
223 status: pagesDeployments.status,
224 createdAt: pagesDeployments.createdAt,
225 })
226 .from(pagesDeployments)
227 .where(eq(pagesDeployments.repositoryId, repoRow.id))
228 .orderBy(desc(pagesDeployments.createdAt))
229 .limit(10);
230 } catch {
231 /* fall through; render with empty list */
232 }
233
234 const unread = await getUnreadCount(user.id);
235 const siteUrl = `${config.appBaseUrl}/${ownerName}/${repoName}/pages/`;
236
237 return c.html(
238 <Layout
239 title={`Pages — ${ownerName}/${repoName}`}
240 user={user}
241 notificationCount={unread}
242 >
243 <RepoHeader owner={ownerName} repo={repoName} />
244 <RepoNav owner={ownerName} repo={repoName} active="code" />
245 <div style="max-width: 720px">
246 <h2 style="margin-bottom: 16px">Pages</h2>
247 {success && (
248 <div class="auth-success">{decodeURIComponent(success)}</div>
249 )}
250 {info && <div class="auth-success">{decodeURIComponent(info)}</div>}
251 {error && (
252 <div class="auth-error">{decodeURIComponent(error)}</div>
253 )}
254
255 <p style="color: var(--text-muted); margin-bottom: 20px">
256 Publish a static site from this repository. Push to the source
257 branch and every successful push becomes a new deployment.
258 </p>
259
260 <div
261 style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 24px; background: var(--bg-muted)"
262 >
263 <div style="font-size: 13px; color: var(--text-muted)">
264 Your site is published at:
265 </div>
266 <div style="margin-top: 4px">
267 <a href={siteUrl}>{siteUrl}</a>
268 </div>
269 </div>
270
271 <form
e7e240eClaude272 method="post"
25a91a6Claude273 action={`/${ownerName}/${repoName}/settings/pages`}
274 >
275 <div class="form-group">
276 <label>
277 <input
278 type="checkbox"
279 name="enabled"
280 value="1"
281 checked={settings.enabled}
282 />
283 {" "}Enable GitHub Pages
284 </label>
285 </div>
286 <div class="form-group">
287 <label for="source_branch">Source branch</label>
288 <input
289 type="text"
290 id="source_branch"
291 name="source_branch"
292 value={settings.sourceBranch}
293 placeholder="gh-pages"
294 />
295 </div>
296 <div class="form-group">
297 <label for="source_dir">Source directory</label>
298 <input
299 type="text"
300 id="source_dir"
301 name="source_dir"
302 value={settings.sourceDir}
303 placeholder="/"
304 />
305 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
306 Use "/" to serve from the repo root, or e.g. "/docs".
307 </div>
308 </div>
309 <div class="form-group">
310 <label for="custom_domain">Custom domain (optional)</label>
311 <input
312 type="text"
313 id="custom_domain"
314 name="custom_domain"
315 value={settings.customDomain || ""}
316 placeholder="example.com"
317 />
318 </div>
319 <button type="submit" class="btn btn-primary">
320 Save
321 </button>
322 </form>
323
324 <div style="margin-top: 32px">
325 <div
326 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px"
327 >
328 <h3>Recent deployments</h3>
329 <form
e7e240eClaude330 method="post"
25a91a6Claude331 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
332 style="display: inline"
333 >
334 <button type="submit" class="btn">
335 Redeploy from HEAD
336 </button>
337 </form>
338 </div>
339 {recent.length === 0 ? (
340 <div class="empty-state">
341 <p>
342 No deployments yet — push to{" "}
343 <code>{settings.sourceBranch}</code> to publish.
344 </p>
345 </div>
346 ) : (
347 <table
348 style="width: 100%; border-collapse: collapse; font-size: 13px"
349 >
350 <thead>
351 <tr style="text-align: left; color: var(--text-muted)">
352 <th style="padding: 6px 0">When</th>
353 <th>Ref</th>
354 <th>Commit</th>
355 <th>Status</th>
356 </tr>
357 </thead>
358 <tbody>
359 {recent.map((d) => (
360 <tr style="border-top: 1px solid var(--border)">
361 <td style="padding: 6px 0">
362 {new Date(d.createdAt).toISOString()}
363 </td>
364 <td>
365 <code>{d.ref}</code>
366 </td>
367 <td>
368 <code>{d.commitSha.slice(0, 7)}</code>
369 </td>
370 <td
371 style={`color: ${d.status === "success" ? "var(--green)" : "var(--red)"}`}
372 >
373 {d.status}
374 </td>
375 </tr>
376 ))}
377 </tbody>
378 </table>
379 )}
380 </div>
381 </div>
382 </Layout>
383 );
384 }
385);
386
387// ---------------------------------------------------------------------------
388// Save settings: POST /:owner/:repo/settings/pages
389// ---------------------------------------------------------------------------
390
391pagesRoute.post(
392 "/:owner/:repo/settings/pages",
393 requireAuth,
394 async (c) => {
395 const { owner: ownerName, repo: repoName } = c.req.param();
396 const user = c.get("user")!;
397 const body = await c.req.parseBody();
398
399 const repoRow = await loadRepo(ownerName, repoName);
400 if (!repoRow) return c.notFound();
401 if (repoRow.ownerId !== user.id) {
402 return c.redirect(`/${ownerName}/${repoName}`);
403 }
404
405 const enabled = body.enabled === "1" || body.enabled === "on";
406 const sourceBranch =
407 String(body.source_branch || "gh-pages").trim() || "gh-pages";
408 let sourceDir = String(body.source_dir || "/").trim() || "/";
409 if (!sourceDir.startsWith("/")) sourceDir = `/${sourceDir}`;
410 const customDomainRaw = String(body.custom_domain || "").trim();
411 const customDomain = customDomainRaw === "" ? null : customDomainRaw;
412
413 try {
414 const [existing] = await db
415 .select({ repositoryId: pagesSettings.repositoryId })
416 .from(pagesSettings)
417 .where(eq(pagesSettings.repositoryId, repoRow.id))
418 .limit(1);
419 if (existing) {
420 await db
421 .update(pagesSettings)
422 .set({
423 enabled,
424 sourceBranch,
425 sourceDir,
426 customDomain,
427 updatedAt: new Date(),
428 })
429 .where(eq(pagesSettings.repositoryId, repoRow.id));
430 } else {
431 await db.insert(pagesSettings).values({
432 repositoryId: repoRow.id,
433 enabled,
434 sourceBranch,
435 sourceDir,
436 customDomain,
437 });
438 }
439 } catch (err) {
440 console.error("[pages] save settings:", err);
441 return c.redirect(
442 `/${ownerName}/${repoName}/settings/pages?error=${encodeURIComponent("Could not save settings")}`
443 );
444 }
445
446 await audit({
447 userId: user.id,
448 repositoryId: repoRow.id,
449 action: "pages.settings.update",
450 metadata: { enabled, sourceBranch, sourceDir, customDomain },
451 });
452
453 return c.redirect(
454 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Pages settings saved")}`
455 );
456 }
457);
458
459// ---------------------------------------------------------------------------
460// Manual redeploy: POST /:owner/:repo/settings/pages/redeploy
461// ---------------------------------------------------------------------------
462
463pagesRoute.post(
464 "/:owner/:repo/settings/pages/redeploy",
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const user = c.get("user")!;
469
470 const repoRow = await loadRepo(ownerName, repoName);
471 if (!repoRow) return c.notFound();
472 if (repoRow.ownerId !== user.id) {
473 return c.redirect(`/${ownerName}/${repoName}`);
474 }
475
476 const settings = await getEffectiveSettings(repoRow.id);
477 const branch = settings.sourceBranch || "gh-pages";
478 const ref = `refs/heads/${branch}`;
479
480 // Try to resolve the current head of the source branch. If the branch
481 // doesn't exist yet, tell the owner to push something to it instead of
482 // recording a bogus deployment row.
483 const sha = await resolveRef(ownerName, repoName, ref);
484 if (!sha) {
485 await audit({
486 userId: user.id,
487 repositoryId: repoRow.id,
488 action: "pages.redeploy",
489 metadata: { ref, result: "no-branch" },
490 });
491 return c.redirect(
492 `/${ownerName}/${repoName}/settings/pages?info=${encodeURIComponent(`Branch ${branch} has no commits yet — push to it to deploy.`)}`
493 );
494 }
495
496 await onPagesPush({
497 ownerLogin: ownerName,
498 repoName,
499 repositoryId: repoRow.id,
500 ref,
501 newSha: sha,
502 triggeredByUserId: user.id,
503 });
504
505 await audit({
506 userId: user.id,
507 repositoryId: repoRow.id,
508 action: "pages.redeploy",
509 metadata: { ref, sha },
510 });
511
512 return c.redirect(
513 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Redeploy recorded")}`
514 );
515 }
516);
517
518export default pagesRoute;