| author | |
| committer | |
| log | b5188e8ceaee3940d2fb3cd88c8fc2121fef1ae7 |
| tree | 7b6e3246022b3f1b868bb10ec869c317551928ec |
| parent | 992db1658633ff268be5d1167634966ae63ed859 |
- Materialize only valid image data URLs and restrict inferred file extensions to a safe whitelist
- Preserve existing message attachments on upsert unless explicitly replaced or cleared
- Serve static `/` via `index.html` and block traversal attempts in static file requests7 files changed, 364 insertions(+), 15 deletions(-)
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+111| ... | ... | @@ -247,6 +247,117 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 247 | 247 | }), |
| 248 | 248 | ); |
| 249 | 249 | |
| 250 | it.effect("materializes only image data URLs and uses a safe extension whitelist", () => | |
| 251 | Effect.gen(function* () { | |
| 252 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 253 | const eventStore = yield* OrchestrationEventStore; | |
| 254 | const sql = yield* SqlClient.SqlClient; | |
| 255 | const now = new Date().toISOString(); | |
| 256 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); | |
| 257 | ||
| 258 | const serverConfig = { | |
| 259 | mode: "web", | |
| 260 | port: 0, | |
| 261 | host: undefined, | |
| 262 | cwd: "/tmp/project-attachments", | |
| 263 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | |
| 264 | stateDir, | |
| 265 | staticDir: undefined, | |
| 266 | devUrl: undefined, | |
| 267 | noBrowser: true, | |
| 268 | authToken: undefined, | |
| 269 | autoBootstrapProjectFromCwd: false, | |
| 270 | logWebSocketEvents: false, | |
| 271 | } satisfies ServerConfigShape; | |
| 272 | ||
| 273 | yield* eventStore.append({ | |
| 274 | type: "thread.message-sent", | |
| 275 | eventId: EventId.makeUnsafe("evt-attachments-safe"), | |
| 276 | aggregateKind: "thread", | |
| 277 | aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), | |
| 278 | occurredAt: now, | |
| 279 | commandId: CommandId.makeUnsafe("cmd-attachments-safe"), | |
| 280 | causationEventId: null, | |
| 281 | correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), | |
| 282 | metadata: {}, | |
| 283 | payload: { | |
| 284 | threadId: ThreadId.makeUnsafe("thread-attachments-safe"), | |
| 285 | messageId: MessageId.makeUnsafe("message-attachments-safe"), | |
| 286 | role: "user", | |
| 287 | text: "Inspect this", | |
| 288 | attachments: [ | |
| 289 | { | |
| 290 | type: "image", | |
| 291 | name: "untrusted.exe", | |
| 292 | mimeType: "image/x-unknown", | |
| 293 | sizeBytes: 5, | |
| 294 | dataUrl: "data:image/x-unknown;base64,SGVsbG8=", | |
| 295 | }, | |
| 296 | { | |
| 297 | type: "image", | |
| 298 | name: "not-image.png", | |
| 299 | mimeType: "image/png", | |
| 300 | sizeBytes: 5, | |
| 301 | dataUrl: "data:text/plain;base64,SGVsbG8=", | |
| 302 | }, | |
| 303 | ], | |
| 304 | turnId: null, | |
| 305 | streaming: false, | |
| 306 | createdAt: now, | |
| 307 | updatedAt: now, | |
| 308 | }, | |
| 309 | }); | |
| 310 | ||
| 311 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | |
| 312 | ||
| 313 | const rows = yield* sql<{ | |
| 314 | readonly attachmentsJson: string | null; | |
| 315 | }>` | |
| 316 | SELECT | |
| 317 | attachments_json AS "attachmentsJson" | |
| 318 | FROM projection_thread_messages | |
| 319 | WHERE message_id = 'message-attachments-safe' | |
| 320 | `; | |
| 321 | assert.equal(rows.length, 1); | |
| 322 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ | |
| 323 | { | |
| 324 | type: "image", | |
| 325 | name: "untrusted.exe", | |
| 326 | mimeType: "image/x-unknown", | |
| 327 | sizeBytes: 5, | |
| 328 | dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe/0.bin", | |
| 329 | }, | |
| 330 | { | |
| 331 | type: "image", | |
| 332 | name: "not-image.png", | |
| 333 | mimeType: "image/png", | |
| 334 | sizeBytes: 5, | |
| 335 | dataUrl: "data:text/plain;base64,SGVsbG8=", | |
| 336 | }, | |
| 337 | ]); | |
| 338 | ||
| 339 | const firstAttachmentPath = path.join( | |
| 340 | stateDir, | |
| 341 | "attachments", | |
| 342 | "thread-attachments-safe", | |
| 343 | "message-attachments-safe", | |
| 344 | "0.bin", | |
| 345 | ); | |
| 346 | assert.equal(fs.existsSync(firstAttachmentPath), true); | |
| 347 | assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64")); | |
| 348 | ||
| 349 | const secondAttachmentPath = path.join( | |
| 350 | stateDir, | |
| 351 | "attachments", | |
| 352 | "thread-attachments-safe", | |
| 353 | "message-attachments-safe", | |
| 354 | "1.png", | |
| 355 | ); | |
| 356 | assert.equal(fs.existsSync(secondAttachmentPath), false); | |
| 357 | fs.rmSync(stateDir, { recursive: true, force: true }); | |
| 358 | }), | |
| 359 | ); | |
| 360 | ||
| 250 | 361 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 251 | 362 | Effect.gen(function* () { |
| 252 | 363 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+26-5| ... | ... | @@ -70,6 +70,17 @@ const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { |
| 70 | 70 | "image/tiff": ".tiff", |
| 71 | 71 | "image/heic": ".heic", |
| 72 | 72 | }; |
| 73 | const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ | |
| 74 | ".jpg", | |
| 75 | ".jpeg", | |
| 76 | ".png", | |
| 77 | ".gif", | |
| 78 | ".webp", | |
| 79 | ".bmp", | |
| 80 | ".tiff", | |
| 81 | ".svg", | |
| 82 | ".ico", | |
| 83 | ]); | |
| 73 | 84 | |
| 74 | 85 | function parseBase64DataUrl( |
| 75 | 86 | dataUrl: string, |
| ... | ... | @@ -87,14 +98,18 @@ function parseBase64DataUrl( |
| 87 | 98 | } |
| 88 | 99 | |
| 89 | 100 | function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string { |
| 90 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[attachment.mimeType.toLowerCase()]; | |
| 91 | if (fromMime) { | |
| 92 | return fromMime; | |
| 101 | const normalizedMimeType = attachment.mimeType.trim().toLowerCase(); | |
| 102 | if (normalizedMimeType.startsWith("image/")) { | |
| 103 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; | |
| 104 | if (fromMime) { | |
| 105 | return fromMime; | |
| 106 | } | |
| 93 | 107 | } |
| 108 | ||
| 94 | 109 | const fromName = attachment.name.includes(".") |
| 95 | 110 | ? `.${attachment.name.split(".").slice(-1)[0]?.toLowerCase() ?? ""}` |
| 96 | 111 | : ""; |
| 97 | if (/^\.[a-z0-9]{1,12}$/.test(fromName)) { | |
| 112 | if (/^\.[a-z0-9]{1,12}$/.test(fromName) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromName)) { | |
| 98 | 113 | return fromName; |
| 99 | 114 | } |
| 100 | 115 | return ".bin"; |
| ... | ... | @@ -132,13 +147,19 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 132 | 147 | if (!parsed) { |
| 133 | 148 | return attachment; |
| 134 | 149 | } |
| 150 | if (!parsed.mimeType.startsWith("image/")) { | |
| 151 | return attachment; | |
| 152 | } | |
| 135 | 153 | |
| 136 | 154 | const bytes = Buffer.from(parsed.base64, "base64"); |
| 137 | 155 | if (bytes.byteLength === 0) { |
| 138 | 156 | return attachment; |
| 139 | 157 | } |
| 140 | 158 | |
| 141 | const fileName = `${index}${inferImageExtension(attachment)}`; | |
| 159 | const fileName = `${index}${inferImageExtension({ | |
| 160 | ...attachment, | |
| 161 | mimeType: parsed.mimeType, | |
| 162 | })}`; | |
| 142 | 163 | const relativePath = `${threadSegment}/${messageSegment}/${fileName}`; |
| 143 | 164 | const absolutePath = path.join(attachmentsRootDir, threadSegment, messageSegment, fileName); |
| 144 | 165 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import { MessageId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; | |
| 6 | import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; | |
| 7 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 8 | ||
| 9 | const layer = it.layer( | |
| 10 | ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 11 | ); | |
| 12 | ||
| 13 | layer("ProjectionThreadMessageRepository", (it) => { | |
| 14 | it.effect("preserves existing attachments when upsert omits attachments", () => | |
| 15 | Effect.gen(function* () { | |
| 16 | const repository = yield* ProjectionThreadMessageRepository; | |
| 17 | const threadId = ThreadId.makeUnsafe("thread-preserve-attachments"); | |
| 18 | const messageId = MessageId.makeUnsafe("message-preserve-attachments"); | |
| 19 | const createdAt = "2026-02-28T19:00:00.000Z"; | |
| 20 | const updatedAt = "2026-02-28T19:00:01.000Z"; | |
| 21 | const persistedAttachments = [ | |
| 22 | { | |
| 23 | type: "image" as const, | |
| 24 | name: "example.png", | |
| 25 | mimeType: "image/png", | |
| 26 | sizeBytes: 5, | |
| 27 | dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", | |
| 28 | }, | |
| 29 | ]; | |
| 30 | ||
| 31 | yield* repository.upsert({ | |
| 32 | messageId, | |
| 33 | threadId, | |
| 34 | turnId: null, | |
| 35 | role: "user", | |
| 36 | text: "initial", | |
| 37 | attachments: persistedAttachments, | |
| 38 | isStreaming: false, | |
| 39 | createdAt, | |
| 40 | updatedAt, | |
| 41 | }); | |
| 42 | ||
| 43 | yield* repository.upsert({ | |
| 44 | messageId, | |
| 45 | threadId, | |
| 46 | turnId: null, | |
| 47 | role: "user", | |
| 48 | text: "updated", | |
| 49 | isStreaming: false, | |
| 50 | createdAt, | |
| 51 | updatedAt: "2026-02-28T19:00:02.000Z", | |
| 52 | }); | |
| 53 | ||
| 54 | const rows = yield* repository.listByThreadId({ threadId }); | |
| 55 | assert.equal(rows.length, 1); | |
| 56 | assert.equal(rows[0]?.text, "updated"); | |
| 57 | assert.deepEqual(rows[0]?.attachments, persistedAttachments); | |
| 58 | }), | |
| 59 | ); | |
| 60 | ||
| 61 | it.effect("allows explicit attachment clearing with an empty array", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const repository = yield* ProjectionThreadMessageRepository; | |
| 64 | const threadId = ThreadId.makeUnsafe("thread-clear-attachments"); | |
| 65 | const messageId = MessageId.makeUnsafe("message-clear-attachments"); | |
| 66 | const createdAt = "2026-02-28T19:10:00.000Z"; | |
| 67 | ||
| 68 | yield* repository.upsert({ | |
| 69 | messageId, | |
| 70 | threadId, | |
| 71 | turnId: null, | |
| 72 | role: "assistant", | |
| 73 | text: "with attachment", | |
| 74 | attachments: [ | |
| 75 | { | |
| 76 | type: "image", | |
| 77 | name: "example.png", | |
| 78 | mimeType: "image/png", | |
| 79 | sizeBytes: 5, | |
| 80 | dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", | |
| 81 | }, | |
| 82 | ], | |
| 83 | isStreaming: false, | |
| 84 | createdAt, | |
| 85 | updatedAt: "2026-02-28T19:10:01.000Z", | |
| 86 | }); | |
| 87 | ||
| 88 | yield* repository.upsert({ | |
| 89 | messageId, | |
| 90 | threadId, | |
| 91 | turnId: null, | |
| 92 | role: "assistant", | |
| 93 | text: "cleared", | |
| 94 | attachments: [], | |
| 95 | isStreaming: false, | |
| 96 | createdAt, | |
| 97 | updatedAt: "2026-02-28T19:10:02.000Z", | |
| 98 | }); | |
| 99 | ||
| 100 | const rows = yield* repository.listByThreadId({ threadId }); | |
| 101 | assert.equal(rows.length, 1); | |
| 102 | assert.equal(rows[0]?.text, "cleared"); | |
| 103 | assert.deepEqual(rows[0]?.attachments, []); | |
| 104 | }), | |
| 105 | ); | |
| 106 | }); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+17-5| ... | ... | @@ -23,8 +23,9 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 23 | 23 | |
| 24 | 24 | const upsertProjectionThreadMessageRow = SqlSchema.void({ |
| 25 | 25 | Request: ProjectionThreadMessage, |
| 26 | execute: (row) => | |
| 27 | sql` | |
| 26 | execute: (row) => { | |
| 27 | const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null; | |
| 28 | return sql` | |
| 28 | 29 | INSERT INTO projection_thread_messages ( |
| 29 | 30 | message_id, |
| 30 | 31 | thread_id, |
| ... | ... | @@ -42,7 +43,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 42 | 43 | ${row.turnId}, |
| 43 | 44 | ${row.role}, |
| 44 | 45 | ${row.text}, |
| 45 | ${row.attachments !== undefined ? JSON.stringify(row.attachments) : null}, | |
| 46 | COALESCE( | |
| 47 | ${nextAttachmentsJson}, | |
| 48 | ( | |
| 49 | SELECT attachments_json | |
| 50 | FROM projection_thread_messages | |
| 51 | WHERE message_id = ${row.messageId} | |
| 52 | ) | |
| 53 | ), | |
| 46 | 54 | ${row.isStreaming ? 1 : 0}, |
| 47 | 55 | ${row.createdAt}, |
| 48 | 56 | ${row.updatedAt} |
| ... | ... | @@ -53,11 +61,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 53 | 61 | turn_id = excluded.turn_id, |
| 54 | 62 | role = excluded.role, |
| 55 | 63 | text = excluded.text, |
| 56 | attachments_json = excluded.attachments_json, | |
| 64 | attachments_json = COALESCE( | |
| 65 | excluded.attachments_json, | |
| 66 | projection_thread_messages.attachments_json | |
| 67 | ), | |
| 57 | 68 | is_streaming = excluded.is_streaming, |
| 58 | 69 | created_at = excluded.created_at, |
| 59 | 70 | updated_at = excluded.updated_at |
| 60 | `, | |
| 71 | `; | |
| 72 | }, | |
| 61 | 73 | }); |
| 62 | 74 | |
| 63 | 75 | const listProjectionThreadMessageRows = SqlSchema.findAll({ |
apps/server/src/wsServer.test.ts+62-1| ... | ... | @@ -296,6 +296,36 @@ async function waitForPush( |
| 296 | 296 | return take(maxMessages); |
| 297 | 297 | } |
| 298 | 298 | |
| 299 | async function requestPath( | |
| 300 | port: number, | |
| 301 | requestPath: string, | |
| 302 | ): Promise<{ statusCode: number; body: string }> { | |
| 303 | return new Promise((resolve, reject) => { | |
| 304 | const req = Http.request( | |
| 305 | { | |
| 306 | hostname: "127.0.0.1", | |
| 307 | port, | |
| 308 | path: requestPath, | |
| 309 | method: "GET", | |
| 310 | }, | |
| 311 | (res) => { | |
| 312 | const chunks: Buffer[] = []; | |
| 313 | res.on("data", (chunk) => { | |
| 314 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | |
| 315 | }); | |
| 316 | res.on("end", () => { | |
| 317 | resolve({ | |
| 318 | statusCode: res.statusCode ?? 0, | |
| 319 | body: Buffer.concat(chunks).toString("utf8"), | |
| 320 | }); | |
| 321 | }); | |
| 322 | }, | |
| 323 | ); | |
| 324 | req.once("error", reject); | |
| 325 | req.end(); | |
| 326 | }); | |
| 327 | } | |
| 328 | ||
| 299 | 329 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 300 | 330 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; |
| 301 | 331 | for (const binding of bindings) { |
| ... | ... | @@ -331,6 +361,7 @@ describe("WebSocket Server", () => { |
| 331 | 361 | devUrl?: string; |
| 332 | 362 | authToken?: string; |
| 333 | 363 | stateDir?: string; |
| 364 | staticDir?: string; | |
| 334 | 365 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 335 | 366 | open?: OpenShape; |
| 336 | 367 | gitManager?: GitManagerShape; |
| ... | ... | @@ -354,7 +385,7 @@ describe("WebSocket Server", () => { |
| 354 | 385 | cwd: options.cwd ?? "/test/project", |
| 355 | 386 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 356 | 387 | stateDir, |
| 357 | staticDir: undefined, | |
| 388 | staticDir: options.staticDir, | |
| 358 | 389 | devUrl: options.devUrl ? new URL(options.devUrl) : undefined, |
| 359 | 390 | noBrowser: true, |
| 360 | 391 | authToken: options.authToken, |
| ... | ... | @@ -456,6 +487,36 @@ describe("WebSocket Server", () => { |
| 456 | 487 | expect(bytes).toEqual(Buffer.from("hello-attachment")); |
| 457 | 488 | }); |
| 458 | 489 | |
| 490 | it("serves static index for root path", async () => { | |
| 491 | const stateDir = makeTempDir("t3code-state-static-root-"); | |
| 492 | const staticDir = makeTempDir("t3code-static-root-"); | |
| 493 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8"); | |
| 494 | ||
| 495 | server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); | |
| 496 | const addr = server.address(); | |
| 497 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 498 | expect(port).toBeGreaterThan(0); | |
| 499 | ||
| 500 | const response = await fetch(`http://127.0.0.1:${port}/`); | |
| 501 | expect(response.status).toBe(200); | |
| 502 | expect(await response.text()).toContain("static-root"); | |
| 503 | }); | |
| 504 | ||
| 505 | it("rejects static path traversal attempts", async () => { | |
| 506 | const stateDir = makeTempDir("t3code-state-static-traversal-"); | |
| 507 | const staticDir = makeTempDir("t3code-static-traversal-"); | |
| 508 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8"); | |
| 509 | ||
| 510 | server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); | |
| 511 | const addr = server.address(); | |
| 512 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 513 | expect(port).toBeGreaterThan(0); | |
| 514 | ||
| 515 | const response = await requestPath(port, "/..%2f..%2fetc/passwd"); | |
| 516 | expect(response.statusCode).toBe(400); | |
| 517 | expect(response.body).toBe("Invalid static file path"); | |
| 518 | }); | |
| 519 | ||
| 459 | 520 | it("bootstraps the cwd project on startup when enabled", async () => { |
| 460 | 521 | server = await createTestServer({ |
| 461 | 522 | cwd: "/test/bootstrap-workspace", |
apps/server/src/wsServer.ts+36-3| ... | ... | @@ -315,17 +315,50 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 315 | 315 | return; |
| 316 | 316 | } |
| 317 | 317 | |
| 318 | let filePath = path.join(staticDir, url.pathname); | |
| 318 | const staticRoot = path.resolve(staticDir); | |
| 319 | const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; | |
| 320 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); | |
| 321 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); | |
| 322 | const staticRelativePath = path | |
| 323 | .normalize(rawStaticRelativePath) | |
| 324 | .replace(/^[/\\]+/, ""); | |
| 325 | const hasPathTraversalSegment = staticRelativePath.startsWith(".."); | |
| 326 | if ( | |
| 327 | staticRelativePath.length === 0 || | |
| 328 | hasRawLeadingParentSegment || | |
| 329 | hasPathTraversalSegment || | |
| 330 | staticRelativePath.includes("\0") | |
| 331 | ) { | |
| 332 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 333 | return; | |
| 334 | } | |
| 335 | ||
| 336 | const isWithinStaticRoot = (candidate: string) => | |
| 337 | candidate === staticRoot || | |
| 338 | candidate.startsWith( | |
| 339 | staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, | |
| 340 | ); | |
| 341 | ||
| 342 | let filePath = path.resolve(staticRoot, staticRelativePath); | |
| 343 | if (!isWithinStaticRoot(filePath)) { | |
| 344 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 345 | return; | |
| 346 | } | |
| 347 | ||
| 319 | 348 | const ext = path.extname(filePath); |
| 320 | 349 | if (!ext) { |
| 321 | filePath = path.join(filePath, "index.html"); | |
| 350 | filePath = path.resolve(filePath, "index.html"); | |
| 351 | if (!isWithinStaticRoot(filePath)) { | |
| 352 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | |
| 353 | return; | |
| 354 | } | |
| 322 | 355 | } |
| 323 | 356 | |
| 324 | 357 | const fileInfo = yield* fileSystem |
| 325 | 358 | .stat(filePath) |
| 326 | 359 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 327 | 360 | if (!fileInfo || fileInfo.type !== "File") { |
| 328 | const indexPath = path.join(staticDir, "index.html"); | |
| 361 | const indexPath = path.resolve(staticRoot, "index.html"); | |
| 329 | 362 | const indexData = yield* fileSystem |
| 330 | 363 | .readFile(indexPath) |
| 331 | 364 | .pipe(Effect.catch(() => Effect.succeed(null))); |
apps/web/src/store.ts+6-1| ... | ... | @@ -210,7 +210,12 @@ function resolveWsHttpOrigin(): string { |
| 210 | 210 | |
| 211 | 211 | try { |
| 212 | 212 | const wsUrl = new URL(wsCandidate); |
| 213 | const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:"; | |
| 213 | const protocol = | |
| 214 | wsUrl.protocol === "wss:" | |
| 215 | ? "https:" | |
| 216 | : wsUrl.protocol === "ws:" | |
| 217 | ? "http:" | |
| 218 | : wsUrl.protocol; | |
| 214 | 219 | return `${protocol}//${wsUrl.host}`; |
| 215 | 220 | } catch { |
| 216 | 221 | return window.location.origin; |