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

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

webhooks.tsxBlame298 lines · 1 contributor
c81ab7aClaude1/**
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}