ework-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ // Gitea-compatible webhook emitter.
2
+ //
3
+ // Emits `issues` (opened/closed/reopened) and `issue_comment` (created) events
4
+ // to project-scoped webhook URLs. Payload shape + headers mirror Gitea so that
5
+ // downstream consumers written against Gitea work
6
+ // unchanged. See:
7
+ // - Gitea docs: https://docs.gitea.com/development/webhooks
8
+ //
9
+ // Design notes:
10
+ // - HMAC-SHA256 signature is computed over the exact bytes we POST (rawBody),
11
+ // hex-encoded lowercase, matching Gitea's `X-Gitea-Signature`.
12
+ // - GitHub-compat alias `X-GitHub-Signature-256: sha256=<hex>` is included too.
13
+ // - Delivery is fire-and-forget: callers `void emitX(...)` and the HTTP response
14
+ // is not blocked. Failures are retried with exponential backoff, then recorded.
15
+ // - One DB row per delivery ATTEMPT (retries append), so the delivery log shows
16
+ // the full retry trail rather than an opaque final status.
17
+
18
+ import { createHmac, randomUUID } from "node:crypto";
19
+ import { rawDB } from "./db";
20
+ import { log } from "./logger";
21
+ import {
22
+ StoreError,
23
+ getIssueById,
24
+ getProjectById,
25
+ getDefaultUpstreamUrl,
26
+ ensureUser,
27
+ type IssueRow,
28
+ type ProjectRow,
29
+ type CommentRow,
30
+ } from "./store";
31
+
32
+ export type WebhookEventName = "issues" | "issue_comment";
33
+ export type IssueAction = "opened" | "closed" | "reopened";
34
+
35
+ export interface WebhookRow {
36
+ id: number;
37
+ project_id: number;
38
+ url: string;
39
+ secret: string;
40
+ content_type: string;
41
+ events: string; // JSON array, e.g. '["issues","issue_comment"]'
42
+ active: number; // 0 | 1
43
+ created_at: string;
44
+ updated_at: string;
45
+ }
46
+
47
+ export interface WebhookDeliveryRow {
48
+ id: number;
49
+ webhook_id: number;
50
+ event: WebhookEventName | string;
51
+ delivery_uuid: string;
52
+ payload: string;
53
+ response_status: number | null;
54
+ response_body: string | null;
55
+ duration_ms: number | null;
56
+ error: string | null;
57
+ created_at: string;
58
+ }
59
+
60
+ const DEFAULT_EVENTS: WebhookEventName[] = ["issues", "issue_comment"];
61
+ const MAX_RESPONSE_LOG_BYTES = 8192;
62
+ const HTTP_TIMEOUT_MS = 10_000;
63
+ const RETRY_DELAYS_MS = [0, 2_000, 8_000];
64
+
65
+ function now(): string {
66
+ return new Date().toISOString();
67
+ }
68
+
69
+ function parseEvents(events: unknown): WebhookEventName[] {
70
+ if (typeof events !== "string") return DEFAULT_EVENTS;
71
+ try {
72
+ const arr = JSON.parse(events) as unknown[];
73
+ const valid = arr.filter(
74
+ (v): v is WebhookEventName => v === "issues" || v === "issue_comment"
75
+ );
76
+ return valid.length > 0 ? valid : DEFAULT_EVENTS;
77
+ } catch {
78
+ return DEFAULT_EVENTS;
79
+ }
80
+ }
81
+
82
+ // ─── CRUD ────────────────────────────────────────────────────
83
+
84
+ export function listWebhooks(projectId: number): WebhookRow[] {
85
+ return rawDB()
86
+ .query("SELECT * FROM webhooks WHERE project_id = ? ORDER BY id")
87
+ .all(projectId) as WebhookRow[];
88
+ }
89
+
90
+ export function getWebhook(id: number): WebhookRow | null {
91
+ const row = rawDB().query("SELECT * FROM webhooks WHERE id = ?").get(id) as
92
+ | WebhookRow
93
+ | undefined;
94
+ return row ?? null;
95
+ }
96
+
97
+ export interface CreateWebhookInput {
98
+ project_id: number;
99
+ url: string;
100
+ secret?: string;
101
+ events?: WebhookEventName[];
102
+ active?: boolean;
103
+ }
104
+
105
+ export function createWebhook(input: CreateWebhookInput): WebhookRow {
106
+ const url = input.url.trim();
107
+ if (!url) throw new StoreError(400, "URL 不能为空");
108
+ if (!/^https?:\/\//i.test(url)) throw new StoreError(400, "URL 必须是 http(s)://");
109
+ if (url.length > 2048) throw new StoreError(400, "URL 过长");
110
+ const secret = (input.secret ?? "").slice(0, 256);
111
+ const events = input.events && input.events.length > 0 ? input.events : DEFAULT_EVENTS;
112
+ const ts = now();
113
+ const db = rawDB();
114
+ const info = db
115
+ .query(
116
+ `INSERT INTO webhooks (project_id, url, secret, content_type, events, active, created_at, updated_at)
117
+ VALUES (?, ?, ?, 'application/json', ?, ?, ?, ?)`
118
+ )
119
+ .run(
120
+ input.project_id,
121
+ url,
122
+ secret,
123
+ JSON.stringify(events),
124
+ input.active === false ? 0 : 1,
125
+ ts,
126
+ ts
127
+ );
128
+ return getWebhook(Number(info.lastInsertRowid))!;
129
+ }
130
+
131
+ export function deleteWebhook(id: number): void {
132
+ rawDB().query("DELETE FROM webhooks WHERE id = ?").run(id);
133
+ }
134
+
135
+ export function setWebhookActive(id: number, active: boolean): void {
136
+ rawDB()
137
+ .query("UPDATE webhooks SET active = ?, updated_at = ? WHERE id = ?")
138
+ .run(active ? 1 : 0, now(), id);
139
+ }
140
+
141
+ export function listDeliveries(webhookId: number, limit = 50): WebhookDeliveryRow[] {
142
+ return rawDB()
143
+ .query(
144
+ "SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT ?"
145
+ )
146
+ .all(webhookId, limit) as WebhookDeliveryRow[];
147
+ }
148
+
149
+ // ─── Payload builders (Gitea-compatible shape) ───────────────
150
+ //
151
+ // We populate the fields downstream consumers actually read (see Gitea's
152
+ // GiteaTracker.parseWebhookEvent): action, issue.{number,title,body,state,
153
+ // user.login,html_url}, comment.{id,body,user.login}, repository.{name,
154
+ // owner.login,full_name}, sender.login. We also include the rest of the
155
+ // standard Gitea fields (created_at, comments count, id, etc.) populated from
156
+ // ework's data so payload-completeness checkers / other Gitea clients work too.
157
+
158
+ interface PayloadUser {
159
+ id: number;
160
+ login: string;
161
+ login_name: string;
162
+ full_name: string;
163
+ email: string;
164
+ avatar_url: string;
165
+ html_url: string;
166
+ is_admin: boolean;
167
+ language: string;
168
+ }
169
+
170
+ interface PayloadLabel {
171
+ id: number;
172
+ name: string;
173
+ color: string;
174
+ description: string;
175
+ }
176
+
177
+ interface PayloadRepository {
178
+ id: number;
179
+ owner: PayloadUser;
180
+ name: string;
181
+ full_name: string;
182
+ description: string;
183
+ private: boolean;
184
+ fork: boolean;
185
+ parent: null;
186
+ empty: boolean;
187
+ mirror: boolean;
188
+ size: number;
189
+ html_url: string;
190
+ ssh_url: string;
191
+ clone_url: string;
192
+ website: string;
193
+ stars_count: number;
194
+ forks_count: number;
195
+ watchers_count: number;
196
+ open_issues_count: number;
197
+ default_branch: string;
198
+ created_at: string;
199
+ updated_at: string;
200
+ }
201
+
202
+ interface PayloadIssue {
203
+ id: number;
204
+ url: string;
205
+ html_url: string;
206
+ number: number;
207
+ title: string;
208
+ body: string;
209
+ labels: PayloadLabel[];
210
+ milestone: null;
211
+ assignee: null;
212
+ assignees: null;
213
+ state: "open" | "closed";
214
+ is_locked: boolean;
215
+ comments: number;
216
+ created_at: string;
217
+ updated_at: string;
218
+ closed_at: string | null;
219
+ due_date: null;
220
+ pull_request: null;
221
+ repository: PayloadRepository;
222
+ user: PayloadUser;
223
+ }
224
+
225
+ interface PayloadComment {
226
+ id: number;
227
+ html_url: string;
228
+ pull_request_url: string;
229
+ issue_url: string;
230
+ body: string;
231
+ created_at: string;
232
+ updated_at: string;
233
+ user: PayloadUser;
234
+ }
235
+
236
+ interface IssueEventPayload {
237
+ action: IssueAction;
238
+ issue: PayloadIssue;
239
+ repository: PayloadRepository;
240
+ sender: PayloadUser;
241
+ commit_id?: string;
242
+ commit_url?: string;
243
+ url?: string;
244
+ }
245
+
246
+ interface CommentEventPayload {
247
+ action: "created";
248
+ issue: PayloadIssue;
249
+ comment: PayloadComment;
250
+ repository: PayloadRepository;
251
+ sender: PayloadUser;
252
+ }
253
+
254
+ function buildUser(login: string, origin: string): PayloadUser {
255
+ return {
256
+ id: 1,
257
+ login,
258
+ login_name: "",
259
+ full_name: login,
260
+ email: `${login}@${new URL(origin).host ?? "localhost"}.noreply.ework`,
261
+ avatar_url: `${origin}/static/favicon.svg`,
262
+ html_url: origin,
263
+ is_admin: false,
264
+ language: "",
265
+ };
266
+ }
267
+
268
+ function buildRepository(project: ProjectRow, origin: string): PayloadRepository {
269
+ const fullName = `${project.owner}/${project.name}`;
270
+ const htmlUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
271
+ // clone_url must be a real Git remote (ework-web is NOT a Git server). Use the
272
+ // project's default upstream URL when bound; fall back to the synthetic htmlUrl
273
+ // + .git for purely tracker-only projects (recipients that don't attempt git
274
+ // clone will still see a syntactically valid URL).
275
+ const upstream = getDefaultUpstreamUrl(project);
276
+ return {
277
+ id: project.id,
278
+ owner: buildUser(project.owner, origin),
279
+ name: project.name,
280
+ full_name: fullName,
281
+ description: project.description ?? "",
282
+ private: false,
283
+ fork: false,
284
+ parent: null,
285
+ empty: false,
286
+ mirror: false,
287
+ size: 0,
288
+ html_url: htmlUrl,
289
+ ssh_url: "",
290
+ clone_url: upstream ?? `${htmlUrl}.git`,
291
+ website: "",
292
+ stars_count: 0,
293
+ forks_count: 0,
294
+ watchers_count: 1,
295
+ open_issues_count: 0,
296
+ default_branch: "main",
297
+ created_at: project.created_at,
298
+ updated_at: project.updated_at,
299
+ };
300
+ }
301
+
302
+ function buildIssue(
303
+ issue: IssueRow,
304
+ project: ProjectRow,
305
+ commentCount: number,
306
+ origin: string
307
+ ): PayloadIssue {
308
+ const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
309
+ const issueUrl = `${repoUrl}/issues/${issue.number}`;
310
+ return {
311
+ id: issue.id,
312
+ url: `${origin}/api/v1/repos/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/issues/${issue.number}`,
313
+ html_url: issueUrl,
314
+ number: issue.number,
315
+ title: issue.title,
316
+ body: issue.body ?? "",
317
+ labels: [],
318
+ milestone: null,
319
+ assignee: null,
320
+ assignees: null,
321
+ state: issue.state,
322
+ is_locked: false,
323
+ comments: commentCount,
324
+ created_at: issue.created_at,
325
+ updated_at: issue.updated_at,
326
+ closed_at: issue.state === "closed" ? issue.updated_at : null,
327
+ due_date: null,
328
+ pull_request: null,
329
+ repository: buildRepository(project, origin),
330
+ user: buildUser(issue.author, origin),
331
+ };
332
+ }
333
+
334
+ function buildComment(
335
+ issue: IssueRow,
336
+ comment: CommentRow,
337
+ project: ProjectRow,
338
+ origin: string
339
+ ): PayloadComment {
340
+ const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
341
+ const issueUrl = `${repoUrl}/issues/${issue.number}`;
342
+ return {
343
+ id: comment.id,
344
+ html_url: `${issueUrl}#issuecomment-${comment.id}`,
345
+ pull_request_url: "",
346
+ issue_url: issueUrl,
347
+ body: comment.body,
348
+ created_at: comment.created_at,
349
+ updated_at: comment.updated_at,
350
+ user: buildUser(comment.author, origin),
351
+ };
352
+ }
353
+
354
+ function buildCommentPayload(
355
+ issue: IssueRow,
356
+ comment: CommentRow,
357
+ project: ProjectRow,
358
+ commentCount: number,
359
+ origin: string
360
+ ): CommentEventPayload {
361
+ return {
362
+ action: "created",
363
+ issue: buildIssue(issue, project, commentCount, origin),
364
+ comment: buildComment(issue, comment, project, origin),
365
+ repository: buildRepository(project, origin),
366
+ sender: buildUser(comment.author, origin),
367
+ };
368
+ }
369
+
370
+ function buildIssuePayload(
371
+ issue: IssueRow,
372
+ project: ProjectRow,
373
+ commentCount: number,
374
+ action: IssueAction,
375
+ origin: string
376
+ ): IssueEventPayload {
377
+ return {
378
+ action,
379
+ issue: buildIssue(issue, project, commentCount, origin),
380
+ repository: buildRepository(project, origin),
381
+ sender: buildUser(issue.author, origin),
382
+ };
383
+ }
384
+
385
+ // ─── Delivery ────────────────────────────────────────────────
386
+
387
+ function signBody(secret: string, rawBody: string): string {
388
+ return createHmac("sha256", secret).update(rawBody).digest("hex");
389
+ }
390
+
391
+ function buildHeaders(
392
+ event: WebhookEventName,
393
+ deliveryUuid: string,
394
+ rawBody: string,
395
+ secret: string
396
+ ): Record<string, string> {
397
+ const sig = signBody(secret, rawBody);
398
+ return {
399
+ "Content-Type": "application/json",
400
+ "User-Agent": "eworkServer/0.1",
401
+ "X-Gitea-Delivery": deliveryUuid,
402
+ "X-Gitea-Event": event,
403
+ "X-Gitea-Signature": sig,
404
+ "X-Gitea-Event-Type": event,
405
+ "X-Gogs-Event": event,
406
+ "X-Gogs-Delivery": deliveryUuid,
407
+ "X-Gogs-Signature": sig,
408
+ "X-GitHub-Delivery": deliveryUuid,
409
+ "X-GitHub-Event": event,
410
+ "X-GitHub-Signature-256": `sha256=${sig}`,
411
+ };
412
+ }
413
+
414
+ interface DeliveryAttemptResult {
415
+ status: number | null;
416
+ body: string | null;
417
+ durationMs: number;
418
+ error: string | null;
419
+ }
420
+
421
+ async function postWithTimeout(
422
+ url: string,
423
+ body: string,
424
+ headers: Record<string, string>
425
+ ): Promise<DeliveryAttemptResult> {
426
+ const start = Date.now();
427
+ const ctrl = new AbortController();
428
+ const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
429
+ try {
430
+ const res = await fetch(url, {
431
+ method: "POST",
432
+ headers,
433
+ body,
434
+ signal: ctrl.signal,
435
+ redirect: "manual",
436
+ });
437
+ const text = await res.text().catch(() => "");
438
+ return {
439
+ status: res.status,
440
+ body: text.slice(0, MAX_RESPONSE_LOG_BYTES) || null,
441
+ durationMs: Date.now() - start,
442
+ error: null,
443
+ };
444
+ } catch (e) {
445
+ return {
446
+ status: null,
447
+ body: null,
448
+ durationMs: Date.now() - start,
449
+ error: e instanceof Error ? e.message : String(e),
450
+ };
451
+ } finally {
452
+ clearTimeout(timer);
453
+ }
454
+ }
455
+
456
+ function recordDelivery(
457
+ webhookId: number,
458
+ event: WebhookEventName,
459
+ deliveryUuid: string,
460
+ rawBody: string,
461
+ result: DeliveryAttemptResult
462
+ ): void {
463
+ try {
464
+ rawDB()
465
+ .query(
466
+ `INSERT INTO webhook_deliveries
467
+ (webhook_id, event, delivery_uuid, payload, response_status, response_body, duration_ms, error, created_at)
468
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
469
+ )
470
+ .run(
471
+ webhookId,
472
+ event,
473
+ deliveryUuid,
474
+ rawBody,
475
+ result.status,
476
+ result.body,
477
+ result.durationMs,
478
+ result.error,
479
+ now()
480
+ );
481
+ } catch (e) {
482
+ log.error("webhook: failed to record delivery", { err: e as Error });
483
+ }
484
+ }
485
+
486
+ async function deliver(
487
+ webhook: WebhookRow,
488
+ event: WebhookEventName,
489
+ rawBody: string
490
+ ): Promise<void> {
491
+ for (const attempt of RETRY_DELAYS_MS) {
492
+ if (attempt > 0) await Bun.sleep(attempt);
493
+ const deliveryUuid = randomUUID();
494
+ const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
495
+ const result = await postWithTimeout(webhook.url, rawBody, headers);
496
+ const success = result.status !== null && result.status >= 200 && result.status < 300;
497
+ recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
498
+ if (success) return;
499
+ // 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
500
+ if (result.status === 410) return;
501
+ }
502
+ log.warn("webhook: gave up after retries", {
503
+ attempts: RETRY_DELAYS_MS.length,
504
+ webhookId: webhook.id,
505
+ url: webhook.url,
506
+ event,
507
+ });
508
+ }
509
+
510
+ function fanOut(
511
+ projectId: number,
512
+ event: WebhookEventName,
513
+ rawBody: string
514
+ ): void {
515
+ const webhooks = rawDB()
516
+ .query("SELECT * FROM webhooks WHERE project_id = ? AND active = 1")
517
+ .all(projectId) as WebhookRow[];
518
+ for (const wh of webhooks) {
519
+ if (!parseEvents(wh.events).includes(event)) continue;
520
+ void deliver(wh, event, rawBody);
521
+ }
522
+ }
523
+
524
+ // ─── Public emit API ─────────────────────────────────────────
525
+
526
+ export function emitIssueEvent(
527
+ projectId: number,
528
+ issueId: number,
529
+ action: IssueAction,
530
+ origin: string
531
+ ): void {
532
+ try {
533
+ const project = getProjectById(projectId);
534
+ if (!project) return;
535
+ const issue = getIssueById(issueId);
536
+ if (!issue) return;
537
+ const commentCount = countCommentsSafe(issueId);
538
+ const payload = buildIssuePayload(issue, project, commentCount, action, origin);
539
+ const rawBody = JSON.stringify(payload);
540
+ fanOut(projectId, "issues", rawBody);
541
+ } catch (e) {
542
+ log.error("webhook: emitIssueEvent failed", {
543
+ err: e as Error,
544
+ projectId,
545
+ issueId,
546
+ action,
547
+ });
548
+ }
549
+ }
550
+
551
+ export function emitCommentEvent(
552
+ projectId: number,
553
+ issueId: number,
554
+ commentId: number,
555
+ origin: string
556
+ ): void {
557
+ try {
558
+ const project = getProjectById(projectId);
559
+ if (!project) return;
560
+ const issue = getIssueById(issueId);
561
+ if (!issue) return;
562
+ const comment = getCommentByIdSafe(commentId);
563
+ if (!comment) return;
564
+ const commentCount = countCommentsSafe(issueId);
565
+ const payload = buildCommentPayload(issue, comment, project, commentCount, origin);
566
+ const rawBody = JSON.stringify(payload);
567
+ fanOut(projectId, "issue_comment", rawBody);
568
+ } catch (e) {
569
+ log.error("webhook: emitCommentEvent failed", {
570
+ err: e as Error,
571
+ projectId,
572
+ issueId,
573
+ commentId,
574
+ });
575
+ }
576
+ }
577
+
578
+ export function emitPingEvent(projectId: number, origin: string): void {
579
+ // Synthetic ping: send a small `ping` event (no issue context). Useful for
580
+ // verifying webhook configuration without triggering a real write.
581
+ try {
582
+ const project = getProjectById(projectId);
583
+ if (!project) return;
584
+ const payload = {
585
+ zen: "smoke",
586
+ hook_id: 0,
587
+ hook: { type: "Gitea", id: 0, active: true, events: parseEvents(undefined) },
588
+ repository: buildRepository(project, origin),
589
+ sender: buildUser(project.owner, origin),
590
+ };
591
+ const rawBody = JSON.stringify(payload);
592
+ fanOut(projectId, "issues", rawBody);
593
+ } catch (e) {
594
+ log.error("webhook: emitPingEvent failed", { err: e as Error, projectId });
595
+ }
596
+ }
597
+
598
+ // ─── Helpers (kept here to avoid widening store.ts surface) ──
599
+
600
+ function countCommentsSafe(issueId: number): number {
601
+ try {
602
+ const row = rawDB()
603
+ .query("SELECT COUNT(*) AS n FROM comments WHERE issue_id = ?")
604
+ .get(issueId) as { n: number } | undefined;
605
+ return row?.n ?? 0;
606
+ } catch {
607
+ return 0;
608
+ }
609
+ }
610
+
611
+ function getCommentByIdSafe(id: number): CommentRow | null {
612
+ try {
613
+ const row = rawDB().query("SELECT * FROM comments WHERE id = ?").get(id) as
614
+ | CommentRow
615
+ | undefined;
616
+ return row ?? null;
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+
622
+ // Re-exported so callers don't need to import ensureUser separately for tests.
623
+ export const _internal = { ensureUser, buildHeaders, signBody, parseEvents };
624
+
625
+ // Payload builders shared with giteaApi.ts (REST shim). The shim must return
626
+ // the exact same shape as the webhook payloads so consumers (daemon, plugins)
627
+ // see one consistent Gitea-compatible contract regardless of whether they
628
+ // pull via webhook push or REST poll.
629
+ export {
630
+ buildUser,
631
+ buildRepository,
632
+ buildIssue as buildIssuePayload,
633
+ buildComment as buildCommentPayload,
634
+ };