Swadhin Biswas

[email protected]
2026-08-17 15:01:13 +0600 committed NaN years ago
Co-auth
commit Co-auth
29 files changed
+477 -136
▼docker-compose.nas.yml
+25-0
80
80
timeout: 10s
81
81
retries: 3
82
82
start_period: 20s
83
+ deploy:
84
+ resources:
85
+ limits:
86
+ memory: 2G
87
+ cpus: "2.0"
83
88
84
89
# ── SSH Git server ─────────────────────────────────────────
85
90
ssh-git:
90
95
PROCESS_TYPE: ssh
91
96
ports:
92
97
- "${SSH_PORT:-2222}:2222"
98
+ deploy:
99
+ resources:
100
+ limits:
101
+ memory: 512M
102
+ cpus: "1.0"
93
103
94
104
# ── Background worker ──────────────────────────────────────
95
105
worker:
107
117
timeout: 10s
108
118
retries: 3
109
119
start_period: 20s
120
+ deploy:
121
+ resources:
122
+ limits:
123
+ memory: 1G
124
+ cpus: "1.0"
110
125
111
126
# ── PostgreSQL ─────────────────────────────────────────────
112
127
postgres:
126
141
interval: 10s
127
142
timeout: 5s
128
143
retries: 5
144
+ deploy:
145
+ resources:
146
+ limits:
147
+ memory: 1G
148
+ cpus: "1.0"
129
149
130
150
# ── Redis ──────────────────────────────────────────────────
131
151
redis:
151
171
interval: 10s
152
172
timeout: 5s
153
173
retries: 5
174
+ deploy:
175
+ resources:
176
+ limits:
177
+ memory: 512M
178
+ cpus: "0.5"
154
179
155
180
# ── Cloudflare Tunnel (optional) ───────────────────────────
156
181
cloudflared:
157
182
▼docker-compose.production.yml
+1-1
57
57
- DATABASE_SSL=false
58
58
- REDIS_URL=redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379
59
59
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env}
60
- - SESSION_SECRET=${SESSION_SECRET:-${JWT_SECRET}}
60
+ - SESSION_SECRET=${SESSION_SECRET:?Set SESSION_SECRET in .env (must differ from JWT_SECRET)}
61
61
- INTERNAL_HOOK_SECRET=${INTERNAL_HOOK_SECRET:?Set INTERNAL_HOOK_SECRET in .env}
62
62
- CRON_SECRET=${CRON_SECRET:?Set CRON_SECRET in .env}
63
63
- AI_CONFIG_ENCRYPTION_KEY=${AI_CONFIG_ENCRYPTION_KEY:?Set AI_CONFIG_ENCRYPTION_KEY in .env}
64
64
▼docker-compose.yml
+54-8
68
68
redis:
69
69
condition: service_healthy
70
70
networks:
71
- - och
71
+ - opencodehub-internal
72
+ - opencodehub-external
72
73
healthcheck:
73
74
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
74
75
interval: 30s
75
76
timeout: 10s
76
77
retries: 3
77
78
start_period: 20s
79
+ deploy:
80
+ resources:
81
+ limits:
82
+ memory: 2G
83
+ cpus: "2.0"
78
84
79
85
# ── SSH Git server ─────────────────────────────────────────
80
86
ssh-git:
108
114
redis:
109
115
condition: service_healthy
110
116
networks:
111
- - och
117
+ - opencodehub-internal
118
+ - opencodehub-external
119
+ healthcheck:
120
+ test: ["CMD-SHELL", "nc -z localhost 2222 || exit 1"]
121
+ interval: 30s
122
+ timeout: 10s
123
+ retries: 3
124
+ start_period: 15s
125
+ deploy:
126
+ resources:
127
+ limits:
128
+ memory: 512M
129
+ cpus: "1.0"
112
130
113
131
# ── Background worker (merge queue, webhooks, mirrors, digests) ──
114
132
worker:
143
161
redis:
144
162
condition: service_healthy
145
163
networks:
146
- - och
164
+ - opencodehub-internal
147
165
healthcheck:
148
166
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/healthz"]
149
167
interval: 30s
150
168
timeout: 10s
151
169
retries: 3
152
170
start_period: 20s
171
+ deploy:
172
+ resources:
173
+ limits:
174
+ memory: 1G
175
+ cpus: "1.0"
153
176
154
177
# ── PostgreSQL ─────────────────────────────────────────────
155
178
postgres:
163
186
volumes:
164
187
- postgres-data:/var/lib/postgresql/data
165
188
networks:
166
- - och
189
+ - opencodehub-internal
167
190
healthcheck:
168
191
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-opencodehub} -d ${POSTGRES_DB:-opencodehub}"]
169
192
interval: 10s
170
193
timeout: 5s
171
194
retries: 5
195
+ deploy:
196
+ resources:
197
+ limits:
198
+ memory: 1G
199
+ cpus: "1.0"
172
200
173
201
# ── Redis (sessions, caching, distributed locking, queues) ──
174
202
redis:
188
216
volumes:
189
217
- redis-data:/data
190
218
networks:
191
- - och
219
+ - opencodehub-internal
192
220
healthcheck:
193
221
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
194
222
interval: 10s
195
223
timeout: 5s
196
224
retries: 5
225
+ deploy:
226
+ resources:
227
+ limits:
228
+ memory: 512M
229
+ cpus: "0.5"
197
230
198
231
# ── CI/CD Runner (Docker-in-Docker, optional) ──────────────
199
232
runner:
216
249
depends_on:
217
250
- app
218
251
networks:
219
- - och
252
+ - opencodehub-external
253
+ healthcheck:
254
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/healthz"]
255
+ interval: 30s
256
+ timeout: 10s
257
+ retries: 3
258
+ start_period: 20s
259
+ deploy:
260
+ resources:
261
+ limits:
262
+ memory: 2G
263
+ cpus: "2.0"
220
264
profiles:
221
265
- with-runner
222
266
234
278
volumes:
235
279
- minio-data:/data
236
280
networks:
237
- - och
281
+ - opencodehub-external
238
282
healthcheck:
239
283
test: ["CMD", "mc", "ready", "local"]
240
284
interval: 30s
264
308
driver: local
265
309
266
310
networks:
267
- och:
311
+ opencodehub-internal:
312
+ driver: bridge
313
+ opencodehub-external:
268
314
driver: bridge
269
315
▼scripts/worker.ts
+2-0
250
250
process.on("SIGTERM", () => shutdown("SIGTERM"));
251
251
process.on("uncaughtException", (err) => {
252
252
logger.error({ err }, "Uncaught exception in worker");
253
+ process.exit(1);
253
254
});
254
255
process.on("unhandledRejection", (reason) => {
255
256
logger.error({ reason }, "Unhandled rejection in worker");
257
+ process.exit(1);
256
258
});
257
259
}
258
260
259
261
▼src/db/index.ts
+10-3
30
30
| LibSQLDatabase<typeof schema>
31
31
| NodePgDatabase<typeof schema>
32
32
| null = null;
33
+let pgPool: pg.Pool | null = null;
33
34
34
35
/**
35
36
* Infer database driver from connection URL when no explicit driver is set.
113
114
ssl: sslEnabled ? { rejectUnauthorized } : undefined,
114
115
max: parseInt(process.env.DATABASE_POOL_SIZE || "10", 10),
115
116
});
117
+ pool.on('error', (err) => {
118
+ logger.error({ err }, 'Unexpected database pool error');
119
+ });
120
+ pgPool = pool;
116
121
db = drizzlePg(pool, { schema });
117
122
logger.info(
118
123
{
179
184
// @ts-ignore
180
185
db.close();
181
186
}
182
- // For PG pool, we might need to close the pool if we had access to it,
183
- // but Drizzle doesn't expose it directly on the db instance easily without type casting.
184
- // In serverless/long-running app, closing might not be strictly necessary unless ensuring graceful shutdown.
187
+ // Close PostgreSQL pool if available
188
+ if (pgPool) {
189
+ await pgPool.end();
190
+ pgPool = null;
191
+ }
185
192
186
193
logger.info("Database connection closed");
187
194
db = null;
188
195
▼src/db/schema/ai-reviews.ts
+4-1
5
5
6
6
import { relations } from "drizzle-orm";
7
7
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
8
+import { index } from "drizzle-orm/pg-core";
8
9
import { pullRequests } from "./pull-requests";
9
10
import { users } from "./users";
10
11
48
49
49
50
// Error
50
51
errorMessage: text("error_message"),
51
-});
52
+}, (table) => [
53
+ index("ai_reviews_pull_request_id_idx").on(table.pullRequestId),
54
+]);
52
55
53
56
// AI Review Suggestions - individual findings
54
57
export const aiReviewSuggestions = pgTable("ai_review_suggestions", {
55
58
▼src/db/schema/automations.ts
+4-1
5
5
6
6
import { relations } from "drizzle-orm";
7
7
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
8
+import { index } from "drizzle-orm/pg-core";
8
9
import { repositories } from "./repositories";
9
10
import { users } from "./users";
10
11
31
32
lastRunAt: timestamp("last_run_at"),
32
33
createdAt: timestamp("created_at").notNull().defaultNow(),
33
34
updatedAt: timestamp("updated_at").notNull().defaultNow(),
34
-});
35
+}, (table) => [
36
+ index("automation_rules_repository_id_idx").on(table.repositoryId),
37
+]);
35
38
36
39
/**
37
40
* Trigger types:
38
41
▼src/db/schema/branch-protection.ts
+4-1
5
5
6
6
import { relations } from "drizzle-orm";
7
7
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
8
+import { index } from "drizzle-orm/pg-core";
8
9
import { repositories } from "./repositories";
9
10
import { users } from "./users";
10
11
29
30
createdAt: timestamp("created_at").notNull().defaultNow(),
30
31
updatedAt: timestamp("updated_at").notNull().defaultNow(),
31
32
createdById: text("created_by_id").references(() => users.id),
32
-});
33
+}, (table) => [
34
+ index("branch_protection_repository_id_idx").on(table.repositoryId),
35
+]);
33
36
34
37
export const branchProtectionRelations = relations(branchProtection, ({ one }) => ({
35
38
repository: one(repositories, {
36
39
▼src/db/schema/deploy-keys.ts
+4-1
5
5
6
6
import { relations } from "drizzle-orm";
7
7
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
8
+import { index } from "drizzle-orm/pg-core";
8
9
import { repositories } from "./repositories";
9
10
10
11
export const deployKeys = pgTable("deploy_keys", {
18
19
readOnly: boolean("read_only").default(true).notNull(),
19
20
createdAt: timestamp("created_at").notNull().defaultNow(),
20
21
lastUsedAt: timestamp("last_used_at"),
21
-});
22
+}, (table) => [
23
+ index("deploy_keys_repository_id_idx").on(table.repositoryId),
24
+]);
22
25
23
26
export const deployKeysRelations = relations(
24
27
deployKeys,
25
28
▼src/db/schema/merge-queue.ts
+8-2
1
1
import { relations } from "drizzle-orm";
2
-import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+import { boolean, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
3
3
import { pullRequests } from "./pull-requests";
4
4
import { repositories } from "./repositories";
5
5
import { users } from "./users";
38
38
startedAt: timestamp("started_at"),
39
39
completedAt: timestamp("completed_at"),
40
40
failureReason: text("failure_reason"),
41
-});
41
+ },
42
+ (t) => ({
43
+ repoIdx: index("merge_queue_repo_idx").on(t.repositoryId),
44
+ statusIdx: index("merge_queue_status_idx").on(t.status),
45
+ prIdx: index("merge_queue_pr_idx").on(t.pullRequestId),
46
+ }),
47
+);
42
48
43
49
// Alias for backwards compatibility
44
50
export const mergeQueueItems = mergeQueue;
45
51
▼src/db/schema/pull-requests.ts
+3-1
196
196
.references(() => users.id, { onDelete: "cascade" }),
197
197
isRequired: boolean("is_required").default(false),
198
198
requestedAt: timestamp("requested_at").notNull().defaultNow(),
199
-});
199
+}, (t) => ({
200
+ prIdx: index("pr_reviewers_pr_idx").on(t.pullRequestId),
201
+}));
200
202
201
203
export const pullRequestChecks = pgTable(
202
204
"pull_request_checks",
203
205
▼src/db/schema/stacked-prs.ts
+13-3
4
4
*/
5
5
6
6
import { relations } from "drizzle-orm";
7
-import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
7
+import { index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
8
8
import { pullRequests } from "./pull-requests";
9
9
import { repositories } from "./repositories";
10
10
import { users } from "./users";
23
23
.references(() => users.id),
24
24
createdAt: timestamp("created_at").notNull().defaultNow(),
25
25
updatedAt: timestamp("updated_at").notNull().defaultNow(),
26
-});
26
+ },
27
+ (t) => ({
28
+ repoIdx: index("pr_stacks_repo_idx").on(t.repositoryId),
29
+ }),
30
+);
27
31
28
32
// Stack entries - individual PRs in a stack with ordering
29
33
export const prStackEntries = pgTable("pr_stack_entries", {
37
41
stackOrder: integer("stack_order").notNull(), // Position in stack (1 = base)
38
42
parentPrId: text("parent_pr_id").references(() => pullRequests.id),
39
43
createdAt: timestamp("created_at").notNull().defaultNow(),
40
-});
44
+ },
45
+ (t) => ({
46
+ stackIdx: index("pr_stack_entries_stack_idx").on(t.stackId),
47
+ prIdx: index("pr_stack_entries_pr_idx").on(t.pullRequestId),
48
+ parentPrIdx: index("pr_stack_entries_parent_pr_idx").on(t.parentPrId),
49
+ }),
50
+);
41
51
42
52
// Relations
43
53
export const prStacksRelations = relations(prStacks, ({ one, many }) => ({
44
54
▼src/env.d.ts
+1-0
4
4
interface Locals {
5
5
user: import("./db/schema").User | null;
6
6
session: import("./db/schema").Session | null;
7
+ cspNonce?: string;
7
8
}
8
9
}
9
10
▼src/lib/graceful-shutdown.ts
+29-17
9
9
* - Worker queues stop accepting new jobs
10
10
*/
11
11
12
+import { closeDatabase } from "@/db";
12
13
import { logger } from "@/lib/logger";
13
14
14
15
let isShuttingDown = false;
15
16
const SHUTDOWN_TIMEOUT_MS = 30_000;
16
17
17
-function gracefulShutdown(signal: string) {
18
+async function gracefulShutdown(signal: string) {
18
19
if (isShuttingDown) return;
19
20
isShuttingDown = true;
20
21
21
22
logger.info({ signal }, "Received shutdown signal, draining...");
22
23
24
+ // Set flag so health checks return "shutting down"
25
+ process.env.OPCODEHUB_SHUTTING_DOWN = "1";
26
+
23
27
// Force exit after timeout
24
28
const forceTimer = setTimeout(() => {
25
29
logger.error("Forced shutdown after timeout");
27
31
}, SHUTDOWN_TIMEOUT_MS);
28
32
forceTimer.unref();
29
33
30
- // Let the process exit naturally once all handles are drained.
31
- // The Node.js event loop will empty when no more async work is pending.
32
- // We set a flag so any health check returns "shutting down".
33
- process.env.OPCODEHUB_SHUTTING_DOWN = "1";
34
+ try {
35
+ // Close database connections
36
+ await closeDatabase();
37
+ logger.info("Database connections closed");
38
+ } catch (err) {
39
+ logger.error({ err }, "Error closing database connections");
40
+ }
41
+
42
+ try {
43
+ // Close Redis if available — import dynamically to avoid circular deps
44
+ const { closeRedis } = await import("@/lib/redis");
45
+ await closeRedis();
46
+ } catch {
47
+ // Redis not available, skip
48
+ }
34
49
35
- logger.info("Shutdown signal processed, waiting for in-flight requests...");
50
+ logger.info("Shutdown complete, exiting...");
51
+
52
+ // Let the process exit naturally once all handles are drained
53
+ setTimeout(() => process.exit(0), 1000);
36
54
}
37
55
38
56
// Handle signals
39
57
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
40
58
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
41
59
42
-// Handle uncaught errors — log and continue (don't crash)
60
+// Handle uncaught errors — log and exit to let process manager restart
43
61
process.on("uncaughtException", (err) => {
44
- logger.error({ err }, "Uncaught exception");
45
- // In production, don't crash — log and keep serving
46
- if (process.env.NODE_ENV !== "production") {
47
- process.exit(1);
48
- }
62
+ logger.fatal({ err }, "Uncaught exception — exiting to prevent corrupted state");
63
+ process.exit(1);
49
64
});
50
65
51
66
process.on("unhandledRejection", (reason) => {
52
- logger.error({ reason }, "Unhandled rejection");
53
- // In production, don't crash
54
- if (process.env.NODE_ENV !== "production") {
55
- process.exit(1);
56
- }
67
+ logger.fatal({ reason }, "Unhandled rejection — exiting to let process manager restart");
68
+ process.exit(1);
57
69
});
58
70
59
71
// Memory pressure warning
60
72
▼src/lib/rate-limit.ts
+4-2
68
68
}
69
69
70
70
class RedisRateLimiter implements RateLimiter {
71
+ private readonly fallback = new InMemoryRateLimiter();
72
+
71
73
constructor(private readonly redis: Redis) {}
72
74
73
75
async check(
101
103
102
104
return { allowed: count <= limit, remaining, resetTime };
103
105
} catch (error) {
104
- logger.error({ error, identifier }, "Redis rate limit check failed, allowing request");
105
- return { allowed: true, remaining: limit, resetTime: now + windowMs };
106
+ logger.error({ error, identifier }, "Redis rate limit check failed, falling back to in-memory limiter");
107
+ return this.fallback.check(identifier, limit, windowMs);
106
108
}
107
109
}
108
110
109
111
▼src/lib/redis.ts
+12-0
151
151
}
152
152
}
153
153
154
+export async function closeRedis(): Promise<void> {
155
+ if (client) {
156
+ try {
157
+ await client.quit();
158
+ logger.info("Redis connection closed");
159
+ } catch {
160
+ // Already disconnected
161
+ }
162
+ client = null;
163
+ }
164
+}
165
+
154
166
// log Redis configuration on module load only when actually used
155
167
if (!shouldSkipRedis()) {
156
168
logger.info({ redisUrl: safeUrl(resolveRedisUrl()) }, "Redis configured");
157
169
▼src/lib/ssh.ts
+11-0
11
11
import ssh2 from "ssh2";
12
12
const { Server } = ssh2;
13
13
14
+const GIT_PROCESS_TIMEOUT_MS =
15
+ parseInt(process.env.GIT_PROCESS_TIMEOUT_SECS || "3600", 10) * 1000;
16
+
14
17
/**
15
18
* Per-IP SSH authentication rate limiter.
16
19
* Tracks failed attempts and blocks IPs that exceed the threshold.
243
246
},
244
247
});
245
248
249
+ // Kill git process if it runs too long
250
+ const gitTimer = setTimeout(() => {
251
+ logger.warn({ fullRepoPath, operation }, "SSH git process timed out, killing");
252
+ gitProcess.kill("SIGKILL");
253
+ }, GIT_PROCESS_TIMEOUT_MS);
254
+ gitProcess.on("close", () => clearTimeout(gitTimer));
255
+ gitProcess.on("error", () => clearTimeout(gitTimer));
256
+
246
257
// Track refs for push hook
247
258
let receivedRefs: string[] = [];
248
259
249
260
▼src/lib/validation.ts
+126-11
1
1
import { z } from "zod";
2
2
import { sanitizeHtml } from "./sanitize";
3
+import { lookup } from "dns";
4
+import { promisify } from "util";
5
+
6
+const dnsLookup = promisify(lookup);
7
+
8
+/**
9
+ * Check if an IPv4 address falls within a CIDR range
10
+ */
11
+function isIPv4InCidr(ip: string, cidr: string): boolean {
12
+ const [range, bits] = cidr.split("/");
13
+ const mask = ~((1 << (32 - parseInt(bits))) - 1);
14
+ const ipNum = ipToNumber(ip);
15
+ const rangeNum = ipToNumber(range);
16
+ if (ipNum === null || rangeNum === null) return false;
17
+ return (ipNum & mask) === (rangeNum & mask);
18
+}
19
+
20
+/**
21
+ * Check if an IPv6 address falls within a CIDR range
22
+ */
23
+function isIPv6InCidr(ip: string, cidr: string): boolean {
24
+ const [range, bits] = cidr.split("/");
25
+ const prefixLen = parseInt(bits);
26
+ const ipBuf = ipv6ToBuffer(ip);
27
+ const rangeBuf = ipv6ToBuffer(range);
28
+ if (!ipBuf || !rangeBuf) return false;
29
+ const fullBytes = Math.floor(prefixLen / 8);
30
+ const remainingBits = prefixLen % 8;
31
+ for (let i = 0; i < fullBytes; i++) {
32
+ if (ipBuf[i] !== rangeBuf[i]) return false;
33
+ }
34
+ if (remainingBits > 0) {
35
+ const mask = ~((1 << (8 - remainingBits)) - 1) & 0xff;
36
+ if ((ipBuf[fullBytes] & mask) !== (rangeBuf[fullBytes] & mask)) return false;
37
+ }
38
+ return true;
39
+}
40
+
41
+function ipToNumber(ip: string): number | null {
42
+ const parts = ip.split(".");
43
+ if (parts.length !== 4) return null;
44
+ return parts.reduce((acc, part) => (acc << 8) + parseInt(part), 0) >>> 0;
45
+}
46
+
47
+function ipv6ToBuffer(ip: string): Buffer | null {
48
+ try {
49
+ // Expand compressed IPv6
50
+ const sections = ip.split(":");
51
+ if (sections.length > 8) return null;
52
+ // Handle :: expansion
53
+ const fullSections = [];
54
+ let foundDoubleColon = false;
55
+ for (const s of sections) {
56
+ if (s === "") {
57
+ if (foundDoubleColon) return null;
58
+ foundDoubleColon = true;
59
+ const remaining = 8 - sections.length + 1;
60
+ for (let i = 0; i < remaining; i++) fullSections.push("0000");
61
+ } else {
62
+ fullSections.push(s.padStart(4, "0"));
63
+ }
64
+ }
65
+ while (fullSections.length < 8) fullSections.push("0000");
66
+ const hex = fullSections.join("");
67
+ return Buffer.from(hex, "hex");
68
+ } catch {
69
+ return null;
70
+ }
71
+}
72
+
73
+/**
74
+ * Resolve hostname and check if the resolved IP is private/reserved
75
+ */
76
+async function isPrivateOrReservedIP(hostname: string): Promise<boolean> {
77
+ // Always block these hostnames before DNS resolution
78
+ const alwaysBlockedHostnames = [
79
+ "localhost",
80
+ "127.0.0.1",
81
+ "::1",
82
+ "metadata.google.internal",
83
+ ];
84
+ if (alwaysBlockedHostnames.includes(hostname.toLowerCase())) return true;
85
+
86
+ try {
87
+ const { address } = await dnsLookup(hostname);
88
+ const ip = address.toLowerCase();
89
+
90
+ // Block 0.0.0.0
91
+ if (ip === "0.0.0.0") return true;
92
+
93
+ // Block 169.254.0.0/16 (link-local)
94
+ if (ip.startsWith("169.254.")) return true;
95
+
96
+ // Block 10.0.0.0/8 (RFC 1918)
97
+ if (ip.startsWith("10.")) return true;
98
+
99
+ // Block 172.16.0.0/12 (RFC 1918)
100
+ if (isIPv4InCidr(ip, "172.16.0.0/12")) return true;
101
+
102
+ // Block 192.168.0.0/16 (RFC 1918)
103
+ if (ip.startsWith("192.168.")) return true;
104
+
105
+ // Block 127.0.0.0/8 (loopback)
106
+ if (ip.startsWith("127.")) return true;
107
+
108
+ // IPv6 private/reserved ranges
109
+ if (ip.includes(":")) {
110
+ // fc00::/7 (unique local addresses)
111
+ if (isIPv6InCidr(ip, "fc00::/7")) return true;
112
+ // fe80::/10 (link-local)
113
+ if (isIPv6InCidr(ip, "fe80::/10")) return true;
114
+ // ::1 (loopback)
115
+ if (ip === "::1") return true;
116
+ // ::0 (unspecified)
117
+ if (ip === "::") return true;
118
+ }
119
+ } catch {
120
+ // DNS resolution failed — reject to be safe
121
+ return true;
122
+ }
123
+
124
+ return false;
125
+}
3
126
4
127
/**
5
128
* Validation Schemas for OpenCodeHub APIs
92
215
.string()
93
216
.url()
94
217
.refine(
95
- (url) => {
218
+ async (url) => {
96
219
try {
97
220
const parsed = new URL(url);
98
221
if (!["http:", "https:"].includes(parsed.protocol)) return false;
99
222
const h = parsed.hostname.toLowerCase();
100
- const blocked = [
101
- "localhost",
102
- "127.0.0.1",
103
- "::1",
104
- "0.0.0.0",
105
- "169.254.169.254",
106
- "metadata.google.internal",
107
- ];
108
- if (blocked.includes(h)) return false;
109
- return true;
223
+ // Check if the resolved IP is private/reserved via DNS lookup
224
+ return !(await isPrivateOrReservedIP(h));
110
225
} catch {
111
226
return false;
112
227
}
113
228
▼src/middleware.ts
+5-2
13
13
newRequestId,
14
14
withRequestContext,
15
15
} from "./lib/request-context";
16
+import { randomBytes } from "crypto";
16
17
17
18
// Define tiers for different routes
18
19
const apiLimiter = createRateLimitMiddleware("api");
157
158
// Add Server-Timing header for observability
158
159
response.headers.set("Server-Timing", `total;dur=${durationMs.toFixed(1)}`);
159
160
160
- // Add Content-Security-Policy header
161
+ // Add Content-Security-Policy header with nonce-based script-src
161
162
if (!response.headers.has("Content-Security-Policy")) {
163
+ const cspNonce = randomBytes(16).toString("base64");
164
+ context.locals.cspNonce = cspNonce;
162
165
response.headers.set(
163
166
"Content-Security-Policy",
164
- "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https: blob:; connect-src 'self' https:;",
167
+ `default-src 'self'; script-src 'self' 'nonce-${cspNonce}' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https: blob:; connect-src 'self' https:;`,
165
168
);
166
169
}
167
170
168
171
▼src/middleware/csrf.ts
+10-11
5
5
*/
6
6
7
7
import { nanoid } from "nanoid";
8
+import { timingSafeEqual as nodeTimingSafeEqual } from "crypto";
8
9
9
10
const CSRF_TOKEN_LENGTH = 32;
10
11
const CSRF_COOKIE_NAME = "csrf_token";
104
105
}
105
106
106
107
/**
107
- * Timing-safe string comparison
108
+ * Timing-safe string comparison using Node.js crypto
109
+ * Pads both strings to a fixed length to prevent length leakage
108
110
*/
109
-function timingSafeEqual(a: string, b: string): boolean {
110
- if (a.length !== b.length) {
111
- return false;
112
- }
111
+const COMPARISON_FIXED_LENGTH = 64;
113
112
114
- let mismatch = 0;
115
- for (let i = 0; i < a.length; i++) {
116
- mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
117
- }
118
-
119
- return mismatch === 0;
113
+function timingSafeEqual(a: string, b: string): boolean {
114
+ const paddedA = a.padEnd(COMPARISON_FIXED_LENGTH, "\0");
115
+ const paddedB = b.padEnd(COMPARISON_FIXED_LENGTH, "\0");
116
+ const bufA = Buffer.from(paddedA, "utf8");
117
+ const bufB = Buffer.from(paddedB, "utf8");
118
+ return nodeTimingSafeEqual(bufA, bufB);
120
119
}
121
120
122
121
/**
123
122
▼src/middleware/rate-limit.ts
+5-4
66
66
return null;
67
67
}
68
68
69
- // Skip rate limiting in development if configured
70
- if (
71
- process.env.NODE_ENV === "development" &&
69
+ // Never skip rate limiting in production, even if RATE_LIMIT_SKIP_DEV is set
70
+ if (process.env.NODE_ENV !== "development") {
71
+ // Fall through to rate limiting
72
+ } else if (
72
73
process.env.RATE_LIMIT_SKIP_DEV === "true"
73
74
) {
74
- return null; // Continue to next handler
75
+ return null; // Continue to next handler (development only)
75
76
}
76
77
77
78
const identifier = getClientIdentifier(request, context);
78
79
▼src/pages/api/admin/stats.ts
+42-25
1
1
2
2
import { getDatabase, schema } from "@/db";
3
-import { count, desc, eq, gte } from "drizzle-orm";
3
+import { count, desc, eq, gte, sql } from "drizzle-orm";
4
4
import type { APIRoute } from "astro";
5
5
import os from "node:os";
6
6
import { withErrorHandler } from "@/lib/errors";
7
7
import { success } from "@/lib/api";
8
+import { logger } from "@/lib/logger";
8
9
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
10
10
11
export const GET: APIRoute = withErrorHandler(async ({ locals }) => {
44
45
}
45
46
});
46
47
47
- // 4. Code Stats (Real Aggregation)
48
- const allCommits = await db.select({ stats: schema.commits.stats }).from(schema.commits);
48
+ // 4. Code Stats — aggregate in SQL to avoid loading all rows into memory
49
49
let added = 0;
50
50
let deleted = 0;
51
- allCommits.forEach(c => {
52
- try {
53
- const s = typeof c.stats === 'string' ? JSON.parse(c.stats) : c.stats;
54
- if (s) {
55
- // ...
56
- }
57
- } catch (e) { }
58
- });
51
+ try {
52
+ const statsRows = await db.execute(sql`
53
+ SELECT
54
+ COALESCE(SUM((stats::json->>'additions')::int), 0)::int AS total_added,
55
+ COALESCE(SUM((stats::json->>'deletions')::int), 0)::int AS total_deleted
56
+ FROM commits
57
+ `);
58
+ const row = (statsRows as any)?.rows?.[0] || (Array.isArray(statsRows) ? statsRows[0] : null);
59
+ if (row) {
60
+ added = Number(row.total_added) || 0;
61
+ deleted = Number(row.total_deleted) || 0;
62
+ }
63
+ } catch (e) {
64
+ // Fallback: if JSON extraction fails, return zeros (non-critical)
65
+ logger.warn({ e }, "Failed to aggregate commit stats via SQL");
66
+ }
59
67
60
- // 5. Languages Stats (Real Aggregation)
61
- const allRepos = await db.select({ languages: schema.repositories.languages }).from(schema.repositories);
68
+ // 5. Languages Stats — aggregate in SQL to avoid loading all repos into memory
62
69
const langMap: Record<string, number> = {};
63
70
let totalLangUsage = 0;
64
-
65
- allRepos.forEach(r => {
66
- try {
67
- const l = typeof r.languages === 'string' ? JSON.parse(r.languages) : r.languages;
68
- if (l) {
69
- Object.entries(l).forEach(([key, val]) => {
70
- langMap[key] = (langMap[key] || 0) + (val as number);
71
- totalLangUsage += (val as number);
72
- });
73
- }
74
- } catch (e) { }
75
- });
71
+ try {
72
+ const langRows = await db.execute(sql`
73
+ SELECT
74
+ key AS lang,
75
+ SUM(val::bigint)::bigint AS total_bytes
76
+ FROM repositories,
77
+ jsonb_each_text(COALESCE(languages::jsonb, '{}'::jsonb)) AS kv(key, val)
78
+ GROUP BY key
79
+ ORDER BY total_bytes DESC
80
+ LIMIT 20
81
+ `);
82
+ const rows = (langRows as any)?.rows || (Array.isArray(langRows) ? langRows : []);
83
+ for (const r of rows) {
84
+ const langName = String(r.lang);
85
+ const bytes = Number(r.total_bytes) || 0;
86
+ langMap[langName] = bytes;
87
+ totalLangUsage += bytes;
88
+ }
89
+ } catch (e) {
90
+ // Fallback: if JSON extraction fails, return empty (non-critical)
91
+ logger.warn({ e }, "Failed to aggregate language stats via SQL");
92
+ }
76
93
77
94
const languages = Object.entries(langMap)
78
95
.map(([name, count]) => ({
79
96
▼src/pages/api/health.ts
+8-2
33
33
if (process.env.REDIS_URL) {
34
34
const redisStart = Date.now();
35
35
try {
36
- // Would ping Redis here
37
- checks.redis = { status: "ok", latency: Date.now() - redisStart };
36
+ const { redis } = await import("@/lib/redis");
37
+ const pong = await redis.ping();
38
+ if (pong === "PONG" || pong === "OK") {
39
+ checks.redis = { status: "ok", latency: Date.now() - redisStart };
40
+ } else {
41
+ checks.redis = { status: "error", message: `Unexpected ping response: ${pong}` };
42
+ }
38
43
} catch (error) {
39
44
checks.redis = {
40
45
status: "error",
108
113
status: isHealthy ? 200 : 503,
109
114
headers: {
110
115
"Content-Type": "application/json",
116
+ "Cache-Control": "no-store, no-cache, must-revalidate",
111
117
},
112
118
}
113
119
);
114
120
▼src/pages/api/oauth/authorize.ts
+17-9
8
8
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
import { createHash, randomBytes } from "crypto";
10
10
import { generateId } from "@/lib/utils";
11
+import { escapeHtml } from "@/lib/sanitize";
11
12
12
13
const CODE_TTL_MINUTES = 10;
13
14
44
45
const appScopes: string[] = app.scopes ? JSON.parse(app.scopes) : [];
45
46
const scopeOk = requestedScopes.every((s: string) => appScopes.includes(s) || appScopes.includes("admin"));
46
47
48
+ const safeAppName = escapeHtml(app.name || "");
49
+ const safeClientId = escapeHtml(clientId);
50
+ const safeRedirectUri = escapeHtml(redirectUri);
51
+ const safeState = escapeHtml(state);
52
+ const safeUsername = escapeHtml(user.username);
53
+ const safeScopes = requestedScopes.map((s: string) => escapeHtml(s));
54
+
47
55
return new Response(
48
56
`<!DOCTYPE html>
49
57
<html>
50
-<head><title>Authorize ${app.name}</title>
58
+<head><title>Authorize ${safeAppName}</title>
51
59
<style>
52
60
body { font-family: system-ui, sans-serif; background: #0b0f1a; color: #e2e8f0; display: flex; justify-content: center; padding-top: 8vh; }
53
61
.card { background: #131a2b; border: 1px solid #243049; border-radius: 12px; padding: 32px; width: 420px; }
60
68
</style></head>
61
69
<body>
62
70
<form class="card" method="POST" action="/api/oauth/authorize">
63
- <input type="hidden" name="client_id" value="${clientId}" />
64
- <input type="hidden" name="redirect_uri" value="${redirectUri}" />
65
- <input type="hidden" name="state" value="${state}" />
66
- <input type="hidden" name="scope" value="${requestedScopes.join(" ")}" />
67
- <h1>Authorize ${app.name}</h1>
68
- <p><strong>${app.name}</strong> (by ${user.username}) is requesting access to your OpenCodeHub account.</p>
71
+ <input type="hidden" name="client_id" value="${safeClientId}" />
72
+ <input type="hidden" name="redirect_uri" value="${safeRedirectUri}" />
73
+ <input type="hidden" name="state" value="${safeState}" />
74
+ <input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}" />
75
+ <h1>Authorize ${safeAppName}</h1>
76
+ <p><strong>${safeAppName}</strong> (by ${safeUsername}) is requesting access to your OpenCodeHub account.</p>
69
77
<p>This app will be able to:</p>
70
- <div>${requestedScopes.map((s: string) => `<span class="scope">${s}</span>`).join("") || '<span class="scope">basic profile</span>'}</div>
71
- <p style="margin-top:16px">Redirect URI: <code>${redirectUri}</code></p>
78
+ <div>${safeScopes.map((s: string) => `<span class="scope">${s}</span>`).join("") || '<span class="scope">basic profile</span>'}</div>
79
+ <p style="margin-top:16px">Redirect URI: <code>${safeRedirectUri}</code></p>
72
80
<button class="allow" name="approve" value="true" type="submit">Authorize</button>
73
81
<button class="deny" name="approve" value="false" type="submit">Deny</button>
74
82
</form>
75
83
▼src/pages/api/repos/[owner]/[repo]/git/push.ts
+37-12
17
17
parseStoragePath,
18
18
ensureRepoInitialized
19
19
} from "@/lib/git-storage";
20
-import { spawn, execSync } from "child_process";
20
+import { spawn } from "child_process";
21
21
import { existsSync, writeFileSync, unlinkSync } from "fs";
22
22
import { join } from "path";
23
23
146
146
// Fetch from the bundle
147
147
const refs: string[] = [];
148
148
149
- // Get list of refs in bundle
150
- const listOutput = execSync(`git bundle list-heads "${tempBundlePath}"`, {
151
- cwd: repoPath,
152
- encoding: "utf-8",
149
+ // Get list of refs in bundle (using spawn to avoid command injection)
150
+ const listOutput = await new Promise<string>((resolve, reject) => {
151
+ const child = spawn("git", ["bundle", "list-heads", tempBundlePath], {
152
+ cwd: repoPath,
153
+ stdio: ["pipe", "pipe", "pipe"],
154
+ });
155
+ let stdout = "";
156
+ let stderr = "";
157
+ child.stdout.on("data", (data) => { stdout += data.toString(); });
158
+ child.stderr.on("data", (data) => { stderr += data.toString(); });
159
+ child.on("close", (code) => {
160
+ if (code !== 0) reject(new Error(`Failed to list bundle refs: ${stderr}`));
161
+ else resolve(stdout);
162
+ });
163
+ child.on("error", reject);
153
164
});
154
165
155
166
const bundleRefs = listOutput
161
172
return { sha, ref };
162
173
});
163
174
164
- // Fetch each ref from bundle
175
+ // Fetch each ref from bundle (using spawn to avoid command injection)
165
176
for (const { ref } of bundleRefs) {
166
177
try {
167
178
// Fetch the ref
168
- execSync(`git fetch "${tempBundlePath}" "${ref}:${ref}"`, {
169
- cwd: repoPath,
170
- stdio: "pipe",
179
+ await new Promise<void>((resolve, reject) => {
180
+ const child = spawn("git", ["fetch", tempBundlePath, `${ref}:${ref}`], {
181
+ cwd: repoPath,
182
+ stdio: "pipe",
183
+ });
184
+ child.on("close", (code) => {
185
+ if (code !== 0) reject(new Error(`git fetch exited with code ${code}`));
186
+ else resolve();
187
+ });
188
+ child.on("error", reject);
171
189
});
172
190
refs.push(ref);
173
191
} catch (fetchError) {
174
192
// Try force update if regular fetch fails
175
193
if (force) {
176
194
try {
177
- execSync(`git fetch "${tempBundlePath}" "+${ref}:${ref}"`, {
178
- cwd: repoPath,
179
- stdio: "pipe",
195
+ await new Promise<void>((resolve, reject) => {
196
+ const child = spawn("git", ["fetch", tempBundlePath, `+${ref}:${ref}`], {
197
+ cwd: repoPath,
198
+ stdio: "pipe",
199
+ });
200
+ child.on("close", (code) => {
201
+ if (code !== 0) reject(new Error(`git fetch exited with code ${code}`));
202
+ else resolve();
203
+ });
204
+ child.on("error", reject);
180
205
});
181
206
refs.push(`${ref} (force)`);
182
207
} catch {
183
208
▼src/pages/api/repos/[owner]/[repo]/ide/fs.ts
+10-1
18
18
const branch = url.searchParams.get("branch") || "main";
19
19
const filePath = url.searchParams.get("path") || "";
20
20
21
+ // Prevent path traversal
22
+ if (filePath.includes("..")) {
23
+ return badRequest("Invalid path: path traversal detected");
24
+ }
25
+
21
26
const db = getDatabase();
22
27
const ownerUser = await db.query.users.findFirst({ where: eq(schema.users.username, owner!) });
23
28
if (!ownerUser) return notFound("Owner not found");
84
89
85
90
// Write files
86
91
for (const [filePath, content] of Object.entries(files)) {
87
- const fullPath = path.join(tempPath, filePath);
92
+ const fullPath = path.resolve(path.join(tempPath, filePath));
93
+ if (!fullPath.startsWith(path.resolve(tempPath))) {
94
+ await fs.rm(tempPath, { recursive: true, force: true });
95
+ return badRequest("Invalid file path: path traversal detected");
96
+ }
88
97
await fs.mkdir(path.dirname(fullPath), { recursive: true });
89
98
await fs.writeFile(fullPath, content);
90
99
await workGit.add(filePath);
91
100
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/requested-reviewers.ts
+6-8
63
63
if (!pr) return notFound("Pull request not found");
64
64
65
65
// Verify users exist and exclude the author
66
- const valid: string[] = [];
67
- for (const id of userIds) {
68
- const u = await db.query.users.findFirst({
69
- where: eq(schema.users.id, id),
70
- columns: { id: true },
71
- });
72
- if (u && u.id !== pr.authorId) valid.push(id);
73
- }
66
+ const uniqueUserIds = [...new Set(userIds)];
67
+ const validUsers = await db.query.users.findMany({
68
+ where: inArray(schema.users.id, uniqueUserIds),
69
+ columns: { id: true },
70
+ });
71
+ const valid = validUsers.map(u => u.id).filter(id => id !== pr.authorId);
74
72
75
73
const existing = await db.query.pullRequestReviewers.findMany({
76
74
where: and(
77
75
▼src/pages/api/repos/[owner]/[repo]/pulls/reviewer-routing.ts
+14-2
276
276
let assigneesAdded = 0;
277
277
let assigneesSkipped = 0;
278
278
279
+ // Batch collect reviewer and assignee inserts
280
+ const reviewerInserts: { id: string; pullRequestId: string; userId: string; isRequired: boolean; requestedAt: Date }[] = [];
281
+ const assigneeInserts: { id: string; pullRequestId: string; userId: string; assignedAt: Date }[] = [];
282
+
279
283
for (const pr of pullRequests) {
280
284
for (const reviewerId of parsed.data.reviewerIds) {
281
285
if (reviewerId === pr.authorId) {
289
293
continue;
290
294
}
291
295
292
- await db.insert(schema.pullRequestReviewers).values({
296
+ reviewerInserts.push({
293
297
id: generateId(),
294
298
pullRequestId: pr.id,
295
299
userId: reviewerId,
307
311
continue;
308
312
}
309
313
310
- await db.insert(schema.pullRequestAssignees).values({
314
+ assigneeInserts.push({
311
315
id: generateId(),
312
316
pullRequestId: pr.id,
313
317
userId: assigneeId,
318
322
}
319
323
}
320
324
325
+ // Batch inserts instead of N+1 individual inserts
326
+ if (reviewerInserts.length > 0) {
327
+ await db.insert(schema.pullRequestReviewers).values(reviewerInserts);
328
+ }
329
+ if (assigneeInserts.length > 0) {
330
+ await db.insert(schema.pullRequestAssignees).values(assigneeInserts);
331
+ }
332
+
321
333
return success({
322
334
routedPrCount: pullRequests.length,
323
335
summary: {
324
336
▼tests/unit/validation.test.ts
+8-8
203
203
});
204
204
205
205
describe("WebhookConfigSchema", () => {
206
- it("accepts valid webhook config", () => {
207
- const result = WebhookConfigSchema.safeParse({
206
+ it("accepts valid webhook config", async () => {
207
+ const result = await WebhookConfigSchema.safeParseAsync({
208
208
url: "https://example.com/webhook",
209
209
events: ["push", "pull_request"],
210
210
secret: "mysecret",
213
213
expect(result.success).toBe(true);
214
214
});
215
215
216
- it("rejects missing url", () => {
217
- const result = WebhookConfigSchema.safeParse({
216
+ it("rejects missing url", async () => {
217
+ const result = await WebhookConfigSchema.safeParseAsync({
218
218
events: ["push"],
219
219
});
220
220
expect(result.success).toBe(false);
221
221
});
222
222
223
- it("rejects private IP urls (SSRF protection)", () => {
224
- const result = WebhookConfigSchema.safeParse({
223
+ it("rejects private IP urls (SSRF protection)", async () => {
224
+ const result = await WebhookConfigSchema.safeParseAsync({
225
225
url: "http://127.0.0.1:8080/hook",
226
226
events: ["push"],
227
227
});
229
229
expect(result.success).toBe(false);
230
230
});
231
231
232
- it("rejects localhost urls", () => {
233
- const result = WebhookConfigSchema.safeParse({
232
+ it("rejects localhost urls", async () => {
233
+ const result = await WebhookConfigSchema.safeParseAsync({
234
234
url: "http://localhost:3000/hook",
235
235
events: ["push"],
236
236
});
237
237