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

gists.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.

gists.tsxBlame805 lines · 1 contributor
1e162a8Claude1/**
2 * Block E4 — Gists: user-owned tiny multi-file repos.
3 *
4 * DB-backed v1 (no git bare repo). Each gist owns a collection of gist_files,
5 * and every edit appends a gist_revisions row with a JSON snapshot of the
6 * full file set at that revision.
7 *
8 * Never throws — all DB paths wrapped in try/catch; any failure redirects.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { randomBytes } from "crypto";
14import { db } from "../db";
15import {
16 gists,
17 gistFiles,
18 gistRevisions,
19 gistStars,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { highlightCode } from "../lib/highlight";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { html } from "hono/html";
27
28export function generateSlug(): string {
29 return randomBytes(4).toString("hex");
30}
31
32export function snapshotOf(
33 files: { filename: string; content: string }[]
34): string {
35 const map: Record<string, string> = {};
36 for (const f of files) map[f.filename] = f.content;
37 return JSON.stringify(map);
38}
39
40const gistRoutes = new Hono<AuthEnv>();
41
42function notFound(user: any, label = "Gist not found") {
43 return (
44 <Layout title={label} user={user}>
45 <div class="empty-state">
46 <h2>{label}</h2>
47 </div>
48 </Layout>
49 );
50}
51
52// Discover / list public gists
53gistRoutes.get("/gists", softAuth, async (c) => {
54 const user = c.get("user");
55 const page = Math.max(1, Number(c.req.query("page")) || 1);
56 const limit = 30;
57 const offset = (page - 1) * limit;
58
59 let rows: any[] = [];
60 try {
61 rows = await db
62 .select({
63 g: gists,
64 owner: { username: users.username },
65 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
66 starCount: sql<number>`(SELECT count(*) FROM gist_stars WHERE gist_id = ${gists.id})`,
67 })
68 .from(gists)
69 .innerJoin(users, eq(gists.ownerId, users.id))
70 .where(eq(gists.isPublic, true))
71 .orderBy(desc(gists.updatedAt))
72 .limit(limit)
73 .offset(offset);
74 } catch {
75 rows = [];
76 }
77
78 return c.html(
79 <Layout title="Discover gists" user={user}>
80 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
81 <h1 style="margin: 0;">Discover gists</h1>
82 {user && (
83 <a href="/gists/new" class="btn btn-primary">
84 + New gist
85 </a>
86 )}
87 </div>
88 {rows.length === 0 ? (
89 <div class="empty-state">
90 <p>No public gists yet.</p>
91 </div>
92 ) : (
93 <div class="commit-list">
94 {rows.map((r) => (
95 <div class="commit-item">
96 <div>
97 <div class="commit-message">
98 <a href={`/gists/${r.g.slug}`}>
99 <strong>{r.g.title || r.g.slug}</strong>
100 </a>
101 </div>
102 <div class="commit-meta">
103 by <a href={`/${r.owner.username}`}>@{r.owner.username}</a>{" "}
104 · {r.fileCount} file{r.fileCount !== 1 ? "s" : ""} · ★{" "}
105 {r.starCount}
106 {r.g.description && ` · ${r.g.description}`}
107 </div>
108 </div>
109 <a href={`/gists/${r.g.slug}`} class="commit-sha">
110 {r.g.slug}
111 </a>
112 </div>
113 ))}
114 </div>
115 )}
116 {(rows.length === limit || page > 1) && (
117 <div style="margin-top: 16px;">
118 {page > 1 && <a href={`/gists?page=${page - 1}`}>← prev</a>}
119 {" "}
120 {rows.length === limit && (
121 <a href={`/gists?page=${page + 1}`}>next →</a>
122 )}
123 </div>
124 )}
125 </Layout>
126 );
127});
128
129// New gist form
130gistRoutes.get("/gists/new", requireAuth, async (c) => {
131 const user = c.get("user");
132 return c.html(
133 <Layout title="New gist" user={user}>
134 <h1 style="margin-top: 20px;">Create a gist</h1>
135 <form
cce7944Claude136 method="post"
1e162a8Claude137 action="/gists"
138 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
139 >
140 <input
141 type="text"
142 name="description"
143 placeholder="Gist description..."
144 style="padding: 8px;"
145 />
146 <div style="display: flex; gap: 16px;">
147 <label>
148 <input type="radio" name="is_public" value="true" checked />{" "}
149 Public
150 </label>
151 <label>
152 <input type="radio" name="is_public" value="false" /> Secret
153 </label>
154 </div>
155 <div id="files">
156 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
157 <input
158 type="text"
159 name="filename[]"
160 placeholder="filename.ext"
161 required
162 style="padding: 6px; width: 300px;"
163 />
164 <textarea
165 name="content[]"
166 rows={12}
167 placeholder="File contents..."
168 required
169 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
170 ></textarea>
171 </div>
172 </div>
173 <button
174 type="button"
175 class="btn"
176 id="add-file"
177 style="align-self: flex-start;"
178 >
179 + Add file
180 </button>
181 <button type="submit" class="btn btn-primary">
182 Create gist
183 </button>
184 </form>
185 {html`
186 <script>
187 document.getElementById("add-file").addEventListener("click", () => {
188 const div = document.createElement("div");
189 div.className = "gist-file";
190 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
191 div.innerHTML =
192 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
193 '<textarea name="content[]" rows="12" placeholder="File contents..." required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
194 document.getElementById("files").appendChild(div);
195 });
196 </script>
197 `}
198 </Layout>
199 );
200});
201
202// Create gist
203gistRoutes.post("/gists", requireAuth, async (c) => {
204 const user = c.get("user")!;
205 const form = await c.req.formData();
206 const description = (form.get("description") as string || "").trim();
207 const isPublic = (form.get("is_public") as string) !== "false";
208 const filenames = form.getAll("filename[]") as string[];
209 const contents = form.getAll("content[]") as string[];
210
211 const files = filenames
212 .map((fn, i) => ({
213 filename: (fn || "").trim(),
214 content: contents[i] || "",
215 }))
216 .filter((f) => f.filename && f.content);
217
218 if (files.length === 0) {
219 return c.text("At least one file is required", 400);
220 }
221
222 // Retry on unique slug collision
223 for (let attempt = 0; attempt < 5; attempt++) {
224 const slug = generateSlug();
225 try {
226 const [gist] = await db
227 .insert(gists)
228 .values({
229 ownerId: user.id,
230 slug,
231 title: files[0].filename,
232 description,
233 isPublic,
234 })
235 .returning({ id: gists.id });
236 await db.insert(gistFiles).values(
237 files.map((f) => ({
238 gistId: gist.id,
239 filename: f.filename,
240 content: f.content,
241 sizeBytes: new TextEncoder().encode(f.content).length,
242 }))
243 );
244 await db.insert(gistRevisions).values({
245 gistId: gist.id,
246 revision: 1,
247 snapshot: snapshotOf(files),
248 authorId: user.id,
249 message: "Initial",
250 });
251 return c.redirect(`/gists/${slug}`);
252 } catch (err: any) {
253 if (attempt === 4) {
254 return c.text("Could not create gist", 500);
255 }
256 // Otherwise assume slug collision, retry with fresh slug.
257 }
258 }
259 return c.redirect("/gists");
260});
261
262// View gist
263gistRoutes.get("/gists/:slug", softAuth, async (c) => {
264 const user = c.get("user");
265 const slug = c.req.param("slug");
266
267 let gist: any = null;
268 let files: any[] = [];
269 let starCount = 0;
270 let isStarred = false;
271 try {
272 const [row] = await db
273 .select({ g: gists, owner: { username: users.username } })
274 .from(gists)
275 .innerJoin(users, eq(gists.ownerId, users.id))
276 .where(eq(gists.slug, slug))
277 .limit(1);
278 if (row) {
279 gist = row;
280 files = await db
281 .select()
282 .from(gistFiles)
283 .where(eq(gistFiles.gistId, gist.g.id))
284 .orderBy(gistFiles.filename);
285 const [cnt] = await db
286 .select({ n: sql<number>`count(*)` })
287 .from(gistStars)
288 .where(eq(gistStars.gistId, gist.g.id));
289 starCount = Number(cnt?.n || 0);
290 if (user) {
291 const [has] = await db
292 .select()
293 .from(gistStars)
294 .where(
295 and(
296 eq(gistStars.gistId, gist.g.id),
297 eq(gistStars.userId, user.id)
298 )
299 )
300 .limit(1);
301 isStarred = !!has;
302 }
303 }
304 } catch {
305 // leave null
306 }
307
308 if (!gist) return c.html(notFound(user), 404);
309
310 const isOwner = user && user.id === gist.g.ownerId;
311 if (!gist.g.isPublic && !isOwner) {
312 return c.html(notFound(user), 404);
313 }
314
315 return c.html(
316 <Layout title={gist.g.title || slug} user={user}>
317 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
318 <div>
319 <h1 style="margin: 0;">
320 <a href={`/${gist.owner.username}`}>@{gist.owner.username}</a>{" "}
321 <span style="color: var(--text-muted);">/</span>{" "}
322 {gist.g.title || slug}
323 {!gist.g.isPublic && <span class="badge">Secret</span>}
324 </h1>
325 {gist.g.description && (
326 <div style="color: var(--text-muted); margin-top: 4px;">
327 {gist.g.description}
328 </div>
329 )}
330 </div>
331 <div style="display: flex; gap: 8px;">
332 {user && !isOwner && (
333 <form
cce7944Claude334 method="post"
1e162a8Claude335 action={`/gists/${slug}/star`}
336 style="display: inline;"
337 >
338 <button
339 type="submit"
340 class={`star-btn${isStarred ? " starred" : ""}`}
341 >
342 {isStarred ? "★" : "☆"} {starCount}
343 </button>
344 </form>
345 )}
346 {!user && (
347 <span class="star-btn">☆ {starCount}</span>
348 )}
349 <a href={`/gists/${slug}/revisions`} class="btn">
350 Revisions
351 </a>
352 {isOwner && (
353 <>
354 <a href={`/gists/${slug}/edit`} class="btn">
355 Edit
356 </a>
357 <form
cce7944Claude358 method="post"
1e162a8Claude359 action={`/gists/${slug}/delete`}
360 style="display: inline;"
361 onsubmit="return confirm('Delete this gist?')"
362 >
363 <button type="submit" class="btn">
364 Delete
365 </button>
366 </form>
367 </>
368 )}
369 </div>
370 </div>
371 {files.map((f) => {
372 const { html: highlighted } = highlightCode(f.content, f.filename);
373 return (
374 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
375 <div class="diff-file-header">{f.filename}</div>
376 <div class="blob-code">
377 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
378 {html([highlighted] as unknown as TemplateStringsArray)}
379 </pre>
380 </div>
381 </div>
382 );
383 })}
384 </Layout>
385 );
386});
387
388// Edit form
389gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
390 const user = c.get("user")!;
391 const slug = c.req.param("slug");
392
393 let gist: any = null;
394 let files: any[] = [];
395 try {
396 const [row] = await db
397 .select()
398 .from(gists)
399 .where(eq(gists.slug, slug))
400 .limit(1);
401 if (row && row.ownerId === user.id) {
402 gist = row;
403 files = await db
404 .select()
405 .from(gistFiles)
406 .where(eq(gistFiles.gistId, gist.id))
407 .orderBy(gistFiles.filename);
408 }
409 } catch {
410 // leave null
411 }
412
413 if (!gist) return c.html(notFound(user, "Not found or not yours"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${gist.slug}`} user={user}>
417 <h1 style="margin-top: 20px;">Edit gist</h1>
418 <form
cce7944Claude419 method="post"
1e162a8Claude420 action={`/gists/${slug}/edit`}
421 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
422 >
423 <input
424 type="text"
425 name="description"
426 value={gist.description}
427 placeholder="Description"
428 style="padding: 8px;"
429 />
430 <input
431 type="text"
432 name="message"
433 placeholder="Revision message (optional)"
434 style="padding: 8px;"
435 />
436 <div id="files">
437 {files.map((f) => (
438 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
439 <input
440 type="text"
441 name="filename[]"
442 value={f.filename}
443 required
444 style="padding: 6px; width: 300px;"
445 />
446 <textarea
447 name="content[]"
448 rows={12}
449 required
450 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
451 >
452 {f.content}
453 </textarea>
454 </div>
455 ))}
456 </div>
457 <button
458 type="button"
459 class="btn"
460 id="add-file"
461 style="align-self: flex-start;"
462 >
463 + Add file
464 </button>
465 <button type="submit" class="btn btn-primary">
466 Save revision
467 </button>
468 </form>
469 {html`
470 <script>
471 document.getElementById("add-file").addEventListener("click", () => {
472 const div = document.createElement("div");
473 div.className = "gist-file";
474 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
475 div.innerHTML =
476 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
477 '<textarea name="content[]" rows="12" required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
478 document.getElementById("files").appendChild(div);
479 });
480 </script>
481 `}
482 </Layout>
483 );
484});
485
486// Save edit
487gistRoutes.post("/gists/:slug/edit", requireAuth, async (c) => {
488 const user = c.get("user")!;
489 const slug = c.req.param("slug");
490 const form = await c.req.formData();
491 const description = (form.get("description") as string || "").trim();
492 const message = (form.get("message") as string || "").trim();
493 const filenames = form.getAll("filename[]") as string[];
494 const contents = form.getAll("content[]") as string[];
495
496 const files = filenames
497 .map((fn, i) => ({
498 filename: (fn || "").trim(),
499 content: contents[i] || "",
500 }))
501 .filter((f) => f.filename && f.content);
502
503 if (files.length === 0) {
504 return c.text("At least one file is required", 400);
505 }
506
507 try {
508 const [row] = await db
509 .select()
510 .from(gists)
511 .where(eq(gists.slug, slug))
512 .limit(1);
513 if (!row || row.ownerId !== user.id) {
514 return c.redirect("/gists");
515 }
516 // Replace file set: delete all, re-insert.
517 await db.delete(gistFiles).where(eq(gistFiles.gistId, row.id));
518 await db.insert(gistFiles).values(
519 files.map((f) => ({
520 gistId: row.id,
521 filename: f.filename,
522 content: f.content,
523 sizeBytes: new TextEncoder().encode(f.content).length,
524 }))
525 );
526 // Bump revision.
527 const [last] = await db
528 .select({ r: sql<number>`max(${gistRevisions.revision})` })
529 .from(gistRevisions)
530 .where(eq(gistRevisions.gistId, row.id));
531 const nextRev = Number(last?.r || 0) + 1;
532 await db.insert(gistRevisions).values({
533 gistId: row.id,
534 revision: nextRev,
535 snapshot: snapshotOf(files),
536 authorId: user.id,
537 message: message || null,
538 });
539 await db
540 .update(gists)
541 .set({ description, updatedAt: new Date() })
542 .where(eq(gists.id, row.id));
543 } catch {
544 // swallow
545 }
546 return c.redirect(`/gists/${slug}`);
547});
548
549// Delete
550gistRoutes.post("/gists/:slug/delete", requireAuth, async (c) => {
551 const user = c.get("user")!;
552 const slug = c.req.param("slug");
553 try {
554 const [row] = await db
555 .select()
556 .from(gists)
557 .where(eq(gists.slug, slug))
558 .limit(1);
559 if (row && row.ownerId === user.id) {
560 await db.delete(gists).where(eq(gists.id, row.id));
561 }
562 } catch {
563 // swallow
564 }
565 return c.redirect("/gists");
566});
567
568// Toggle star
569gistRoutes.post("/gists/:slug/star", requireAuth, async (c) => {
570 const user = c.get("user")!;
571 const slug = c.req.param("slug");
572 try {
573 const [row] = await db
574 .select()
575 .from(gists)
576 .where(eq(gists.slug, slug))
577 .limit(1);
578 if (row && row.ownerId !== user.id) {
579 const [existing] = await db
580 .select()
581 .from(gistStars)
582 .where(
583 and(
584 eq(gistStars.gistId, row.id),
585 eq(gistStars.userId, user.id)
586 )
587 )
588 .limit(1);
589 if (existing) {
590 await db.delete(gistStars).where(eq(gistStars.id, existing.id));
591 } else {
592 await db.insert(gistStars).values({
593 gistId: row.id,
594 userId: user.id,
595 });
596 }
597 }
598 } catch {
599 // swallow
600 }
601 return c.redirect(`/gists/${slug}`);
602});
603
604// Revisions list
605gistRoutes.get("/gists/:slug/revisions", softAuth, async (c) => {
606 const user = c.get("user");
607 const slug = c.req.param("slug");
608
609 let gist: any = null;
610 let revs: any[] = [];
611 try {
612 const [row] = await db
613 .select()
614 .from(gists)
615 .where(eq(gists.slug, slug))
616 .limit(1);
617 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
618 gist = row;
619 revs = await db
620 .select({
621 r: gistRevisions,
622 author: { username: users.username },
623 })
624 .from(gistRevisions)
625 .innerJoin(users, eq(gistRevisions.authorId, users.id))
626 .where(eq(gistRevisions.gistId, gist.id))
627 .orderBy(desc(gistRevisions.revision));
628 }
629 } catch {
630 // leave null
631 }
632
633 if (!gist) return c.html(notFound(user), 404);
634
635 return c.html(
636 <Layout title={`${gist.slug} — revisions`} user={user}>
637 <h1 style="margin-top: 16px;">
638 <a href={`/gists/${slug}`}>{gist.title || slug}</a> — revisions
639 </h1>
640 <div class="commit-list" style="margin-top: 16px;">
641 {revs.map((rv) => (
642 <div class="commit-item">
643 <div>
644 <div class="commit-message">
645 <a href={`/gists/${slug}/revisions/${rv.r.revision}`}>
646 <strong>Revision {rv.r.revision}</strong>
647 </a>
648 {rv.r.message ? ` — ${rv.r.message}` : ""}
649 </div>
650 <div class="commit-meta">
651 by @{rv.author.username}
652 </div>
653 </div>
654 <a
655 href={`/gists/${slug}/revisions/${rv.r.revision}`}
656 class="commit-sha"
657 >
658 r{rv.r.revision}
659 </a>
660 </div>
661 ))}
662 </div>
663 </Layout>
664 );
665});
666
667// Revision detail
668gistRoutes.get(
669 "/gists/:slug/revisions/:rev",
670 softAuth,
671 async (c) => {
672 const user = c.get("user");
673 const slug = c.req.param("slug");
674 const rev = Number(c.req.param("rev"));
675
676 let gist: any = null;
677 let snapshot: Record<string, string> | null = null;
678 try {
679 const [row] = await db
680 .select()
681 .from(gists)
682 .where(eq(gists.slug, slug))
683 .limit(1);
684 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
685 gist = row;
686 const [rv] = await db
687 .select()
688 .from(gistRevisions)
689 .where(
690 and(
691 eq(gistRevisions.gistId, gist.id),
692 eq(gistRevisions.revision, rev)
693 )
694 )
695 .limit(1);
696 if (rv) {
697 try {
698 snapshot = JSON.parse(rv.snapshot);
699 } catch {
700 snapshot = {};
701 }
702 }
703 }
704 } catch {
705 // leave null
706 }
707
708 if (!gist || !snapshot)
709 return c.html(notFound(user, "Revision not found"), 404);
710
711 return c.html(
712 <Layout title={`${slug} @ r${rev}`} user={user}>
713 <h1 style="margin-top: 16px;">
714 <a href={`/gists/${slug}`}>{gist.title || slug}</a> @ revision {rev}
715 </h1>
716 {Object.entries(snapshot).map(([filename, content]) => {
717 const { html: highlighted } = highlightCode(content, filename);
718 return (
719 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
720 <div class="diff-file-header">{filename}</div>
721 <div class="blob-code">
722 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
723 {html([highlighted] as unknown as TemplateStringsArray)}
724 </pre>
725 </div>
726 </div>
727 );
728 })}
729 </Layout>
730 );
731 }
732);
733
734// User's public gists
735gistRoutes.get("/:username/gists", softAuth, async (c) => {
736 const user = c.get("user");
737 const username = c.req.param("username");
738
739 let ownerUser: any = null;
740 let rows: any[] = [];
741 try {
742 const [u] = await db
743 .select()
744 .from(users)
745 .where(eq(users.username, username))
746 .limit(1);
747 if (u) {
748 ownerUser = u;
749 const showPrivate = user && user.id === u.id;
750 rows = await db
751 .select({
752 g: gists,
753 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
754 })
755 .from(gists)
756 .where(
757 showPrivate
758 ? eq(gists.ownerId, u.id)
759 : and(eq(gists.ownerId, u.id), eq(gists.isPublic, true))
760 )
761 .orderBy(desc(gists.updatedAt));
762 }
763 } catch {
764 rows = [];
765 }
766
767 if (!ownerUser) return c.html(notFound(user, "User not found"), 404);
768
769 return c.html(
770 <Layout title={`@${username}'s gists`} user={user}>
771 <h1 style="margin-top: 16px;">
772 <a href={`/${username}`}>@{username}</a>'s gists
773 </h1>
774 {rows.length === 0 ? (
775 <div class="empty-state">
776 <p>No gists yet.</p>
777 </div>
778 ) : (
779 <div class="commit-list" style="margin-top: 16px;">
780 {rows.map((r) => (
781 <div class="commit-item">
782 <div>
783 <div class="commit-message">
784 <a href={`/gists/${r.g.slug}`}>
785 <strong>{r.g.title || r.g.slug}</strong>
786 </a>
787 {!r.g.isPublic && <span class="badge">Secret</span>}
788 </div>
789 <div class="commit-meta">
790 {r.fileCount} file{r.fileCount !== 1 ? "s" : ""}
791 {r.g.description && ` · ${r.g.description}`}
792 </div>
793 </div>
794 <a href={`/gists/${r.g.slug}`} class="commit-sha">
795 {r.g.slug}
796 </a>
797 </div>
798 ))}
799 </div>
800 )}
801 </Layout>
802 );
803});
804
805export default gistRoutes;