Co-auth
commit Co-auth
29 files changed
+477 -136
Showing 29 changed files+477-136
docker-compose.nas.ymldocker-compose.production.ymldocker-compose.ymlscripts/worker.tssrc/db/index.tssrc/db/schema/ai-reviews.tssrc/db/schema/automations.tssrc/db/schema/branch-protection.tssrc/db/schema/deploy-keys.tssrc/db/schema/merge-queue.tssrc/db/schema/pull-requests.tssrc/db/schema/stacked-prs.tssrc/env.d.tssrc/lib/graceful-shutdown.tssrc/lib/rate-limit.tssrc/lib/redis.tssrc/lib/ssh.tssrc/lib/validation.tssrc/middleware.tssrc/middleware/csrf.tssrc/middleware/rate-limit.tssrc/pages/api/admin/stats.tssrc/pages/api/health.tssrc/pages/api/oauth/authorize.tssrc/pages/api/repos/[owner]/[repo]/git/push.tssrc/pages/api/repos/[owner]/[repo]/ide/fs.tssrc/pages/api/repos/[owner]/[repo]/pulls/[number]/requested-reviewers.tssrc/pages/api/repos/[owner]/[repo]/pulls/reviewer-routing.tstests/unit/validation.test.ts
▼docker-compose.nas.yml
+25-0
| @@ -80,6 +80,11 @@ services: | ||
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,6 +95,11 @@ services: | ||
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,6 +117,11 @@ services: | ||
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,6 +141,11 @@ services: | ||
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,6 +171,11 @@ services: | ||
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,7 +57,7 @@ services: | ||
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,13 +68,19 @@ services: | ||
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,7 +114,19 @@ services: | ||
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,13 +161,18 @@ services: | ||
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,12 +186,17 @@ services: | ||
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,12 +216,17 @@ services: | ||
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,7 +249,18 @@ services: | ||
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,7 +278,7 @@ services: | ||
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,5 +308,7 @@ volumes: | ||
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,9 +250,11 @@ function setupGracefulShutdown(healthServer: ReturnType<typeof createServer>) { | ||
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,6 +30,7 @@ let db: | ||
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,6 +114,10 @@ export function getDatabase(): | ||
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,9 +184,11 @@ export async function closeDatabase(): Promise<void> { | ||
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,6 +5,7 @@ | ||
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,7 +49,9 @@ export const aiReviews = pgTable("ai_reviews", { | ||
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,6 +5,7 @@ | ||
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,7 +32,9 @@ export const automationRules = pgTable("automation_rules", { | ||
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,6 +5,7 @@ | ||
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,7 +30,9 @@ export const branchProtection = pgTable("branch_protection", { | ||
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,6 +5,7 @@ | ||
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,7 +19,9 @@ 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,5 +1,5 @@ | ||
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,7 +38,13 @@ export const mergeQueue = pgTable("merge_queue", { | ||
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,7 +196,9 @@ export const pullRequestReviewers = pgTable("pull_request_reviewers", { | ||
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,7 +4,7 @@ | ||
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,7 +23,11 @@ export const prStacks = pgTable("pr_stacks", { | ||
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,7 +41,13 @@ 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,5 +4,6 @@ declare namespace App { | ||
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,17 +9,21 @@ | ||
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,33 +31,41 @@ function gracefulShutdown(signal: string) { | ||
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,6 +68,8 @@ class InMemoryRateLimiter implements RateLimiter { | ||
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,8 +103,8 @@ class RedisRateLimiter implements RateLimiter { | ||
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,6 +151,18 @@ export async function deleteSession(sessionId: string): Promise<void> { | ||
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,6 +11,9 @@ import { dirname, join } from "path"; | ||
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,6 +246,14 @@ export function createSSHServer( | ||
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,5 +1,128 @@ | ||
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,21 +215,13 @@ export const WebhookConfigSchema = z.object({ | ||
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,6 +13,7 @@ import { | ||
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,11 +158,13 @@ async function onRequestInner( | ||
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,6 +5,7 @@ | ||
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,19 +105,17 @@ export async function validateCsrfToken(request: Request): Promise<boolean> { | ||
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,12 +66,13 @@ export function createRateLimitMiddleware( | ||
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,10 +1,11 @@ | ||
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,35 +45,51 @@ 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,8 +33,13 @@ export const GET: APIRoute = withErrorHandler(async () => { | ||
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,6 +113,7 @@ export const GET: APIRoute = withErrorHandler(async () => { | ||
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,6 +8,7 @@ import { logger } from "@/lib/logger"; | ||
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,10 +45,17 @@ export const GET: APIRoute = withErrorHandler(async ({ request, url }) => { | ||
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,15 +68,15 @@ export const GET: APIRoute = withErrorHandler(async ({ request, url }) => { | ||
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,7 +17,7 @@ import { | ||
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,10 +146,21 @@ export const POST: APIRoute = withErrorHandler(async ({ request, params }) => { | ||
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,22 +172,36 @@ export const POST: APIRoute = withErrorHandler(async ({ request, params }) => { | ||
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,6 +18,11 @@ export const GET: APIRoute = async ({ request, params, url }) => { | ||
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,7 +89,11 @@ export const POST: APIRoute = async ({ request, params }) => { | ||
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,14 +63,12 @@ export const POST: APIRoute = async ({ request, params }) => { | ||
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,6 +276,10 @@ export const POST: APIRoute = withErrorHandler( | ||
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,7 +293,7 @@ export const POST: APIRoute = withErrorHandler( | ||
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,7 +311,7 @@ export const POST: APIRoute = withErrorHandler( | ||
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,6 +322,14 @@ export const POST: APIRoute = withErrorHandler( | ||
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,8 +203,8 @@ describe("BranchProtectionSchema", () => { | ||
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,15 +213,15 @@ describe("WebhookConfigSchema", () => { | ||
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,8 +229,8 @@ describe("WebhookConfigSchema", () => { | ||
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 | |