Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitc81ab7aunknown_key

feat: Week 5 — forking, webhooks, explore, API tokens, topics

feat: Week 5 — forking, webhooks, explore, API tokens, topics

- Fork: clone bare repos between users, link back to parent,
  fork count tracking, activity logging
- Webhooks: register payload URLs with HMAC secret verification,
  select events (push/issue/pr/star), delivery status tracking,
  web management UI under repo settings
- Explore page: /explore with search, sort by recent/stars/forks,
  filter by topic
- API tokens: personal access tokens (glc_*) with SHA-256 hashing,
  scope management (repo/user/admin), web UI to generate and revoke
- Repository topics: tag repos for discoverability
- Schema: webhooks, api_tokens, repo_topics tables; forkedFromId
  on repositories
- Navigation: Explore link in header for all users
- Repo header: Fork button + fork count + "forked from" indicator
- 60 passing tests

https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
Claude committed on April 12, 2026Parent: 0074234
8 files changed+8818c81ab7a35ef565176f405b5c48210054cc4f246f
8 changed files+881−8
Modifiedsrc/app.tsx+16−0View fileUnifiedSplit
1111import compareRoutes from "./routes/compare";
1212import pullRoutes from "./routes/pulls";
1313import editorRoutes from "./routes/editor";
14import forkRoutes from "./routes/fork";
15import webhookRoutes from "./routes/webhooks";
16import exploreRoutes from "./routes/explore";
17import tokenRoutes from "./routes/tokens";
1418import webRoutes from "./routes/web";
1519
1620const app = new Hono();
3135// Settings routes (profile, SSH keys)
3236app.route("/", settingsRoutes);
3337
38// API tokens
39app.route("/", tokenRoutes);
40
3441// Repo settings (description, visibility, delete)
3542app.route("/", repoSettings);
3643
44// Webhooks management
45app.route("/", webhookRoutes);
46
3747// Compare view (branch diffs)
3848app.route("/", compareRoutes);
3949
4353// Pull requests
4454app.route("/", pullRoutes);
4555
56// Fork
57app.route("/", forkRoutes);
58
4659// Web file editor
4760app.route("/", editorRoutes);
4861
62// Explore page
63app.route("/", exploreRoutes);
64
4965// Web UI (catch-all, must be last)
5066app.route("/", webRoutes);
5167
Modifiedsrc/db/schema.ts+53−0View fileUnifiedSplit
4444 isPrivate: boolean("is_private").default(false).notNull(),
4545 defaultBranch: text("default_branch").default("main").notNull(),
4646 diskPath: text("disk_path").notNull(),
47 forkedFromId: uuid("forked_from_id").references(() => repositories.id, {
48 onDelete: "set null",
49 }),
4750 createdAt: timestamp("created_at").defaultNow().notNull(),
4851 updatedAt: timestamp("updated_at").defaultNow().notNull(),
4952 pushedAt: timestamp("pushed_at"),
212215 ]
213216);
214217
218export const webhooks = pgTable(
219 "webhooks",
220 {
221 id: uuid("id").primaryKey().defaultRandom(),
222 repositoryId: uuid("repository_id")
223 .notNull()
224 .references(() => repositories.id, { onDelete: "cascade" }),
225 url: text("url").notNull(),
226 secret: text("secret"),
227 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
228 isActive: boolean("is_active").default(true).notNull(),
229 lastDeliveredAt: timestamp("last_delivered_at"),
230 lastStatus: integer("last_status"),
231 createdAt: timestamp("created_at").defaultNow().notNull(),
232 },
233 (table) => [index("webhooks_repo").on(table.repositoryId)]
234);
235
236export const apiTokens = pgTable("api_tokens", {
237 id: uuid("id").primaryKey().defaultRandom(),
238 userId: uuid("user_id")
239 .notNull()
240 .references(() => users.id, { onDelete: "cascade" }),
241 name: text("name").notNull(),
242 tokenHash: text("token_hash").notNull(),
243 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
244 scopes: text("scopes").notNull().default("repo"), // comma-separated
245 lastUsedAt: timestamp("last_used_at"),
246 expiresAt: timestamp("expires_at"),
247 createdAt: timestamp("created_at").defaultNow().notNull(),
248});
249
250export const repoTopics = pgTable(
251 "repo_topics",
252 {
253 id: uuid("id").primaryKey().defaultRandom(),
254 repositoryId: uuid("repository_id")
255 .notNull()
256 .references(() => repositories.id, { onDelete: "cascade" }),
257 topic: text("topic").notNull(),
258 },
259 (table) => [
260 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
261 index("topics_name").on(table.topic),
262 ]
263);
264
215265export const sshKeys = pgTable("ssh_keys", {
216266 id: uuid("id").primaryKey().defaultRandom(),
217267 userId: uuid("user_id")
237287export type PullRequest = typeof pullRequests.$inferSelect;
238288export type PrComment = typeof prComments.$inferSelect;
239289export type ActivityEntry = typeof activityFeed.$inferSelect;
290export type Webhook = typeof webhooks.$inferSelect;
291export type ApiToken = typeof apiTokens.$inferSelect;
292export type RepoTopic = typeof repoTopics.$inferSelect;
Addedsrc/routes/explore.tsx+173−0View fileUnifiedSplit
1/**
2 * Explore page — discover public repositories, search, trending.
3 */
4
5import { Hono } from "hono";
6import { eq, desc, sql, like, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users, repoTopics } from "../db/schema";
9import { Layout } from "../views/layout";
10import { RepoCard } from "../views/components";
11import { softAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const explore = new Hono<AuthEnv>();
15
16explore.use("*", softAuth);
17
18explore.get("/explore", async (c) => {
19 const user = c.get("user");
20 const q = c.req.query("q") || "";
21 const sort = c.req.query("sort") || "recent";
22 const topic = c.req.query("topic") || "";
23
24 let repoList: Array<{
25 repo: typeof repositories.$inferSelect;
26 ownerName: string;
27 }> = [];
28
29 if (q.trim()) {
30 // Search repos
31 const results = await db
32 .select({
33 repo: repositories,
34 ownerName: users.username,
35 })
36 .from(repositories)
37 .innerJoin(users, eq(repositories.ownerId, users.id))
38 .where(
39 and(
40 eq(repositories.isPrivate, false),
41 sql`(${repositories.name} ILIKE ${'%' + q + '%'} OR ${repositories.description} ILIKE ${'%' + q + '%'})`
42 )
43 )
44 .orderBy(desc(repositories.starCount))
45 .limit(50);
46
47 repoList = results.map((r) => ({
48 repo: r.repo,
49 ownerName: r.ownerName,
50 }));
51 } else if (topic) {
52 // Filter by topic
53 const results = await db
54 .select({
55 repo: repositories,
56 ownerName: users.username,
57 })
58 .from(repositories)
59 .innerJoin(users, eq(repositories.ownerId, users.id))
60 .innerJoin(repoTopics, eq(repoTopics.repositoryId, repositories.id))
61 .where(
62 and(
63 eq(repositories.isPrivate, false),
64 eq(repoTopics.topic, topic.toLowerCase())
65 )
66 )
67 .orderBy(desc(repositories.starCount))
68 .limit(50);
69
70 repoList = results.map((r) => ({
71 repo: r.repo,
72 ownerName: r.ownerName,
73 }));
74 } else {
75 // Default: recent or popular
76 const orderBy =
77 sort === "stars"
78 ? desc(repositories.starCount)
79 : sort === "forks"
80 ? desc(repositories.forkCount)
81 : desc(repositories.createdAt);
82
83 const results = await db
84 .select({
85 repo: repositories,
86 ownerName: users.username,
87 })
88 .from(repositories)
89 .innerJoin(users, eq(repositories.ownerId, users.id))
90 .where(eq(repositories.isPrivate, false))
91 .orderBy(orderBy)
92 .limit(50);
93
94 repoList = results.map((r) => ({
95 repo: r.repo,
96 ownerName: r.ownerName,
97 }));
98 }
99
100 return c.html(
101 <Layout title="Explore" user={user}>
102 <h2 style="margin-bottom: 16px">Explore repositories</h2>
103 <div style="display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; align-items: center">
104 <form
105 method="GET"
106 action="/explore"
107 style="display: flex; gap: 8px; flex: 1; min-width: 250px"
108 >
109 <input
110 type="text"
111 name="q"
112 value={q}
113 placeholder="Search repositories..."
114 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
115 />
116 <button type="submit" class="btn btn-primary">
117 Search
118 </button>
119 </form>
120 <div style="display: flex; gap: 8px">
121 <a
122 href="/explore?sort=recent"
123 class={`btn btn-sm ${sort === "recent" && !q ? "btn-primary" : ""}`}
124 >
125 Recent
126 </a>
127 <a
128 href="/explore?sort=stars"
129 class={`btn btn-sm ${sort === "stars" ? "btn-primary" : ""}`}
130 >
131 Most stars
132 </a>
133 <a
134 href="/explore?sort=forks"
135 class={`btn btn-sm ${sort === "forks" ? "btn-primary" : ""}`}
136 >
137 Most forks
138 </a>
139 </div>
140 </div>
141 {topic && (
142 <div style="margin-bottom: 16px">
143 <span class="badge" style="font-size: 14px; padding: 4px 12px">
144 Topic: {topic}
145 </span>
146 <a
147 href="/explore"
148 style="margin-left: 8px; font-size: 13px; color: var(--text-muted)"
149 >
150 Clear
151 </a>
152 </div>
153 )}
154 {repoList.length === 0 ? (
155 <div class="empty-state">
156 <p>
157 {q
158 ? `No repositories matching "${q}"`
159 : "No public repositories yet."}
160 </p>
161 </div>
162 ) : (
163 <div class="card-grid">
164 {repoList.map(({ repo, ownerName }) => (
165 <RepoCard repo={repo} ownerName={ownerName} />
166 ))}
167 </div>
168 )}
169 </Layout>
170 );
171});
172
173export default explore;
Addedsrc/routes/fork.ts+103−0View fileUnifiedSplit
1/**
2 * Fork route — copy a repository into your account.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { repositories, users, activityFeed } from "../db/schema";
9import { softAuth, requireAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11import { getRepoPath, repoExists, initBareRepo } from "../git/repository";
12import { config } from "../lib/config";
13import { join } from "path";
14
15const fork = new Hono<AuthEnv>();
16
17fork.use("*", softAuth);
18
19// Fork a repository
20fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
21 const { owner: ownerName, repo: repoName } = c.req.param();
22 const user = c.get("user")!;
23
24 // Can't fork your own repo
25 if (ownerName === user.username) {
26 return c.redirect(`/${ownerName}/${repoName}`);
27 }
28
29 // Check source exists
30 if (!(await repoExists(ownerName, repoName))) {
31 return c.redirect(`/${ownerName}/${repoName}`);
32 }
33
34 // Check if already forked
35 if (await repoExists(user.username, repoName)) {
36 return c.redirect(`/${user.username}/${repoName}`);
37 }
38
39 // Get source repo from DB
40 const [sourceOwner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
46
47 const [sourceRepo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, sourceOwner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
58
59 // Clone the bare repo
60 const sourcePath = getRepoPath(ownerName, repoName);
61 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
62
63 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
64 stdout: "pipe",
65 stderr: "pipe",
66 });
67 await proc.exited;
68
69 // Insert into DB
70 await db.insert(repositories).values({
71 name: repoName,
72 ownerId: user.id,
73 description: sourceRepo.description
74 ? `Fork of ${ownerName}/${repoName}${sourceRepo.description}`
75 : `Fork of ${ownerName}/${repoName}`,
76 isPrivate: false,
77 defaultBranch: sourceRepo.defaultBranch,
78 diskPath: destPath,
79 forkedFromId: sourceRepo.id,
80 });
81
82 // Update fork count
83 await db
84 .update(repositories)
85 .set({ forkCount: sourceRepo.forkCount + 1 })
86 .where(eq(repositories.id, sourceRepo.id));
87
88 // Log activity
89 try {
90 await db.insert(activityFeed).values({
91 repositoryId: sourceRepo.id,
92 userId: user.id,
93 action: "fork",
94 metadata: JSON.stringify({ forkOwner: user.username }),
95 });
96 } catch {
97 // best effort
98 }
99
100 return c.redirect(`/${user.username}/${repoName}`);
101});
102
103export default fork;
Addedsrc/routes/tokens.tsx+209−0View fileUnifiedSplit
1/**
2 * API tokens — personal access tokens for automation.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { apiTokens } from "../db/schema";
9import { Layout } from "../views/layout";
10import { softAuth, requireAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
12
13const tokens = new Hono<AuthEnv>();
14
15tokens.use("/settings/tokens*", softAuth, requireAuth);
16tokens.use("/api/user/tokens*", softAuth, requireAuth);
17
18function generateToken(): string {
19 const bytes = crypto.getRandomValues(new Uint8Array(32));
20 return (
21 "glc_" +
22 Array.from(bytes)
23 .map((b) => b.toString(16).padStart(2, "0"))
24 .join("")
25 );
26}
27
28async function hashToken(token: string): Promise<string> {
29 const data = new TextEncoder().encode(token);
30 const hash = await crypto.subtle.digest("SHA-256", data);
31 return Array.from(new Uint8Array(hash))
32 .map((b) => b.toString(16).padStart(2, "0"))
33 .join("");
34}
35
36// Token settings page
37tokens.get("/settings/tokens", async (c) => {
38 const user = c.get("user")!;
39 const success = c.req.query("success");
40 const newToken = c.req.query("new_token");
41
42 const userTokens = await db
43 .select()
44 .from(apiTokens)
45 .where(eq(apiTokens.userId, user.id));
46
47 return c.html(
48 <Layout title="API Tokens" user={user}>
49 <div class="settings-container">
50 <h2>Personal access tokens</h2>
51 {success && (
52 <div class="auth-success" style="margin-top: 12px">
53 {decodeURIComponent(success)}
54 </div>
55 )}
56 {newToken && (
57 <div
58 class="auth-success"
59 style="margin-top: 12px; font-family: var(--font-mono); word-break: break-all"
60 >
61 New token (copy now — it won't be shown again):{" "}
62 <strong>{decodeURIComponent(newToken)}</strong>
63 </div>
64 )}
65 <div style="margin-top: 16px">
66 {userTokens.length === 0 ? (
67 <p style="color: var(--text-muted)">No tokens yet.</p>
68 ) : (
69 userTokens.map((token) => (
70 <div class="ssh-key-item">
71 <div>
72 <strong>{token.name}</strong>
73 <div class="ssh-key-meta">
74 <code>{token.tokenPrefix}...</code>
75 <span style="margin-left: 8px">
76 Scopes: {token.scopes}
77 </span>
78 {token.lastUsedAt && (
79 <span>
80 {" "}
81 | Last used{" "}
82 {new Date(token.lastUsedAt).toLocaleDateString()}
83 </span>
84 )}
85 </div>
86 </div>
87 <form
88 method="POST"
89 action={`/settings/tokens/${token.id}/delete`}
90 >
91 <button type="submit" class="btn btn-danger btn-sm">
92 Revoke
93 </button>
94 </form>
95 </div>
96 ))
97 )}
98 </div>
99
100 <h3 style="margin-top: 24px; margin-bottom: 12px">
101 Generate new token
102 </h3>
103 <form method="POST" action="/settings/tokens">
104 <div class="form-group">
105 <label for="name">Token name</label>
106 <input
107 type="text"
108 id="name"
109 name="name"
110 required
111 placeholder="e.g. CI/CD pipeline"
112 />
113 </div>
114 <div class="form-group">
115 <label>Scopes</label>
116 <div style="display: flex; gap: 16px; flex-wrap: wrap">
117 {["repo", "user", "admin"].map((scope) => (
118 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
119 <input
120 type="checkbox"
121 name="scopes"
122 value={scope}
123 checked={scope === "repo"}
124 />{" "}
125 {scope}
126 </label>
127 ))}
128 </div>
129 </div>
130 <button type="submit" class="btn btn-primary">
131 Generate token
132 </button>
133 </form>
134 </div>
135 </Layout>
136 );
137});
138
139// Create token
140tokens.post("/settings/tokens", async (c) => {
141 const user = c.get("user")!;
142 const body = await c.req.parseBody();
143 const name = String(body.name || "").trim();
144
145 let scopes: string;
146 const rawScopes = body.scopes;
147 if (Array.isArray(rawScopes)) {
148 scopes = rawScopes.join(",");
149 } else {
150 scopes = String(rawScopes || "repo");
151 }
152
153 if (!name) {
154 return c.redirect("/settings/tokens?error=Name+is+required");
155 }
156
157 const token = generateToken();
158 const tokenH = await hashToken(token);
159
160 await db.insert(apiTokens).values({
161 userId: user.id,
162 name,
163 tokenHash: tokenH,
164 tokenPrefix: token.slice(0, 12),
165 scopes,
166 });
167
168 return c.redirect(
169 `/settings/tokens?new_token=${encodeURIComponent(token)}`
170 );
171});
172
173// Delete token
174tokens.post("/settings/tokens/:id/delete", async (c) => {
175 const user = c.get("user")!;
176 const tokenId = c.req.param("id");
177
178 const [token] = await db
179 .select()
180 .from(apiTokens)
181 .where(eq(apiTokens.id, tokenId))
182 .limit(1);
183
184 if (!token || token.userId !== user.id) {
185 return c.redirect("/settings/tokens");
186 }
187
188 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
189 return c.redirect("/settings/tokens?success=Token+revoked");
190});
191
192// API endpoint
193tokens.get("/api/user/tokens", async (c) => {
194 const user = c.get("user")!;
195 const userTokens = await db
196 .select({
197 id: apiTokens.id,
198 name: apiTokens.name,
199 tokenPrefix: apiTokens.tokenPrefix,
200 scopes: apiTokens.scopes,
201 lastUsedAt: apiTokens.lastUsedAt,
202 createdAt: apiTokens.createdAt,
203 })
204 .from(apiTokens)
205 .where(eq(apiTokens.userId, user.id));
206 return c.json(userTokens);
207});
208
209export default tokens;
Addedsrc/routes/webhooks.tsx+298−0View fileUnifiedSplit
1/**
2 * Webhooks management — register, list, delete, test.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
8import { webhooks, repositories, users } from "../db/schema";
9import { Layout } from "../views/layout";
10import { RepoHeader } from "../views/components";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const webhookRoutes = new Hono<AuthEnv>();
15
16webhookRoutes.use("*", softAuth);
17
18// List webhooks
19webhookRoutes.get(
20 "/:owner/:repo/settings/webhooks",
21 requireAuth,
22 async (c) => {
23 const { owner: ownerName, repo: repoName } = c.req.param();
24 const user = c.get("user")!;
25 const success = c.req.query("success");
26 const error = c.req.query("error");
27
28 const [owner] = await db
29 .select()
30 .from(users)
31 .where(eq(users.username, ownerName))
32 .limit(1);
33 if (!owner || owner.id !== user.id) {
34 return c.text("Unauthorized", 403);
35 }
36
37 const [repo] = await db
38 .select()
39 .from(repositories)
40 .where(
41 and(
42 eq(repositories.ownerId, owner.id),
43 eq(repositories.name, repoName)
44 )
45 )
46 .limit(1);
47 if (!repo) return c.notFound();
48
49 const hooks = await db
50 .select()
51 .from(webhooks)
52 .where(eq(webhooks.repositoryId, repo.id));
53
54 return c.html(
55 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
56 <RepoHeader owner={ownerName} repo={repoName} />
57 <div style="max-width: 700px">
58 <h2 style="margin-bottom: 16px">Webhooks</h2>
59 {success && (
60 <div class="auth-success">{decodeURIComponent(success)}</div>
61 )}
62 {error && (
63 <div class="auth-error">{decodeURIComponent(error)}</div>
64 )}
65 {hooks.length > 0 && (
66 <div style="margin-bottom: 24px">
67 {hooks.map((hook) => (
68 <div class="ssh-key-item">
69 <div>
70 <strong>{hook.url}</strong>
71 <div class="ssh-key-meta">
72 Events: {hook.events} |{" "}
73 {hook.isActive ? (
74 <span style="color: var(--green)">Active</span>
75 ) : (
76 <span style="color: var(--red)">Inactive</span>
77 )}
78 {hook.lastDeliveredAt && (
79 <span>
80 {" "}
81 | Last: {hook.lastStatus}
82 </span>
83 )}
84 </div>
85 </div>
86 <form
87 method="POST"
88 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
89 >
90 <button type="submit" class="btn btn-danger btn-sm">
91 Delete
92 </button>
93 </form>
94 </div>
95 ))}
96 </div>
97 )}
98
99 <h3 style="margin-bottom: 12px">Add webhook</h3>
100 <form
101 method="POST"
102 action={`/${ownerName}/${repoName}/settings/webhooks`}
103 >
104 <div class="form-group">
105 <label>Payload URL</label>
106 <input
107 type="url"
108 name="url"
109 required
110 placeholder="https://example.com/hooks/gluecron"
111 />
112 </div>
113 <div class="form-group">
114 <label>Secret (optional)</label>
115 <input
116 type="text"
117 name="secret"
118 placeholder="Shared secret for HMAC verification"
119 />
120 </div>
121 <div class="form-group">
122 <label>Events</label>
123 <div style="display: flex; gap: 16px; flex-wrap: wrap">
124 {["push", "issue", "pr", "star"].map((evt) => (
125 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
126 <input
127 type="checkbox"
128 name="events"
129 value={evt}
130 checked={evt === "push"}
131 />{" "}
132 {evt}
133 </label>
134 ))}
135 </div>
136 </div>
137 <button type="submit" class="btn btn-primary">
138 Add webhook
139 </button>
140 </form>
141 </div>
142 </Layout>
143 );
144 }
145);
146
147// Create webhook
148webhookRoutes.post(
149 "/:owner/:repo/settings/webhooks",
150 requireAuth,
151 async (c) => {
152 const { owner: ownerName, repo: repoName } = c.req.param();
153 const user = c.get("user")!;
154 const body = await c.req.parseBody();
155 const url = String(body.url || "").trim();
156 const secret = String(body.secret || "").trim() || null;
157
158 // Events can be a string or array
159 let events: string;
160 const rawEvents = body.events;
161 if (Array.isArray(rawEvents)) {
162 events = rawEvents.join(",");
163 } else {
164 events = String(rawEvents || "push");
165 }
166
167 if (!url) {
168 return c.redirect(
169 `/${ownerName}/${repoName}/settings/webhooks?error=URL+is+required`
170 );
171 }
172
173 const [owner] = await db
174 .select()
175 .from(users)
176 .where(eq(users.username, ownerName))
177 .limit(1);
178 if (!owner || owner.id !== user.id) {
179 return c.redirect(`/${ownerName}/${repoName}`);
180 }
181
182 const [repo] = await db
183 .select()
184 .from(repositories)
185 .where(
186 and(
187 eq(repositories.ownerId, owner.id),
188 eq(repositories.name, repoName)
189 )
190 )
191 .limit(1);
192 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
193
194 await db.insert(webhooks).values({
195 repositoryId: repo.id,
196 url,
197 secret,
198 events,
199 });
200
201 return c.redirect(
202 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+added`
203 );
204 }
205);
206
207// Delete webhook
208webhookRoutes.post(
209 "/:owner/:repo/settings/webhooks/:id/delete",
210 requireAuth,
211 async (c) => {
212 const { owner: ownerName, repo: repoName, id } = c.req.param();
213
214 await db.delete(webhooks).where(eq(webhooks.id, id));
215
216 return c.redirect(
217 `/${ownerName}/${repoName}/settings/webhooks?success=Webhook+deleted`
218 );
219 }
220);
221
222export default webhookRoutes;
223
224/**
225 * Fire webhooks for a repository event.
226 */
227export async function fireWebhooks(
228 repositoryId: string,
229 event: string,
230 payload: Record<string, unknown>
231): Promise<void> {
232 try {
233 const hooks = await db
234 .select()
235 .from(webhooks)
236 .where(eq(webhooks.repositoryId, repositoryId));
237
238 for (const hook of hooks) {
239 if (!hook.isActive) continue;
240 const hookEvents = hook.events.split(",");
241 if (!hookEvents.includes(event)) continue;
242
243 try {
244 const headers: Record<string, string> = {
245 "Content-Type": "application/json",
246 "X-Gluecron-Event": event,
247 };
248
249 if (hook.secret) {
250 const encoder = new TextEncoder();
251 const key = await crypto.subtle.importKey(
252 "raw",
253 encoder.encode(hook.secret),
254 { name: "HMAC", hash: "SHA-256" },
255 false,
256 ["sign"]
257 );
258 const signature = await crypto.subtle.sign(
259 "HMAC",
260 key,
261 encoder.encode(JSON.stringify(payload))
262 );
263 headers["X-Gluecron-Signature"] =
264 "sha256=" +
265 Array.from(new Uint8Array(signature))
266 .map((b) => b.toString(16).padStart(2, "0"))
267 .join("");
268 }
269
270 const res = await fetch(hook.url, {
271 method: "POST",
272 headers,
273 body: JSON.stringify(payload),
274 signal: AbortSignal.timeout(10000),
275 });
276
277 await db
278 .update(webhooks)
279 .set({
280 lastDeliveredAt: new Date(),
281 lastStatus: res.status,
282 })
283 .where(eq(webhooks.id, hook.id));
284 } catch (err) {
285 console.error(`[webhook] delivery failed for ${hook.url}:`, err);
286 await db
287 .update(webhooks)
288 .set({
289 lastDeliveredAt: new Date(),
290 lastStatus: 0,
291 })
292 .where(eq(webhooks.id, hook.id));
293 }
294 }
295 } catch (err) {
296 console.error("[webhook] failed to query webhooks:", err);
297 }
298}
Modifiedsrc/views/components.tsx+26−8View fileUnifiedSplit
88 repo: string;
99 starCount?: number;
1010 starred?: boolean;
11 forkCount?: number;
1112 currentUser?: string | null;
12}> = ({ owner, repo, starCount, starred, currentUser }) => (
13 forkedFrom?: string | null;
14}> = ({ owner, repo, starCount, starred, forkCount, currentUser, forkedFrom }) => (
1315 <div class="repo-header">
14 <a href={`/${owner}`} class="owner">
15 {owner}
16 </a>
17 <span class="separator">/</span>
18 <a href={`/${owner}/${repo}`} class="name">
19 {repo}
20 </a>
16 <div>
17 <div style="display: flex; align-items: center; gap: 8px; font-size: 20px">
18 <a href={`/${owner}`} class="owner">
19 {owner}
20 </a>
21 <span class="separator">/</span>
22 <a href={`/${owner}/${repo}`} class="name">
23 {repo}
24 </a>
25 </div>
26 {forkedFrom && (
27 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
28 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
29 </div>
30 )}
31 </div>
2132 <div class="repo-header-actions">
33 {currentUser && currentUser !== owner && (
34 <form method="POST" action={`/${owner}/${repo}/fork`} style="display:inline">
35 <button type="submit" class="star-btn">
36 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
37 </button>
38 </form>
39 )}
2240 {starCount !== undefined && (
2341 currentUser ? (
2442 <form method="POST" action={`/${owner}/${repo}/star`} style="display:inline">
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
2121 gluecron
2222 </a>
2323 <div class="nav-right">
24 <a href="/explore" class="nav-link">
25 Explore
26 </a>
2427 {user ? (
2528 <>
2629 <a href="/new" class="btn btn-sm btn-primary">
2730