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.tsxBlame519 lines · 2 contributors
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}
5db1b25copilot-swe-agent[bot]282 aria-label="Enable GitHub Pages"
25a91a6Claude283 />
284 {" "}Enable GitHub Pages
285 </label>
286 </div>
287 <div class="form-group">
288 <label for="source_branch">Source branch</label>
289 <input
290 type="text"
291 id="source_branch"
292 name="source_branch"
293 value={settings.sourceBranch}
294 placeholder="gh-pages"
295 />
296 </div>
297 <div class="form-group">
298 <label for="source_dir">Source directory</label>
299 <input
300 type="text"
301 id="source_dir"
302 name="source_dir"
303 value={settings.sourceDir}
304 placeholder="/"
305 />
306 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
307 Use "/" to serve from the repo root, or e.g. "/docs".
308 </div>
309 </div>
310 <div class="form-group">
311 <label for="custom_domain">Custom domain (optional)</label>
312 <input
313 type="text"
314 id="custom_domain"
315 name="custom_domain"
316 value={settings.customDomain || ""}
317 placeholder="example.com"
318 />
319 </div>
320 <button type="submit" class="btn btn-primary">
321 Save
322 </button>
323 </form>
324
325 <div style="margin-top: 32px">
326 <div
327 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px"
328 >
329 <h3>Recent deployments</h3>
330 <form
e7e240eClaude331 method="post"
25a91a6Claude332 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
333 style="display: inline"
334 >
335 <button type="submit" class="btn">
336 Redeploy from HEAD
337 </button>
338 </form>
339 </div>
340 {recent.length === 0 ? (
341 <div class="empty-state">
342 <p>
343 No deployments yet — push to{" "}
344 <code>{settings.sourceBranch}</code> to publish.
345 </p>
346 </div>
347 ) : (
348 <table
349 style="width: 100%; border-collapse: collapse; font-size: 13px"
350 >
351 <thead>
352 <tr style="text-align: left; color: var(--text-muted)">
353 <th style="padding: 6px 0">When</th>
354 <th>Ref</th>
355 <th>Commit</th>
356 <th>Status</th>
357 </tr>
358 </thead>
359 <tbody>
360 {recent.map((d) => (
361 <tr style="border-top: 1px solid var(--border)">
362 <td style="padding: 6px 0">
363 {new Date(d.createdAt).toISOString()}
364 </td>
365 <td>
366 <code>{d.ref}</code>
367 </td>
368 <td>
369 <code>{d.commitSha.slice(0, 7)}</code>
370 </td>
371 <td
372 style={`color: ${d.status === "success" ? "var(--green)" : "var(--red)"}`}
373 >
374 {d.status}
375 </td>
376 </tr>
377 ))}
378 </tbody>
379 </table>
380 )}
381 </div>
382 </div>
383 </Layout>
384 );
385 }
386);
387
388// ---------------------------------------------------------------------------
389// Save settings: POST /:owner/:repo/settings/pages
390// ---------------------------------------------------------------------------
391
392pagesRoute.post(
393 "/:owner/:repo/settings/pages",
394 requireAuth,
395 async (c) => {
396 const { owner: ownerName, repo: repoName } = c.req.param();
397 const user = c.get("user")!;
398 const body = await c.req.parseBody();
399
400 const repoRow = await loadRepo(ownerName, repoName);
401 if (!repoRow) return c.notFound();
402 if (repoRow.ownerId !== user.id) {
403 return c.redirect(`/${ownerName}/${repoName}`);
404 }
405
406 const enabled = body.enabled === "1" || body.enabled === "on";
407 const sourceBranch =
408 String(body.source_branch || "gh-pages").trim() || "gh-pages";
409 let sourceDir = String(body.source_dir || "/").trim() || "/";
410 if (!sourceDir.startsWith("/")) sourceDir = `/${sourceDir}`;
411 const customDomainRaw = String(body.custom_domain || "").trim();
412 const customDomain = customDomainRaw === "" ? null : customDomainRaw;
413
414 try {
415 const [existing] = await db
416 .select({ repositoryId: pagesSettings.repositoryId })
417 .from(pagesSettings)
418 .where(eq(pagesSettings.repositoryId, repoRow.id))
419 .limit(1);
420 if (existing) {
421 await db
422 .update(pagesSettings)
423 .set({
424 enabled,
425 sourceBranch,
426 sourceDir,
427 customDomain,
428 updatedAt: new Date(),
429 })
430 .where(eq(pagesSettings.repositoryId, repoRow.id));
431 } else {
432 await db.insert(pagesSettings).values({
433 repositoryId: repoRow.id,
434 enabled,
435 sourceBranch,
436 sourceDir,
437 customDomain,
438 });
439 }
440 } catch (err) {
441 console.error("[pages] save settings:", err);
442 return c.redirect(
443 `/${ownerName}/${repoName}/settings/pages?error=${encodeURIComponent("Could not save settings")}`
444 );
445 }
446
447 await audit({
448 userId: user.id,
449 repositoryId: repoRow.id,
450 action: "pages.settings.update",
451 metadata: { enabled, sourceBranch, sourceDir, customDomain },
452 });
453
454 return c.redirect(
455 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Pages settings saved")}`
456 );
457 }
458);
459
460// ---------------------------------------------------------------------------
461// Manual redeploy: POST /:owner/:repo/settings/pages/redeploy
462// ---------------------------------------------------------------------------
463
464pagesRoute.post(
465 "/:owner/:repo/settings/pages/redeploy",
466 requireAuth,
467 async (c) => {
468 const { owner: ownerName, repo: repoName } = c.req.param();
469 const user = c.get("user")!;
470
471 const repoRow = await loadRepo(ownerName, repoName);
472 if (!repoRow) return c.notFound();
473 if (repoRow.ownerId !== user.id) {
474 return c.redirect(`/${ownerName}/${repoName}`);
475 }
476
477 const settings = await getEffectiveSettings(repoRow.id);
478 const branch = settings.sourceBranch || "gh-pages";
479 const ref = `refs/heads/${branch}`;
480
481 // Try to resolve the current head of the source branch. If the branch
482 // doesn't exist yet, tell the owner to push something to it instead of
483 // recording a bogus deployment row.
484 const sha = await resolveRef(ownerName, repoName, ref);
485 if (!sha) {
486 await audit({
487 userId: user.id,
488 repositoryId: repoRow.id,
489 action: "pages.redeploy",
490 metadata: { ref, result: "no-branch" },
491 });
492 return c.redirect(
493 `/${ownerName}/${repoName}/settings/pages?info=${encodeURIComponent(`Branch ${branch} has no commits yet — push to it to deploy.`)}`
494 );
495 }
496
497 await onPagesPush({
498 ownerLogin: ownerName,
499 repoName,
500 repositoryId: repoRow.id,
501 ref,
502 newSha: sha,
503 triggeredByUserId: user.id,
504 });
505
506 await audit({
507 userId: user.id,
508 repositoryId: repoRow.id,
509 action: "pages.redeploy",
510 metadata: { ref, sha },
511 });
512
513 return c.redirect(
514 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Redeploy recorded")}`
515 );
516 }
517);
518
519export default pagesRoute;