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
▼.env.example
+3-3
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
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
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
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
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
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
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
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
▼src/lib/docker-registry-upload.ts
+175-0
1
+/**
2
+ * Docker/OCI blob upload session management.
3
+ *
4
+ * Tracks in-flight chunked uploads (POST /v2/.../blobs/uploads/ → PATCH → PUT).
5
+ * Chunks land in a temp file under .tmp/docker-uploads/<uuid>; the session map
6
+ * lives in-process (single-node self-hosted deployments are the primary target,
7
+ * mirroring how src/lib/action-resolver.ts handles its local cache).
8
+ *
9
+ * Security properties enforced here:
10
+ * - digest verification (sha256) before any blob is admitted to storage
11
+ * - TTL expiry (1h) and bounded session count so abandoned uploads cannot leak disk
12
+ */
13
+import { createHash, randomUUID } from "node:crypto";
14
+import { createReadStream } from "node:fs";
15
+import { appendFile, mkdir, rm, stat } from "node:fs/promises";
16
+import path from "node:path";
17
+import { logger } from "@/lib/logger";
18
+
19
+const UPLOAD_TTL_MS = 60 * 60 * 1000;
20
+const MAX_SESSIONS = 500;
21
+const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
22
+
23
+export function maxBlobBytes(): number {
24
+ const mb = parseInt(process.env.DOCKER_MAX_BLOB_MB || "8192", 10);
25
+ return (Number.isFinite(mb) && mb > 0 ? mb : 8192) * 1024 * 1024;
26
+}
27
+
28
+export interface BlobUploadSession {
29
+ id: string;
30
+ imageName: string;
31
+ size: number;
32
+ createdAt: number;
33
+}
34
+
35
+const sessions = new Map<string, BlobUploadSession>();
36
+let cleanupTimer: NodeJS.Timeout | null = null;
37
+
38
+function uploadDir(): string {
39
+ return path.join(process.cwd(), ".tmp", "docker-uploads");
40
+}
41
+
42
+function uploadPath(id: string): string {
43
+ return path.join(uploadDir(), id);
44
+}
45
+
46
+function ensureCleanup(): void {
47
+ if (cleanupTimer) return;
48
+ cleanupTimer = setInterval(() => {
49
+ const now = Date.now();
50
+ for (const [id, session] of sessions) {
51
+ if (now - session.createdAt > UPLOAD_TTL_MS) {
52
+ sessions.delete(id);
53
+ void rm(uploadPath(id), { force: true }).catch(() => {});
54
+ }
55
+ }
56
+ }, CLEANUP_INTERVAL_MS);
57
+ cleanupTimer.unref();
58
+}
59
+
60
+function evictOldestIfNeeded(): void {
61
+ while (sessions.size >= MAX_SESSIONS) {
62
+ let oldestId: string | null = null;
63
+ let oldestAt = Infinity;
64
+ for (const [id, session] of sessions) {
65
+ if (session.createdAt < oldestAt) {
66
+ oldestAt = session.createdAt;
67
+ oldestId = id;
68
+ }
69
+ }
70
+ if (!oldestId) break;
71
+ sessions.delete(oldestId);
72
+ void rm(uploadPath(oldestId), { force: true }).catch(() => {});
73
+ }
74
+}
75
+
76
+export async function createUploadSession(
77
+ imageName: string,
78
+): Promise<BlobUploadSession> {
79
+ ensureCleanup();
80
+ evictOldestIfNeeded();
81
+ await mkdir(uploadDir(), { recursive: true });
82
+ const id = randomUUID();
83
+ const session: BlobUploadSession = {
84
+ id,
85
+ imageName,
86
+ size: 0,
87
+ createdAt: Date.now(),
88
+ };
89
+ sessions.set(id, session);
90
+ await appendFile(uploadPath(id), Buffer.alloc(0));
91
+ return session;
92
+}
93
+
94
+export function getUploadSession(id: string): BlobUploadSession | null {
95
+ return sessions.get(id) ?? null;
96
+}
97
+
98
+export async function appendToUpload(
99
+ id: string,
100
+ chunk: Buffer,
101
+): Promise<
102
+ { ok: true; session: BlobUploadSession } | { ok: false; reason: string }
103
+> {
104
+ const session = sessions.get(id);
105
+ if (!session) return { ok: false, reason: "BLOB_UPLOAD_UNKNOWN" };
106
+ const newSize = session.size + chunk.length;
107
+ if (newSize > maxBlobBytes()) {
108
+ await cancelUpload(id);
109
+ return { ok: false, reason: "BLOB_UPLOAD_INVALID" };
110
+ }
111
+ await appendFile(uploadPath(id), chunk);
112
+ session.size = newSize;
113
+ return { ok: true, session };
114
+}
115
+
116
+/**
117
+ * Verify the assembled temp file against the expected digest and move it into
118
+ * the storage adapter at the canonical content-addressed key.
119
+ */
120
+export async function finalizeUpload(
121
+ id: string,
122
+ expectedDigest: string,
123
+ storageKey: string,
124
+): Promise<
125
+ | { ok: true; digest: string; size: number }
126
+ | { ok: false; reason: string; status: number }
127
+> {
128
+ const session = sessions.get(id);
129
+ if (!session) {
130
+ return { ok: false, reason: "BLOB_UPLOAD_UNKNOWN", status: 404 };
131
+ }
132
+
133
+ const filePath = uploadPath(id);
134
+ let fileInfo;
135
+ try {
136
+ fileInfo = await stat(filePath);
137
+ } catch {
138
+ sessions.delete(id);
139
+ return { ok: false, reason: "BLOB_UPLOAD_UNKNOWN", status: 404 };
140
+ }
141
+
142
+ const hash = createHash("sha256");
143
+ await new Promise<void>((resolve, reject) => {
144
+ const stream = createReadStream(filePath);
145
+ stream.on("data", (chunk) => hash.update(chunk));
146
+ stream.on("end", () => resolve());
147
+ stream.on("error", reject);
148
+ });
149
+ const computed = `sha256:${hash.digest("hex")}`;
150
+
151
+ if (computed !== expectedDigest) {
152
+ logger.warn(
153
+ { expected: expectedDigest, computed, image: session.imageName },
154
+ "Docker blob digest mismatch",
155
+ );
156
+ await cancelUpload(id);
157
+ return { ok: false, reason: "DIGEST_INVALID", status: 400 };
158
+ }
159
+
160
+ const { getStorage } = await import("@/lib/storage");
161
+ const storage = await getStorage();
162
+ await storage.put(storageKey, createReadStream(filePath), {
163
+ contentType: "application/octet-stream",
164
+ });
165
+
166
+ sessions.delete(id);
167
+ await rm(filePath, { force: true }).catch(() => {});
168
+
169
+ return { ok: true, digest: computed, size: fileInfo.size };
170
+}
171
+
172
+export async function cancelUpload(id: string): Promise<void> {
173
+ sessions.delete(id);
174
+ await rm(uploadPath(id), { force: true }).catch(() => {});
175
+}
0
176
▼src/lib/login-lockout.ts
+189-0
1
+import { logger } from "@/lib/logger";
2
+import { isDistributed } from "@/lib/rate-limit";
3
+import { redis } from "@/lib/redis";
4
+
5
+const USER_KEY_PREFIX = "u:";
6
+const IP_KEY_PREFIX = "i:";
7
+const REDIS_KEY_PREFIX = "login-lockout:";
8
+
9
+const USER_IP_MAX_FAILURES = 5;
10
+const IP_MAX_FAILURES = 20;
11
+const FAILURE_WINDOW_MS = 15 * 60_000;
12
+const BASE_LOCKOUT_MS = 15 * 60_000;
13
+const MAX_LOCKOUT_MS = 24 * 60 * 60_000;
14
+const CLEANUP_INTERVAL_MS = 5 * 60_000;
15
+const MAX_MEMORY_ENTRIES = 10_000;
16
+
17
+export interface LockoutStatus {
18
+ locked: boolean;
19
+ retryAfterSecs?: number;
20
+}
21
+
22
+interface LockoutEntry {
23
+ failures: number;
24
+ windowStart: number;
25
+ lockedUntil: number;
26
+ lockoutCount: number;
27
+}
28
+
29
+interface LockoutStore {
30
+ get(identifier: string): Promise<LockoutEntry | null>;
31
+ set(identifier: string, entry: LockoutEntry, ttlMs: number): Promise<void>;
32
+ delete(identifier: string): Promise<void>;
33
+}
34
+
35
+class InMemoryLockoutStore implements LockoutStore {
36
+ private store: Map<string, LockoutEntry> = new Map();
37
+
38
+ constructor() {
39
+ setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS).unref();
40
+ }
41
+
42
+ private cleanup(): void {
43
+ const now = Date.now();
44
+ for (const [identifier, entry] of this.store.entries()) {
45
+ if (
46
+ entry.lockedUntil < now &&
47
+ now - entry.windowStart > FAILURE_WINDOW_MS
48
+ ) {
49
+ this.store.delete(identifier);
50
+ }
51
+ }
52
+ }
53
+
54
+ async get(identifier: string): Promise<LockoutEntry | null> {
55
+ return this.store.get(identifier) ?? null;
56
+ }
57
+
58
+ async set(identifier: string, entry: LockoutEntry): Promise<void> {
59
+ this.store.delete(identifier);
60
+ this.store.set(identifier, entry);
61
+ while (this.store.size > MAX_MEMORY_ENTRIES) {
62
+ const oldest = this.store.keys().next().value;
63
+ if (oldest === undefined) break;
64
+ this.store.delete(oldest);
65
+ }
66
+ }
67
+
68
+ async delete(identifier: string): Promise<void> {
69
+ this.store.delete(identifier);
70
+ }
71
+}
72
+
73
+class RedisLockoutStore implements LockoutStore {
74
+ constructor(private readonly fallback: InMemoryLockoutStore) {}
75
+
76
+ async get(identifier: string): Promise<LockoutEntry | null> {
77
+ try {
78
+ const raw = await redis.get(`${REDIS_KEY_PREFIX}${identifier}`);
79
+ if (!raw) return null;
80
+ return JSON.parse(raw) as LockoutEntry;
81
+ } catch (error) {
82
+ logger.error({ error, identifier }, "Redis login lockout read failed, falling back to in-memory store");
83
+ return this.fallback.get(identifier);
84
+ }
85
+ }
86
+
87
+ async set(identifier: string, entry: LockoutEntry, ttlMs: number): Promise<void> {
88
+ try {
89
+ await redis.set(
90
+ `${REDIS_KEY_PREFIX}${identifier}`,
91
+ JSON.stringify(entry),
92
+ "PX",
93
+ Math.ceil(ttlMs),
94
+ );
95
+ return;
96
+ } catch (error) {
97
+ logger.error({ error, identifier }, "Redis login lockout write failed, falling back to in-memory store");
98
+ }
99
+ await this.fallback.set(identifier, entry);
100
+ }
101
+
102
+ async delete(identifier: string): Promise<void> {
103
+ try {
104
+ await redis.del(`${REDIS_KEY_PREFIX}${identifier}`);
105
+ } catch (error) {
106
+ logger.error({ error, identifier }, "Redis login lockout delete failed");
107
+ }
108
+ await this.fallback.delete(identifier);
109
+ }
110
+}
111
+
112
+export function normalizeLogin(value: string): string {
113
+ return value.trim().toLowerCase();
114
+}
115
+
116
+export function userIpKey(login: string, ip: string | null | undefined): string {
117
+ return `${USER_KEY_PREFIX}${normalizeLogin(login)}|${ip || "unknown"}`;
118
+}
119
+
120
+export function ipKey(ip: string | null | undefined): string {
121
+ return `${IP_KEY_PREFIX}${ip || "unknown"}`;
122
+}
123
+
124
+function maxFailuresFor(identifier: string): number {
125
+ return identifier.startsWith(IP_KEY_PREFIX)
126
+ ? IP_MAX_FAILURES
127
+ : USER_IP_MAX_FAILURES;
128
+}
129
+
130
+function ttlFor(entry: LockoutEntry, now: number): number {
131
+ const windowRemaining = entry.windowStart + FAILURE_WINDOW_MS - now;
132
+ const lockRemaining = entry.lockedUntil - now;
133
+ return Math.max(Math.max(windowRemaining, lockRemaining), FAILURE_WINDOW_MS);
134
+}
135
+
136
+const memoryStore = new InMemoryLockoutStore();
137
+const store: LockoutStore = isDistributed
138
+ ? new RedisLockoutStore(memoryStore)
139
+ : memoryStore;
140
+
141
+export async function recordLoginFailure(identifier: string): Promise<void> {
142
+ const now = Date.now();
143
+ const entry = (await store.get(identifier)) ?? {
144
+ failures: 0,
145
+ windowStart: now,
146
+ lockedUntil: 0,
147
+ lockoutCount: 0,
148
+ };
149
+
150
+ if (entry.lockedUntil > now) {
151
+ return;
152
+ }
153
+
154
+ if (now - entry.windowStart > FAILURE_WINDOW_MS) {
155
+ entry.windowStart = now;
156
+ entry.failures = 0;
157
+ }
158
+
159
+ entry.failures += 1;
160
+
161
+ if (entry.failures >= maxFailuresFor(identifier)) {
162
+ entry.lockoutCount += 1;
163
+ const lockMs = Math.min(
164
+ BASE_LOCKOUT_MS * 2 ** (entry.lockoutCount - 1),
165
+ MAX_LOCKOUT_MS,
166
+ );
167
+ entry.lockedUntil = now + lockMs;
168
+ entry.failures = 0;
169
+ logger.warn({ identifier }, "Login lockout triggered");
170
+ }
171
+
172
+ await store.set(identifier, entry, ttlFor(entry, now));
173
+}
174
+
175
+export async function clearLoginFailures(identifier: string): Promise<void> {
176
+ await store.delete(identifier);
177
+}
178
+
179
+export async function isLockedOut(identifier: string): Promise<LockoutStatus> {
180
+ const now = Date.now();
181
+ const entry = await store.get(identifier);
182
+ if (!entry || entry.lockedUntil <= now) {
183
+ return { locked: false };
184
+ }
185
+ return {
186
+ locked: true,
187
+ retryAfterSecs: Math.ceil((entry.lockedUntil - now) / 1000),
188
+ };
189
+}
0
190
▼src/lib/push-mirror.ts
+455-0
1
+/**
2
+ * Push Mirror Library
3
+ *
4
+ * Mirrors repository refs OUT to an external remote — complements the
5
+ * pull-only mirror sync in ./mirror-sync.ts.
6
+ *
7
+ * Storage: config lives on the `repositories` table, mirroring the pull-side
8
+ * pattern exactly (mirrorUrl / mirrorToken / lastMirrorSyncAt / mirrorSyncStatus):
9
+ * - pushMirrorEnabled master switch
10
+ * - pushMirrorUrl destination remote (never contains credentials)
11
+ * - pushMirrorToken encrypted auth token (workflow-secret-crypto)
12
+ * - pushMirrorStatus pending | pushing | success | failed
13
+ * - lastPushMirrorAt timestamp of last completed push attempt
14
+ *
15
+ * Limitation: ONE push remote per repository.
16
+ *
17
+ * Security:
18
+ * - Tokens are encrypted at rest and decrypted only transiently into the
19
+ * remote URL per attempt (same mechanism as pull mirrors); they are never
20
+ * persisted in git config or returned by any API.
21
+ * - Destination URLs are SSRF-validated with validateGitCloneUrl. Private
22
+ * targets are rejected unless PUSH_MIRROR_ALLOW_PRIVATE=true (same opt-in
23
+ * pattern as FEDERATION_ALLOW_LOCALHOST).
24
+ */
25
+
26
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
27
+import { and, asc, eq, isNull, lt } from "drizzle-orm";
28
+import { getDatabase, schema } from "@/db";
29
+import { repositories } from "@/db/schema/repositories";
30
+import { logger } from "./logger";
31
+import { resolveRepoPath } from "./git-storage";
32
+import { validateGitCloneUrl } from "./url-validator";
33
+import { encryptWorkflowSecret } from "./workflow-secret-crypto";
34
+// Shared transient-token URL builder — identical semantics to pull-side fetch auth.
35
+import { buildFetchUrl as buildAuthenticatedRemoteUrl } from "./mirror-sync";
36
+
37
+export interface ConfigurePushMirrorInput {
38
+ url: string;
39
+ /** Plaintext token; encrypted before storage. null clears a stored token. Omit to keep existing. */
40
+ authToken?: string | null;
41
+}
42
+
43
+export interface PushMirrorConfig {
44
+ enabled: boolean;
45
+ url: string | null;
46
+ hasToken: boolean;
47
+ status: string | null;
48
+ lastPushMirrorAt: Date | null;
49
+}
50
+
51
+export interface PushMirrorResult {
52
+ success: boolean;
53
+ refsUpdated: number;
54
+ error?: string;
55
+ durationMs?: number;
56
+}
57
+
58
+export interface ProcessDuePushMirrorsOptions {
59
+ limit?: number;
60
+ minIntervalSeconds?: number;
61
+}
62
+
63
+export interface ProcessDuePushMirrorsResult {
64
+ total: number;
65
+ eligible: number;
66
+ pushed: number;
67
+ failed: number;
68
+ failedRepoIds: string[];
69
+ durationMs: number;
70
+}
71
+
72
+function getDb(): NodePgDatabase<typeof schema> {
73
+ return getDatabase() as NodePgDatabase<typeof schema>;
74
+}
75
+
76
+/**
77
+ * SSRF-validate a push destination URL. Rejects file://, non-git schemes,
78
+ * localhost/private networks unless explicitly allowed via
79
+ * PUSH_MIRROR_ALLOW_PRIVATE=true.
80
+ */
81
+export async function validatePushMirrorUrl(
82
+ url: string
83
+): Promise<{ valid: true } | { valid: false; reason: string }> {
84
+ const allowPrivate = process.env.PUSH_MIRROR_ALLOW_PRIVATE === "true";
85
+ return validateGitCloneUrl(url, allowPrivate);
86
+}
87
+
88
+/** Timeout in seconds for a single push attempt. */
89
+function getPushTimeoutSecs(): number {
90
+ const raw = parseInt(
91
+ process.env.PUSH_MIRROR_TIMEOUT_SECS ||
92
+ process.env.GIT_PROCESS_TIMEOUT_SECS ||
93
+ "300",
94
+ 10
95
+ );
96
+ return Number.isFinite(raw) && raw > 0 ? raw : 300;
97
+}
98
+
99
+/** Min interval between automatic pushes of the same repo. */
100
+function getMinIntervalSecs(override?: number): number {
101
+ if (override !== undefined && Number.isFinite(override) && override >= 0) {
102
+ return override;
103
+ }
104
+ const raw = parseInt(process.env.PUSH_MIRROR_MIN_INTERVAL_SECS || "300", 10);
105
+ return Number.isFinite(raw) && raw >= 0 ? raw : 300;
106
+}
107
+
108
+/**
109
+ * Git error output can echo the authenticated remote URL (with embedded
110
+ * token). Redact any userinfo password before storing/logging.
111
+ */
112
+export function redactCredentials(message: string): string {
113
+ return message.replace(
114
+ /(https?:\/\/[^:@/\s]+):([^@\s/]+)@/gi,
115
+ "$1:***@"
116
+ );
117
+}
118
+
119
+async function markStatus(repoId: string, values: Record<string, unknown>): Promise<void> {
120
+ const db = getDb();
121
+ await db
122
+ .update(repositories)
123
+ .set({ ...values, updatedAt: new Date() })
124
+ .where(eq(repositories.id, repoId));
125
+}
126
+
127
+/**
128
+ * Enable/update the push mirror configuration for a repository.
129
+ */
130
+export async function configurePushMirror(
131
+ repoId: string,
132
+ input: ConfigurePushMirrorInput
133
+): Promise<{ success: boolean; config?: PushMirrorConfig; error?: string }> {
134
+ const validation = await validatePushMirrorUrl(input.url);
135
+ if (!validation.valid) {
136
+ return { success: false, error: validation.reason };
137
+ }
138
+
139
+ const db = getDb();
140
+ const rows = await db
141
+ .select({ id: repositories.id })
142
+ .from(repositories)
143
+ .where(eq(repositories.id, repoId))
144
+ .limit(1);
145
+ if (rows.length === 0) {
146
+ return { success: false, error: "Repository not found" };
147
+ }
148
+
149
+ try {
150
+ await markStatus(repoId, {
151
+ pushMirrorEnabled: true,
152
+ pushMirrorUrl: input.url,
153
+ // Undefined = keep existing stored token; explicit value encrypts/replaces; null clears.
154
+ ...(input.authToken === undefined
155
+ ? {}
156
+ : {
157
+ pushMirrorToken:
158
+ input.authToken === null || input.authToken === ""
159
+ ? null
160
+ : encryptWorkflowSecret(input.authToken),
161
+ }),
162
+ pushMirrorStatus: "pending",
163
+ });
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : "Unknown error";
166
+ logger.error({ repoId, error: message }, "Failed to configure push mirror");
167
+ return { success: false, error: message };
168
+ }
169
+
170
+ const config = await getPushMirror(repoId);
171
+ return { success: true, config: config ?? undefined };
172
+}
173
+
174
+/**
175
+ * Remove the push mirror configuration for a repository.
176
+ */
177
+export async function removePushMirror(
178
+ repoId: string
179
+): Promise<{ success: boolean; error?: string }> {
180
+ const db = getDb();
181
+ try {
182
+ const rows = await db
183
+ .select({ id: repositories.id })
184
+ .from(repositories)
185
+ .where(eq(repositories.id, repoId))
186
+ .limit(1);
187
+ if (rows.length === 0) {
188
+ return { success: false, error: "Repository not found" };
189
+ }
190
+
191
+ await markStatus(repoId, {
192
+ pushMirrorEnabled: false,
193
+ pushMirrorUrl: null,
194
+ pushMirrorToken: null,
195
+ pushMirrorStatus: null,
196
+ });
197
+ return { success: true };
198
+ } catch (error) {
199
+ const message = error instanceof Error ? error.message : "Unknown error";
200
+ logger.error({ repoId, error: message }, "Failed to remove push mirror");
201
+ return { success: false, error: message };
202
+ }
203
+}
204
+
205
+/**
206
+ * Read the push mirror configuration. Never exposes the token itself.
207
+ */
208
+export async function getPushMirror(repoId: string): Promise<PushMirrorConfig | null> {
209
+ const db = getDb();
210
+ const rows = await db
211
+ .select({
212
+ enabled: repositories.pushMirrorEnabled,
213
+ url: repositories.pushMirrorUrl,
214
+ token: repositories.pushMirrorToken,
215
+ status: repositories.pushMirrorStatus,
216
+ lastPushMirrorAt: repositories.lastPushMirrorAt,
217
+ })
218
+ .from(repositories)
219
+ .where(eq(repositories.id, repoId))
220
+ .limit(1);
221
+
222
+ const row = rows[0];
223
+ if (!row) return null;
224
+
225
+ return {
226
+ enabled: row.enabled,
227
+ url: row.url,
228
+ hasToken: row.token !== null && row.token !== undefined,
229
+ status: row.status,
230
+ lastPushMirrorAt: row.lastPushMirrorAt,
231
+ };
232
+}
233
+
234
+/**
235
+ * Push all branches and tags to the configured external remote right now.
236
+ *
237
+ * Uses an explicit forced refspec list (+refs/heads/* and +refs/tags/*) so we
238
+ * never push hidden refs (refs/pull/*, notes, etc.) unintentionally — unlike
239
+ * a blind `git push --mirror`. Credentials are injected into the URL per
240
+ * attempt and never persisted. Never throws.
241
+ */
242
+export async function pushMirrorNow(repositoryId: string): Promise<PushMirrorResult> {
243
+ const startedAt = Date.now();
244
+
245
+ let repo: typeof repositories.$inferSelect | undefined;
246
+ try {
247
+ const db = getDb();
248
+ const rows = await db
249
+ .select()
250
+ .from(repositories)
251
+ .where(eq(repositories.id, repositoryId))
252
+ .limit(1);
253
+ repo = rows[0];
254
+ } catch (error) {
255
+ const message = error instanceof Error ? error.message : "Unknown error";
256
+ logger.error({ repoId: repositoryId, error: message }, "Failed to load repository for push mirror");
257
+ return { success: false, refsUpdated: 0, error: message };
258
+ }
259
+
260
+ if (!repo) {
261
+ return { success: false, refsUpdated: 0, error: "Repository not found" };
262
+ }
263
+
264
+ if (!repo.pushMirrorEnabled || !repo.pushMirrorUrl) {
265
+ return { success: false, refsUpdated: 0, error: "Push mirror not configured" };
266
+ }
267
+
268
+ const timeoutSecs = getPushTimeoutSecs();
269
+
270
+ try {
271
+ const repoPath = await resolveRepoPath(repo.diskPath);
272
+
273
+ // Mark as pushing
274
+ try {
275
+ await markStatus(repositoryId, { pushMirrorStatus: "pushing" });
276
+ } catch (statusError) {
277
+ logger.warn(
278
+ { repoId: repositoryId, error: statusError instanceof Error ? statusError.message : "unknown" },
279
+ "Failed to mark push mirror status as pushing"
280
+ );
281
+ }
282
+
283
+ // Lazy-load simple-git (via the sanitized-env wrapper) so this module
284
+ // stays cheap to import in workers/cron contexts.
285
+ const { createSimpleGit } = await import("./git");
286
+ const git = createSimpleGit({
287
+ baseDir: repoPath,
288
+ // simple-git kills the underlying git process when the block
289
+ // timeout elapses — our process kill guarantee.
290
+ timeout: { block: timeoutSecs * 1000 },
291
+ });
292
+
293
+ // Transient credential injection — decrypted per attempt, never persisted.
294
+ const pushUrl = buildAuthenticatedRemoteUrl(repo.pushMirrorUrl, repo.pushMirrorToken);
295
+
296
+ const pushArgs = [
297
+ "push",
298
+ "--prune",
299
+ pushUrl,
300
+ "+refs/heads/*:refs/heads/*",
301
+ "+refs/tags/*:refs/tags/*",
302
+ ];
303
+
304
+ logger.info({ repoId: repositoryId }, "Starting push mirror");
305
+
306
+ let timer: ReturnType<typeof setTimeout> | undefined;
307
+ const output = await Promise.race([
308
+ git.raw(pushArgs),
309
+ new Promise<never>((_, reject) => {
310
+ timer = setTimeout(
311
+ () => reject(new Error(`Push mirror timed out after ${timeoutSecs}s`)),
312
+ timeoutSecs * 1000
313
+ );
314
+ }),
315
+ ]).finally(() => clearTimeout(timer));
316
+
317
+ const refsUpdated = String(output)
318
+ .split("\n")
319
+ .filter((line) => line.includes("->"))
320
+ .length;
321
+
322
+ await markStatus(repositoryId, {
323
+ pushMirrorStatus: "success",
324
+ lastPushMirrorAt: new Date(),
325
+ });
326
+
327
+ const durationMs = Date.now() - startedAt;
328
+ logger.info({ repoId: repositoryId, refsUpdated, durationMs }, "Push mirror completed");
329
+
330
+ return { success: true, refsUpdated, durationMs };
331
+ } catch (error) {
332
+ const errorMessage = redactCredentials(
333
+ error instanceof Error ? error.message : "Unknown error"
334
+ );
335
+
336
+ logger.error({ repoId: repositoryId, error: errorMessage }, "Push mirror failed");
337
+
338
+ try {
339
+ await markStatus(repositoryId, { pushMirrorStatus: "failed" });
340
+ } catch (statusError) {
341
+ logger.error(
342
+ { repoId: repositoryId, error: statusError instanceof Error ? statusError.message : "unknown" },
343
+ "Failed to record push mirror failure status"
344
+ );
345
+ }
346
+
347
+ return { success: false, refsUpdated: 0, error: errorMessage, durationMs: Date.now() - startedAt };
348
+ }
349
+}
350
+
351
+/**
352
+ * Find and process repos whose push mirror is due.
353
+ *
354
+ * Due means: push mirroring enabled AND either never pushed or last push older
355
+ * than the min interval (PUSH_MIRROR_MIN_INTERVAL_SECS, default 300). Oldest
356
+ * first. Repos are processed sequentially with per-repo isolation — one
357
+ * failure never aborts the batch and this function never throws.
358
+ */
359
+export async function processDuePushMirrors(
360
+ options: ProcessDuePushMirrorsOptions = {}
361
+): Promise<ProcessDuePushMirrorsResult> {
362
+ const startedAt = Date.now();
363
+ const limit = options.limit && options.limit > 0 ? Math.floor(options.limit) : 10;
364
+ const minIntervalSecs = getMinIntervalSecs(options.minIntervalSeconds);
365
+ const cutoff = new Date(Date.now() - minIntervalSecs * 1000);
366
+
367
+ const empty: ProcessDuePushMirrorsResult = {
368
+ total: 0,
369
+ eligible: 0,
370
+ pushed: 0,
371
+ failed: 0,
372
+ failedRepoIds: [],
373
+ durationMs: 0,
374
+ };
375
+
376
+ let dueRepoIds: string[] = [];
377
+ try {
378
+ const db = getDb();
379
+ const enabledAndNeverPushed = and(
380
+ eq(repositories.pushMirrorEnabled, true),
381
+ isNull(repositories.lastPushMirrorAt)
382
+ );
383
+
384
+ // Never-pushed repos first (oldest created), then stale ones oldest-push-first.
385
+ const neverPushed = await db
386
+ .select({ id: repositories.id })
387
+ .from(repositories)
388
+ .where(enabledAndNeverPushed)
389
+ .orderBy(asc(repositories.createdAt))
390
+ .limit(limit);
391
+
392
+ dueRepoIds = neverPushed.map((row) => row.id);
393
+
394
+ if (dueRepoIds.length < limit) {
395
+ const stale = await db
396
+ .select({ id: repositories.id })
397
+ .from(repositories)
398
+ .where(
399
+ and(
400
+ eq(repositories.pushMirrorEnabled, true),
401
+ lt(repositories.lastPushMirrorAt, cutoff)
402
+ )
403
+ )
404
+ .orderBy(asc(repositories.lastPushMirrorAt))
405
+ .limit(limit - dueRepoIds.length);
406
+
407
+ dueRepoIds = dueRepoIds.concat(stale.map((row) => row.id));
408
+ }
409
+ } catch (error) {
410
+ const message = error instanceof Error ? error.message : "Unknown error";
411
+ logger.error({ error: message }, "Failed to select due push mirrors");
412
+ return { ...empty, durationMs: Date.now() - startedAt };
413
+ }
414
+
415
+ let pushed = 0;
416
+ let failed = 0;
417
+ const failedRepoIds: string[] = [];
418
+
419
+ for (const repoId of dueRepoIds) {
420
+ try {
421
+ const result = await pushMirrorNow(repoId);
422
+ if (result.success) {
423
+ pushed++;
424
+ } else {
425
+ failed++;
426
+ failedRepoIds.push(repoId);
427
+ }
428
+ } catch (error) {
429
+ // Defensive: pushMirrorNow should not throw, but isolate anyway.
430
+ failed++;
431
+ failedRepoIds.push(repoId);
432
+ logger.error(
433
+ { repoId, error: error instanceof Error ? error.message : "Unknown error" },
434
+ "Unexpected error processing push mirror"
435
+ );
436
+ }
437
+ }
438
+
439
+ const durationMs = Date.now() - startedAt;
440
+ if (dueRepoIds.length > 0) {
441
+ logger.info(
442
+ { total: dueRepoIds.length, pushed, failed, durationMs },
443
+ "Push mirror batch completed"
444
+ );
445
+ }
446
+
447
+ return {
448
+ total: dueRepoIds.length,
449
+ eligible: dueRepoIds.length,
450
+ pushed,
451
+ failed,
452
+ failedRepoIds,
453
+ durationMs,
454
+ };
455
+}
0
456