feat: push-mirror, webhook queue, auth-cache, gists/discussions
swadhinbiswas committed 1 months ago
96005fc
1 parent 3846c8b
commit 96005fc
11 files changed
+1435 -3
Showing 11 changed files+1435-3
▼.env.example
+3-3
| @@ -164,9 +164,9 @@ BACKUP_DIR=./backups | ||
164 | 164 | AIR_GAPPED_MODE=false |
165 | 165 | |
166 | 166 | # ── Observability (optional) ──────────────────────────────── |
167 | -# Bearer token protecting GET /api/metrics (Prometheus). Set a value in | |
168 | -# production; when empty, the endpoint is public and should be restricted | |
169 | -# at the reverse proxy. | |
167 | +# Bearer token protecting GET /api/metrics (Prometheus). Empty = public in | |
168 | +# non-production; denied with 403 in production when empty. | |
169 | +# Production requires METRICS_TOKEN to be set. | |
170 | 170 | METRICS_TOKEN= |
171 | 171 | |
172 | 172 | # Grafana Cloud OTLP |
173 | 173 | |
▼drizzle/0008_add_push_mirror.sql
+5-0
| @@ -0,0 +1,5 @@ | ||
1 | +ALTER TABLE "repositories" ADD COLUMN "push_mirror_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint | |
2 | +ALTER TABLE "repositories" ADD COLUMN "push_mirror_url" text;--> statement-breakpoint | |
3 | +ALTER TABLE "repositories" ADD COLUMN "push_mirror_token" text;--> statement-breakpoint | |
4 | +ALTER TABLE "repositories" ADD COLUMN "last_push_mirror_at" timestamp;--> statement-breakpoint | |
5 | +ALTER TABLE "repositories" ADD COLUMN "push_mirror_status" text; | |
0 | 6 | |
▼drizzle/0009_add_webhook_queue.sql
+5-0
| @@ -0,0 +1,5 @@ | ||
1 | +ALTER TABLE "webhook_deliveries" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint | |
2 | +ALTER TABLE "webhook_deliveries" ADD COLUMN "next_attempt_at" timestamp;--> statement-breakpoint | |
3 | +ALTER TABLE "webhook_deliveries" ADD COLUMN "locked_at" timestamp;--> statement-breakpoint | |
4 | +ALTER TABLE "webhook_deliveries" ADD COLUMN "failure_reason" text;--> statement-breakpoint | |
5 | +CREATE INDEX IF NOT EXISTS "webhook_deliveries_queue_idx" ON "webhook_deliveries" ("status","next_attempt_at"); | |
0 | 6 | |
▼drizzle/meta/_journal.json
+14-0
| @@ -57,6 +57,20 @@ | ||
57 | 57 | "when": 1787125181115, |
58 | 58 | "tag": "0007_add_mirror_username", |
59 | 59 | "breakpoints": true |
60 | + }, | |
61 | + { | |
62 | + "idx": 8, | |
63 | + "version": "7", | |
64 | + "when": 1787400000000, | |
65 | + "tag": "0008_add_push_mirror", | |
66 | + "breakpoints": true | |
67 | + }, | |
68 | + { | |
69 | + "idx": 9, | |
70 | + "version": "7", | |
71 | + "when": 1787500000000, | |
72 | + "tag": "0009_add_webhook_queue", | |
73 | + "breakpoints": true | |
60 | 74 | } |
61 | 75 | ] |
62 | 76 | } |
63 | 77 | |
▼src/db/schema/discussions.ts
+89-0
| @@ -0,0 +1,89 @@ | ||
1 | +/** | |
2 | + * Discussions Schema - Drizzle ORM | |
3 | + * GitHub-style repository discussions and threaded comments | |
4 | + */ | |
5 | + | |
6 | +import { | |
7 | + boolean, | |
8 | + index, | |
9 | + integer, | |
10 | + pgTable, | |
11 | + text, | |
12 | + timestamp, | |
13 | +} from "drizzle-orm/pg-core"; | |
14 | +import { repositories } from "./repositories"; | |
15 | +import { users } from "./users"; | |
16 | + | |
17 | +export const DISCUSSION_CATEGORIES = [ | |
18 | + "General", | |
19 | + "Ideas", | |
20 | + "Q&A", | |
21 | + "Show and tell", | |
22 | +] as const; | |
23 | + | |
24 | +export type DiscussionCategory = (typeof DISCUSSION_CATEGORIES)[number]; | |
25 | + | |
26 | +export const discussions = pgTable( | |
27 | + "discussions", | |
28 | + { | |
29 | + id: text("id").primaryKey(), | |
30 | + repositoryId: text("repository_id") | |
31 | + .notNull() | |
32 | + .references(() => repositories.id, { onDelete: "cascade" }), | |
33 | + authorId: text("author_id") | |
34 | + .notNull() | |
35 | + .references(() => users.id), | |
36 | + title: text("title").notNull(), | |
37 | + body: text("body").notNull(), | |
38 | + category: text("category").notNull().default("General"), | |
39 | + pinned: boolean("pinned").default(false), | |
40 | + closed: boolean("closed").default(false), | |
41 | + commentCount: integer("comment_count").default(0), | |
42 | + lastActivityAt: timestamp("last_activity_at"), | |
43 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
44 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
45 | + }, | |
46 | + (t) => ({ | |
47 | + repoClosedIdx: index("discussions_repo_closed_idx").on( | |
48 | + t.repositoryId, | |
49 | + t.closed, | |
50 | + ), | |
51 | + repoActivityIdx: index("discussions_repo_activity_idx").on( | |
52 | + t.repositoryId, | |
53 | + t.lastActivityAt, | |
54 | + ), | |
55 | + authorIdx: index("discussions_author_idx").on(t.authorId), | |
56 | + }), | |
57 | +); | |
58 | + | |
59 | +export const discussionComments = pgTable( | |
60 | + "discussion_comments", | |
61 | + { | |
62 | + id: text("id").primaryKey(), | |
63 | + discussionId: text("discussion_id") | |
64 | + .notNull() | |
65 | + .references(() => discussions.id, { onDelete: "cascade" }), | |
66 | + // Self-reference for one-level threading; stored but treated flat in UI v1. | |
67 | + // Plain text column (no FK) mirrors the issues.parent_id convention to avoid | |
68 | + // circular type references in Drizzle. | |
69 | + parentId: text("parent_id"), | |
70 | + authorId: text("author_id") | |
71 | + .notNull() | |
72 | + .references(() => users.id), | |
73 | + body: text("body").notNull(), | |
74 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
75 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
76 | + }, | |
77 | + (t) => ({ | |
78 | + discussionIdx: index("discussion_comments_discussion_idx").on( | |
79 | + t.discussionId, | |
80 | + ), | |
81 | + parentIdx: index("discussion_comments_parent_idx").on(t.parentId), | |
82 | + }), | |
83 | +); | |
84 | + | |
85 | +// Types | |
86 | +export type Discussion = typeof discussions.$inferSelect; | |
87 | +export type NewDiscussion = typeof discussions.$inferInsert; | |
88 | +export type DiscussionComment = typeof discussionComments.$inferSelect; | |
89 | +export type NewDiscussionComment = typeof discussionComments.$inferInsert; | |
0 | 90 | |
▼src/db/schema/gists.ts
+54-0
| @@ -0,0 +1,54 @@ | ||
1 | +/** | |
2 | + * Gists Schema - Drizzle ORM | |
3 | + * GitHub-style standalone code snippets (multi-file, public or secret). | |
4 | + * V1 scope: no stars, forks, or comments. | |
5 | + */ | |
6 | + | |
7 | +import { relations } from "drizzle-orm"; | |
8 | +import { | |
9 | + boolean, | |
10 | + index, | |
11 | + jsonb, | |
12 | + pgTable, | |
13 | + text, | |
14 | + timestamp, | |
15 | +} from "drizzle-orm/pg-core"; | |
16 | +import { users } from "./users"; | |
17 | + | |
18 | +/** A single file inside a gist */ | |
19 | +export interface GistFile { | |
20 | + filename: string; | |
21 | + content: string; | |
22 | +} | |
23 | + | |
24 | +export const gists = pgTable( | |
25 | + "gists", | |
26 | + { | |
27 | + id: text("id").primaryKey(), | |
28 | + userId: text("user_id") | |
29 | + .notNull() | |
30 | + .references(() => users.id, { onDelete: "cascade" }), | |
31 | + description: text("description").notNull().default(""), | |
32 | + // false = secret (unlisted but link-accessible), true = public | |
33 | + public: boolean("public").notNull().default(false), | |
34 | + files: jsonb("files").$type<GistFile[]>().notNull(), | |
35 | + createdAt: timestamp("created_at").notNull().defaultNow(), | |
36 | + updatedAt: timestamp("updated_at").notNull().defaultNow(), | |
37 | + }, | |
38 | + (t) => ({ | |
39 | + userUpdatedIdx: index("gists_user_updated_idx").on(t.userId, t.updatedAt), | |
40 | + publicIdx: index("gists_public_idx").on(t.public), | |
41 | + }), | |
42 | +); | |
43 | + | |
44 | +// Relations | |
45 | +export const gistsRelations = relations(gists, ({ one }) => ({ | |
46 | + user: one(users, { | |
47 | + fields: [gists.userId], | |
48 | + references: [users.id], | |
49 | + }), | |
50 | +})); | |
51 | + | |
52 | +// Types | |
53 | +export type Gist = typeof gists.$inferSelect; | |
54 | +export type NewGist = typeof gists.$inferInsert; | |
0 | 55 | |
▼src/lib/auth-cache.ts
+129-0
| @@ -0,0 +1,129 @@ | ||
1 | +/** | |
2 | + * Short-TTL in-memory cache for middleware auth lookups. | |
3 | + * | |
4 | + * The request pipeline resolves the full user row (and session row) on every | |
5 | + * request — 2 DB queries per request under load. This cache absorbs that cost | |
6 | + * with a small, bounded, short-TTL window. Revocation latency is therefore | |
7 | + * bounded by AUTH_CACHE_TTL_MS; logout calls invalidateAuthCache for | |
8 | + * immediate effect. | |
9 | + * | |
10 | + * Hit/miss outcomes are recorded via the cacheHits/cacheMisses Prometheus | |
11 | + * counters from src/lib/metrics.ts. | |
12 | + */ | |
13 | +import { cacheHits, cacheMisses } from "@/lib/metrics"; | |
14 | +import { logger } from "@/lib/logger"; | |
15 | + | |
16 | +const TTL_MS = Math.max( | |
17 | + 1000, | |
18 | + parseInt(process.env.AUTH_CACHE_TTL_MS || "15000", 10), | |
19 | +); | |
20 | +const MAX_ENTRIES = 5000; | |
21 | + | |
22 | +interface CacheEntry<T> { | |
23 | + value: T; | |
24 | + expiresAt: number; | |
25 | +} | |
26 | + | |
27 | +const userCache = new Map<string, CacheEntry<unknown>>(); | |
28 | +interface SessionCacheEntry extends CacheEntry<unknown> { | |
29 | + ownerUserId?: string; | |
30 | +} | |
31 | +const sessionCache = new Map<string, SessionCacheEntry>(); | |
32 | + | |
33 | +function evictOldest( | |
34 | + map: Map<string, CacheEntry<unknown> | SessionCacheEntry>, | |
35 | +): void { | |
36 | + while (map.size >= MAX_ENTRIES) { | |
37 | + const oldest = map.keys().next().value; | |
38 | + if (oldest === undefined) break; | |
39 | + map.delete(oldest); | |
40 | + } | |
41 | +} | |
42 | + | |
43 | +function getCached( | |
44 | + map: Map<string, CacheEntry<unknown>>, | |
45 | + key: string, | |
46 | +): unknown | undefined { | |
47 | + const entry = map.get(key); | |
48 | + if (!entry) { | |
49 | + cacheMisses.inc(); | |
50 | + return undefined; | |
51 | + } | |
52 | + if (Date.now() > entry.expiresAt) { | |
53 | + map.delete(key); | |
54 | + cacheMisses.inc(); | |
55 | + return undefined; | |
56 | + } | |
57 | + cacheHits.inc(); | |
58 | + return entry.value; | |
59 | +} | |
60 | + | |
61 | +function setCached( | |
62 | + map: Map<string, CacheEntry<unknown> | SessionCacheEntry>, | |
63 | + key: string, | |
64 | + value: unknown, | |
65 | + ownerUserId?: string, | |
66 | +): void { | |
67 | + evictOldest(map); | |
68 | + map.set(key, { | |
69 | + value, | |
70 | + expiresAt: Date.now() + TTL_MS, | |
71 | + ...(ownerUserId ? { ownerUserId } : {}), | |
72 | + }); | |
73 | +} | |
74 | + | |
75 | +export async function cachedUserLookup<T>( | |
76 | + userId: string, | |
77 | + lookup: () => Promise<T | null>, | |
78 | +): Promise<T | null> { | |
79 | + const hit = getCached(userCache, userId); | |
80 | + if (hit !== undefined) return hit as T; | |
81 | + const value = await lookup(); | |
82 | + if (value !== null && value !== undefined) { | |
83 | + setCached(userCache, userId, value); | |
84 | + } | |
85 | + return value ?? null; | |
86 | +} | |
87 | + | |
88 | +export async function cachedSessionLookup<T>( | |
89 | + sessionId: string, | |
90 | + lookup: () => Promise<T | null>, | |
91 | +): Promise<T | null> { | |
92 | + const hit = getCached(sessionCache, sessionId); | |
93 | + if (hit !== undefined) return hit as T; | |
94 | + const value = await lookup(); | |
95 | + if ( | |
96 | + value !== null && | |
97 | + value !== undefined && | |
98 | + typeof value === "object" && | |
99 | + "userId" in (value as Record<string, unknown>) | |
100 | + ) { | |
101 | + setCached( | |
102 | + sessionCache, | |
103 | + sessionId, | |
104 | + value, | |
105 | + (value as { userId?: string }).userId, | |
106 | + ); | |
107 | + } | |
108 | + return value ?? null; | |
109 | +} | |
110 | + | |
111 | +/** Invalidate all cached entries for a user (call on logout/password change). */ | |
112 | +export function invalidateAuthCache(userId?: string, sessionId?: string): void { | |
113 | + if (userId) userCache.delete(userId); | |
114 | + if (sessionId) sessionCache.delete(sessionId); | |
115 | + logger.debug({ userId, sessionId }, "Auth cache invalidated"); | |
116 | +} | |
117 | + | |
118 | +/** | |
119 | + * Drop every cached session row owned by a user — used when all their | |
120 | + * sessions are revoked server-side (password change/reset) so cached rows | |
121 | + * cannot outlive the revocation for the TTL window. | |
122 | + */ | |
123 | +export function revokeUserSessionCache(userId: string): void { | |
124 | + userCache.delete(userId); | |
125 | + for (const [sessionId, entry] of sessionCache) { | |
126 | + if (entry.ownerUserId === userId) sessionCache.delete(sessionId); | |
127 | + } | |
128 | + logger.debug({ userId }, "User session cache revoked"); | |
129 | +} | |
0 | 130 | |
▼src/lib/commit-signature.ts
+317-0
| @@ -0,0 +1,317 @@ | ||
1 | +/** | |
2 | + * Commit Signature Verification Library | |
3 | + * GitHub-style "Verified" commit badges | |
4 | + * Verifies git commit GPG signatures against user-uploaded keys | |
5 | + * in the gpg_keys table using openpgp (no system keyring needed) | |
6 | + */ | |
7 | + | |
8 | +import { getDatabase, schema } from "@/db"; | |
9 | +import { and, eq, inArray } from "drizzle-orm"; | |
10 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
11 | +import * as openpgp from "openpgp"; | |
12 | + | |
13 | +import { logger } from "./logger"; | |
14 | + | |
15 | +// Types | |
16 | + | |
17 | +export interface CommitVerification { | |
18 | + /** Commit object contains a gpgsig/gpgsig-sha256 header */ | |
19 | + signed: boolean; | |
20 | + /** Signature cryptographically valid AND owned by the committer's account */ | |
21 | + verified: boolean; | |
22 | + /** A public key stored in gpg_keys verified the signature */ | |
23 | + validKeyInDb: boolean; | |
24 | + /** users.id of the committer matched by committer email, if registered */ | |
25 | + signerUserId: string | null; | |
26 | +} | |
27 | + | |
28 | +const UNSIGNED_RESULT: CommitVerification = { | |
29 | + signed: false, | |
30 | + verified: false, | |
31 | + validKeyInDb: false, | |
32 | + signerUserId: null, | |
33 | +}; | |
34 | + | |
35 | +// Cache | |
36 | + | |
37 | +const CACHE_TTL_MS = 10 * 60 * 1000; | |
38 | +const CACHE_MAX_ENTRIES = 2000; | |
39 | + | |
40 | +interface CacheEntry { | |
41 | + result: CommitVerification; | |
42 | + expiresAt: number; | |
43 | +} | |
44 | + | |
45 | +const verificationCache = new Map<string, CacheEntry>(); | |
46 | + | |
47 | +function cacheGet(key: string): CommitVerification | null { | |
48 | + const entry = verificationCache.get(key); | |
49 | + if (!entry) return null; | |
50 | + if (Date.now() > entry.expiresAt) { | |
51 | + verificationCache.delete(key); | |
52 | + return null; | |
53 | + } | |
54 | + return entry.result; | |
55 | +} | |
56 | + | |
57 | +function cacheSet(key: string, result: CommitVerification): void { | |
58 | + while (verificationCache.size >= CACHE_MAX_ENTRIES) { | |
59 | + const oldest = verificationCache.keys().next().value; | |
60 | + if (oldest === undefined) break; | |
61 | + verificationCache.delete(oldest); | |
62 | + } | |
63 | + verificationCache.set(key, { result, expiresAt: Date.now() + CACHE_TTL_MS }); | |
64 | +} | |
65 | + | |
66 | +// Signature extraction | |
67 | + | |
68 | +/** | |
69 | + * Parse the raw output of `git cat-file commit <sha>`. | |
70 | + * Returns the armored signature block and the exact commit bytes with all | |
71 | + * gpgsig/gpgsig-sha256 header lines stripped (this is what Git signs). | |
72 | + */ | |
73 | +export function extractSignatureFromCommit( | |
74 | + rawCommitObject: string, | |
75 | +): { signature: string; signedData: string } | null { | |
76 | + const separatorIndex = rawCommitObject.indexOf("\n\n"); | |
77 | + const headerSection = | |
78 | + separatorIndex === -1 ? rawCommitObject : rawCommitObject.slice(0, separatorIndex); | |
79 | + const tail = separatorIndex === -1 ? "" : rawCommitObject.slice(separatorIndex); | |
80 | + | |
81 | + const headerLines = headerSection.split("\n"); | |
82 | + const keptLines: string[] = []; | |
83 | + let signatureLines: string[] | null = null; | |
84 | + let collecting = false; | |
85 | + | |
86 | + for (const line of headerLines) { | |
87 | + if (/^gpgsig(-sha256)?( |$)/.test(line)) { | |
88 | + if (!signatureLines) { | |
89 | + signatureLines = []; | |
90 | + collecting = true; | |
91 | + } else { | |
92 | + collecting = false; | |
93 | + keptLines.push(line); | |
94 | + } | |
95 | + continue; | |
96 | + } | |
97 | + if (collecting && line.startsWith(" ")) { | |
98 | + signatureLines!.push(line.slice(1)); | |
99 | + continue; | |
100 | + } | |
101 | + collecting = false; | |
102 | + keptLines.push(line); | |
103 | + } | |
104 | + | |
105 | + if (!signatureLines || signatureLines.length === 0) { | |
106 | + return null; | |
107 | + } | |
108 | + | |
109 | + const signature = signatureLines.join("\n"); | |
110 | + const signedData = `${keptLines.join("\n")}${tail}`; | |
111 | + return { signature, signedData }; | |
112 | +} | |
113 | + | |
114 | +function parseCommitterEmail(rawCommitObject: string): string | null { | |
115 | + const match = rawCommitObject.match(/^committer [^\n]*<([^>]*)>/m); | |
116 | + return match ? match[1] : null; | |
117 | +} | |
118 | + | |
119 | +// Verification | |
120 | + | |
121 | +interface VerificationContext { | |
122 | + db: NodePgDatabase<typeof schema>; | |
123 | + repoPath: string; | |
124 | +} | |
125 | + | |
126 | +async function loadContext( | |
127 | + owner: string, | |
128 | + repo: string, | |
129 | +): Promise<VerificationContext | null> { | |
130 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
131 | + const ownerUser = await db.query.users.findFirst({ | |
132 | + where: eq(schema.users.username, owner), | |
133 | + }); | |
134 | + if (!ownerUser) return null; | |
135 | + | |
136 | + const repoRow = await db.query.repositories.findFirst({ | |
137 | + where: and( | |
138 | + eq(schema.repositories.ownerId, ownerUser.id), | |
139 | + eq(schema.repositories.name, repo), | |
140 | + ), | |
141 | + }); | |
142 | + if (!repoRow) return null; | |
143 | + | |
144 | + const { resolveRepoPath } = await import("./git-storage"); | |
145 | + const repoPath = await resolveRepoPath(repoRow.diskPath); | |
146 | + return { db, repoPath }; | |
147 | +} | |
148 | + | |
149 | +async function readRawCommit(repoPath: string, sha: string): Promise<string | null> { | |
150 | + if (!/^[0-9a-f]{7,64}$/i.test(sha)) return null; | |
151 | + const { simpleGit } = await import("simple-git"); | |
152 | + try { | |
153 | + return await simpleGit(repoPath).raw(["cat-file", "commit", sha]); | |
154 | + } catch { | |
155 | + return null; | |
156 | + } | |
157 | +} | |
158 | + | |
159 | +async function verifyWithKeys( | |
160 | + ctx: VerificationContext, | |
161 | + sha: string, | |
162 | + extracted: { signature: string; signedData: string }, | |
163 | +): Promise<CommitVerification> { | |
164 | + const sig = await openpgp.readSignature({ armoredSignature: extracted.signature }); | |
165 | + const signingKeyIds = sig | |
166 | + .getSigningKeyIDs() | |
167 | + .map((id) => id.toHex().toLowerCase()) | |
168 | + .filter(Boolean); | |
169 | + | |
170 | + for (const keyId of signingKeyIds) { | |
171 | + const cached = cacheGet(`${sha}:${keyId}`); | |
172 | + if (cached) return cached; | |
173 | + } | |
174 | + | |
175 | + let keyRows = signingKeyIds.length | |
176 | + ? await ctx.db.select().from(schema.gpgKeys).where(inArray(schema.gpgKeys.keyId, signingKeyIds)) | |
177 | + : []; | |
178 | + if (!keyRows.length) { | |
179 | + keyRows = await ctx.db.select().from(schema.gpgKeys).limit(500); | |
180 | + } | |
181 | + | |
182 | + const message = await openpgp.createMessage({ text: extracted.signedData }); | |
183 | + | |
184 | + for (const row of keyRows) { | |
185 | + try { | |
186 | + const keyObj = await openpgp.readKey({ armoredKey: row.publicKey }); | |
187 | + const keyHex = keyObj.getKeyID().toHex().toLowerCase(); | |
188 | + const fingerprint = keyObj.getFingerprint().toLowerCase(); | |
189 | + if ( | |
190 | + signingKeyIds.length && | |
191 | + !signingKeyIds.some( | |
192 | + (id) => keyHex === id || fingerprint.endsWith(id) || keyHex.endsWith(id), | |
193 | + ) | |
194 | + ) { | |
195 | + continue; | |
196 | + } | |
197 | + | |
198 | + const result = await openpgp.verify({ | |
199 | + message, | |
200 | + verificationKeys: keyObj, | |
201 | + signature: sig, | |
202 | + }); | |
203 | + const firstSignature = result.signatures[0]; | |
204 | + if (!firstSignature) continue; | |
205 | + | |
206 | + const isValid = await firstSignature.verified.then( | |
207 | + () => true, | |
208 | + () => false, | |
209 | + ); | |
210 | + if (!isValid) continue; | |
211 | + | |
212 | + const committerEmail = parseCommitterEmail(extracted.signedData); | |
213 | + const signerUser = committerEmail | |
214 | + ? await ctx.db.query.users.findFirst({ | |
215 | + where: eq(schema.users.email, committerEmail), | |
216 | + }) | |
217 | + : null; | |
218 | + | |
219 | + const verification: CommitVerification = { | |
220 | + signed: true, | |
221 | + verified: !!signerUser && row.userId === signerUser.id, | |
222 | + validKeyInDb: true, | |
223 | + signerUserId: signerUser?.id ?? null, | |
224 | + }; | |
225 | + cacheSet(`${sha}:${fingerprint}`, verification); | |
226 | + cacheSet(`${sha}:${keyHex}`, verification); | |
227 | + return verification; | |
228 | + } catch { | |
229 | + continue; | |
230 | + } | |
231 | + } | |
232 | + | |
233 | + const fallback: CommitVerification = { | |
234 | + signed: true, | |
235 | + verified: false, | |
236 | + validKeyInDb: false, | |
237 | + signerUserId: null, | |
238 | + }; | |
239 | + for (const keyId of signingKeyIds) { | |
240 | + cacheSet(`${sha}:${keyId}`, fallback); | |
241 | + } | |
242 | + return fallback; | |
243 | +} | |
244 | + | |
245 | +async function verifySha( | |
246 | + ctx: VerificationContext, | |
247 | + sha: string, | |
248 | +): Promise<CommitVerification> { | |
249 | + const raw = await readRawCommit(ctx.repoPath, sha); | |
250 | + if (!raw) return { ...UNSIGNED_RESULT }; | |
251 | + | |
252 | + const extracted = extractSignatureFromCommit(raw); | |
253 | + if (!extracted) return { ...UNSIGNED_RESULT }; | |
254 | + | |
255 | + try { | |
256 | + return await verifyWithKeys(ctx, sha, extracted); | |
257 | + } catch (e) { | |
258 | + logger.warn({ err: e, sha }, "Commit signature verification failed"); | |
259 | + return { ...UNSIGNED_RESULT }; | |
260 | + } | |
261 | +} | |
262 | + | |
263 | +/** | |
264 | + * Verify a single commit's GPG signature against user-uploaded public keys. | |
265 | + * Never throws — any failure yields an "unsigned"-shaped result. | |
266 | + */ | |
267 | +export async function verifyCommitSignature(opts: { | |
268 | + owner: string; | |
269 | + repo: string; | |
270 | + sha: string; | |
271 | +}): Promise<CommitVerification> { | |
272 | + try { | |
273 | + const ctx = await loadContext(opts.owner, opts.repo); | |
274 | + if (!ctx) return { ...UNSIGNED_RESULT }; | |
275 | + return await verifySha(ctx, opts.sha); | |
276 | + } catch (e) { | |
277 | + logger.warn( | |
278 | + { err: e, owner: opts.owner, repo: opts.repo, sha: opts.sha }, | |
279 | + "Commit signature verification failed", | |
280 | + ); | |
281 | + return { ...UNSIGNED_RESULT }; | |
282 | + } | |
283 | +} | |
284 | + | |
285 | +/** | |
286 | + * Verify a page of commits sequentially (openpgp is CPU-bound). | |
287 | + * Returns a Map keyed by sha. Empty map when disabled via | |
288 | + * COMMIT_SIGNATURE_VERIFICATION=false. | |
289 | + */ | |
290 | +export async function verifyCommitsSignatures( | |
291 | + owner: string, | |
292 | + repo: string, | |
293 | + shas: string[], | |
294 | + maxBatch = 20, | |
295 | +): Promise<Map<string, CommitVerification>> { | |
296 | + const results = new Map<string, CommitVerification>(); | |
297 | + if (process.env.COMMIT_SIGNATURE_VERIFICATION === "false") return results; | |
298 | + | |
299 | + const batch = shas.filter(Boolean).slice(0, maxBatch); | |
300 | + if (batch.length === 0) return results; | |
301 | + | |
302 | + let ctx: VerificationContext | null = null; | |
303 | + try { | |
304 | + ctx = await loadContext(owner, repo); | |
305 | + } catch (e) { | |
306 | + logger.warn({ err: e, owner, repo }, "Commit signature verification context failed"); | |
307 | + } | |
308 | + if (!ctx) { | |
309 | + for (const sha of batch) results.set(sha, { ...UNSIGNED_RESULT }); | |
310 | + return results; | |
311 | + } | |
312 | + | |
313 | + for (const sha of batch) { | |
314 | + results.set(sha, await verifySha(ctx, sha)); | |
315 | + } | |
316 | + return results; | |
317 | +} | |
0 | 318 | |