diff --git a/lib/db/db-client.ts b/lib/db/db-client.ts index 8e15843..977b5dd 100644 --- a/lib/db/db-client.ts +++ b/lib/db/db-client.ts @@ -1,7 +1,12 @@ import { createStore } from "zustand/vanilla" import { hoist } from "zustand-hoist" import { combine } from "zustand/middleware" -import { databaseSchema, type File, type FileServerEvent } from "./schema.ts" +import { + databaseSchema, + type File, + type FileProxy, + type FileServerEvent, +} from "./schema.ts" import { normalizePath } from "../utils/normalize-path" export const createDatabase = () => { @@ -203,4 +208,56 @@ const initializer = combine(databaseSchema.parse({}), (set, get) => ({ events: [], })) }, + + createFileProxy: ( + proxy: Omit, + ): FileProxy => { + let newProxy: FileProxy + set((state) => { + newProxy = { + ...proxy, + file_proxy_id: state.idCounter.toString(), + created_at: new Date().toISOString(), + } as FileProxy + return { + file_proxies: [...state.file_proxies, newProxy], + idCounter: state.idCounter + 1, + } + }) + return newProxy! + }, + + getFileProxy: (query: { + file_proxy_id?: string + matching_pattern?: string + }): FileProxy | undefined => { + const state = get() + return state.file_proxies.find( + (p) => + (query.file_proxy_id && p.file_proxy_id === query.file_proxy_id) || + (query.matching_pattern && + p.matching_pattern === query.matching_pattern), + ) + }, + + listFileProxies: (): FileProxy[] => { + return get().file_proxies + }, + + matchFileProxy: (file_path: string): FileProxy | undefined => { + const state = get() + const normalizedPath = normalizePath(file_path) + + for (const proxy of state.file_proxies) { + const pattern = proxy.matching_pattern + // Pattern format: "prefix/*" - match anything starting with "prefix/" + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1) // Remove the "*" to get "prefix/" + if (normalizedPath.startsWith(prefix)) { + return proxy + } + } + } + return undefined + }, })) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6bfae67..478a70b 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -32,9 +32,28 @@ export const eventSchema = z.object({ }) export type FileServerEvent = z.infer +export const fileProxySchema = z.discriminatedUnion("proxy_type", [ + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("disk"), + disk_path: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("http"), + http_target_url: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), +]) +export type FileProxy = z.infer + export const databaseSchema = z.object({ idCounter: z.number().default(0), files: z.array(fileSchema).default([]), events: z.array(eventSchema).default([]), + file_proxies: z.array(fileProxySchema).default([]), }) export type DatabaseSchema = z.infer diff --git a/lib/utils/resolve-file-proxy.ts b/lib/utils/resolve-file-proxy.ts new file mode 100644 index 0000000..3145b4e --- /dev/null +++ b/lib/utils/resolve-file-proxy.ts @@ -0,0 +1,112 @@ +import type { FileProxy } from "../db/schema" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { normalizePath } from "./normalize-path" + +export async function resolveFileProxy( + proxy: FileProxy, + file_path: string, +): Promise { + const normalizedPath = normalizePath(file_path) + const pattern = proxy.matching_pattern + + // Extract the relative path after the pattern prefix + // Pattern: "prefix/*" -> prefix is "prefix/" + const prefix = pattern.slice(0, -1) // Remove "*" to get "prefix/" + const relativePath = normalizedPath.slice(prefix.length) + + if (proxy.proxy_type === "disk") { + return resolveDiskProxy(proxy.disk_path, relativePath) + } else { + return resolveHttpProxy(proxy.http_target_url, relativePath) + } +} + +async function resolveDiskProxy( + diskPath: string, + relativePath: string, +): Promise { + const fullPath = join(diskPath, relativePath) + + try { + const content = await readFile(fullPath) + const fileName = relativePath.split("/").pop() || "file" + const contentType = getContentType(fileName) + + return new Response(content, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${fileName}"`, + "Content-Length": content.byteLength.toString(), + }, + }) + } catch (error: any) { + if (error.code === "ENOENT") { + return new Response("File not found", { status: 404 }) + } + console.error("Disk proxy error:", error) + return new Response("Failed to read file from disk", { status: 500 }) + } +} + +async function resolveHttpProxy( + httpTargetUrl: string, + relativePath: string, +): Promise { + // Ensure the URL doesn't have double slashes + const baseUrl = httpTargetUrl.endsWith("/") + ? httpTargetUrl.slice(0, -1) + : httpTargetUrl + const targetUrl = `${baseUrl}/${relativePath}` + + try { + const response = await fetch(targetUrl) + + if (!response.ok) { + return new Response(`HTTP proxy returned status ${response.status}`, { + status: response.status, + }) + } + + // Pass through the response body and relevant headers + const headers = new Headers() + const contentType = response.headers.get("Content-Type") + if (contentType) { + headers.set("Content-Type", contentType) + } + const contentLength = response.headers.get("Content-Length") + if (contentLength) { + headers.set("Content-Length", contentLength) + } + const fileName = relativePath.split("/").pop() || "file" + headers.set("Content-Disposition", `attachment; filename="${fileName}"`) + + return new Response(response.body, { + status: response.status, + headers, + }) + } catch (error) { + console.error("HTTP proxy error:", error) + return new Response("Failed to fetch file from HTTP proxy", { status: 502 }) + } +} + +function getContentType(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase() + const mimeTypes: Record = { + txt: "text/plain", + html: "text/html", + css: "text/css", + js: "application/javascript", + json: "application/json", + xml: "application/xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + pdf: "application/pdf", + zip: "application/zip", + } + return mimeTypes[ext || ""] || "application/octet-stream" +} diff --git a/routes/file_proxies/create.ts b/routes/file_proxies/create.ts new file mode 100644 index 0000000..907314c --- /dev/null +++ b/routes/file_proxies/create.ts @@ -0,0 +1,71 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const fileProxyInputSchema = z.discriminatedUnion("proxy_type", [ + z.object({ + proxy_type: z.literal("disk"), + disk_path: z.string(), + matching_pattern: z.string().regex(/^.+\/\*$/, { + message: "matching_pattern must end with /*", + }), + }), + z.object({ + proxy_type: z.literal("http"), + http_target_url: z.string().url(), + matching_pattern: z.string().regex(/^.+\/\*$/, { + message: "matching_pattern must end with /*", + }), + }), +]) + +const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [ + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("disk"), + disk_path: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("http"), + http_target_url: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["POST"], + jsonBody: fileProxyInputSchema, + jsonResponse: z.union([ + z.object({ + file_proxy: fileProxyOutputSchema, + }), + z.object({ + error: z.object({ + message: z.string(), + }), + }), + ]), +})((req, ctx) => { + const proxyInput = req.jsonBody + + // Check if a proxy with the same matching_pattern already exists + const existingProxy = ctx.db.getFileProxy({ + matching_pattern: proxyInput.matching_pattern, + }) + if (existingProxy) { + return ctx.json( + { + error: { + message: `A file proxy with matching_pattern "${proxyInput.matching_pattern}" already exists`, + }, + }, + { status: 400 }, + ) + } + + const file_proxy = ctx.db.createFileProxy(proxyInput) + return ctx.json({ file_proxy }) +}) diff --git a/routes/file_proxies/get.ts b/routes/file_proxies/get.ts new file mode 100644 index 0000000..1b24552 --- /dev/null +++ b/routes/file_proxies/get.ts @@ -0,0 +1,35 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [ + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("disk"), + disk_path: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("http"), + http_target_url: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["GET"], + queryParams: z.object({ + file_proxy_id: z.string().optional(), + matching_pattern: z.string().optional(), + }), + jsonResponse: z.object({ + file_proxy: fileProxyOutputSchema.nullable(), + }), +})((req, ctx) => { + const { file_proxy_id, matching_pattern } = req.query + const file_proxy = + ctx.db.getFileProxy({ file_proxy_id, matching_pattern }) ?? null + return ctx.json({ file_proxy }) +}) diff --git a/routes/file_proxies/list.ts b/routes/file_proxies/list.ts new file mode 100644 index 0000000..c6babbc --- /dev/null +++ b/routes/file_proxies/list.ts @@ -0,0 +1,30 @@ +import { withRouteSpec } from "lib/middleware/with-winter-spec" +import { z } from "zod" + +const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [ + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("disk"), + disk_path: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), + z.object({ + file_proxy_id: z.string(), + proxy_type: z.literal("http"), + http_target_url: z.string(), + matching_pattern: z.string(), + created_at: z.string(), + }), +]) + +export default withRouteSpec({ + methods: ["GET"], + jsonResponse: z.object({ + file_proxies: z.array(fileProxyOutputSchema), + }), +})((req, ctx) => { + return ctx.json({ + file_proxies: ctx.db.listFileProxies(), + }) +}) diff --git a/routes/files/download.ts b/routes/files/download.ts index 132f9bd..4bb47d3 100644 --- a/routes/files/download.ts +++ b/routes/files/download.ts @@ -4,6 +4,7 @@ import { decodeBase64ToUint8Array, uint8ArrayToArrayBuffer, } from "lib/utils/decode-base64" +import { resolveFileProxy } from "lib/utils/resolve-file-proxy" export default withRouteSpec({ methods: ["GET"], @@ -11,11 +12,18 @@ export default withRouteSpec({ file_id: z.string().optional(), file_path: z.string().optional(), }), -})((req, ctx) => { +})(async (req, ctx) => { const { file_id, file_path } = req.query const file = ctx.db.getFile({ file_id, file_path }) if (!file) { + // Check if there's a matching proxy (only for file_path queries) + if (file_path) { + const proxy = ctx.db.matchFileProxy(file_path) + if (proxy) { + return resolveFileProxy(proxy, file_path) + } + } return new Response("File not found", { status: 404 }) } diff --git a/routes/files/download/[[file_path]].ts b/routes/files/download/[[...file_path]].ts similarity index 64% rename from routes/files/download/[[file_path]].ts rename to routes/files/download/[[...file_path]].ts index 1ae80cd..3e8fa02 100644 --- a/routes/files/download/[[file_path]].ts +++ b/routes/files/download/[[...file_path]].ts @@ -4,17 +4,27 @@ import { decodeBase64ToUint8Array, uint8ArrayToArrayBuffer, } from "lib/utils/decode-base64" +import { resolveFileProxy } from "lib/utils/resolve-file-proxy" export default withRouteSpec({ methods: ["GET"], pathParams: z.object({ - file_path: z.string(), + file_path: z.union([z.string(), z.array(z.string())]), }), -})((req, ctx) => { - const { file_path } = req.routeParams as { file_path: string } - const file = ctx.db.getFile({ file_path: `/${file_path}` }) +})(async (req, ctx) => { + const { file_path } = req.routeParams as { file_path: string | string[] } + const joinedFilePath = Array.isArray(file_path) + ? file_path.join("/") + : file_path + const normalizedPath = `/${joinedFilePath}` + const file = ctx.db.getFile({ file_path: normalizedPath }) if (!file) { + // Check if there's a matching proxy + const proxy = ctx.db.matchFileProxy(normalizedPath) + if (proxy) { + return resolveFileProxy(proxy, normalizedPath) + } return new Response("File not found", { status: 404 }) } diff --git a/routes/files/static/[[...file_path]].ts b/routes/files/static/[[...file_path]].ts index 41fdaa1..27f2910 100644 --- a/routes/files/static/[[...file_path]].ts +++ b/routes/files/static/[[...file_path]].ts @@ -4,6 +4,7 @@ import { decodeBase64ToUint8Array, uint8ArrayToArrayBuffer, } from "lib/utils/decode-base64" +import { resolveFileProxy } from "lib/utils/resolve-file-proxy" const getMimeType = (filePath: string): string => { const ext = filePath.split(".").pop()?.toLowerCase() @@ -58,7 +59,7 @@ export default withRouteSpec({ pathParams: z.object({ file_path: z.union([z.string(), z.array(z.string())]), }), -})((req, ctx) => { +})(async (req, ctx) => { const { file_path } = req.routeParams as { file_path: string | string[] } @@ -67,9 +68,15 @@ export default withRouteSpec({ ? file_path.join("/") : file_path - const file = ctx.db.getFile({ file_path: `/${joinedFilePath}` }) + const normalizedPath = `/${joinedFilePath}` + const file = ctx.db.getFile({ file_path: normalizedPath }) if (!file) { + // Check if there's a matching proxy + const proxy = ctx.db.matchFileProxy(normalizedPath) + if (proxy) { + return resolveFileProxy(proxy, normalizedPath) + } return new Response("File not found", { status: 404 }) } diff --git a/tests/routes/file-proxy01.test.ts b/tests/routes/file-proxy01.test.ts new file mode 100644 index 0000000..a3cd999 --- /dev/null +++ b/tests/routes/file-proxy01.test.ts @@ -0,0 +1,95 @@ +import { test, expect } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" + +test("file proxy CRUD operations", async () => { + const { axios } = await getTestServer() + + // Create a disk proxy + const createDiskRes = await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: "/tmp/test-files", + matching_pattern: "local/*", + }) + expect(createDiskRes.status).toBe(200) + expect(createDiskRes.data.file_proxy.proxy_type).toBe("disk") + expect(createDiskRes.data.file_proxy.disk_path).toBe("/tmp/test-files") + expect(createDiskRes.data.file_proxy.matching_pattern).toBe("local/*") + expect(createDiskRes.data.file_proxy.file_proxy_id).toBeDefined() + expect(createDiskRes.data.file_proxy.created_at).toBeDefined() + + const diskProxyId = createDiskRes.data.file_proxy.file_proxy_id + + // Create an HTTP proxy + const createHttpRes = await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: "https://example.com/files", + matching_pattern: "remote/*", + }) + expect(createHttpRes.status).toBe(200) + expect(createHttpRes.data.file_proxy.proxy_type).toBe("http") + expect(createHttpRes.data.file_proxy.http_target_url).toBe( + "https://example.com/files", + ) + expect(createHttpRes.data.file_proxy.matching_pattern).toBe("remote/*") + + const httpProxyId = createHttpRes.data.file_proxy.file_proxy_id + + // Get proxy by ID + const getByIdRes = await axios.get("/file_proxies/get", { + params: { file_proxy_id: diskProxyId }, + }) + expect(getByIdRes.status).toBe(200) + expect(getByIdRes.data.file_proxy.file_proxy_id).toBe(diskProxyId) + expect(getByIdRes.data.file_proxy.proxy_type).toBe("disk") + + // Get proxy by matching_pattern + const getByPatternRes = await axios.get("/file_proxies/get", { + params: { matching_pattern: "remote/*" }, + }) + expect(getByPatternRes.status).toBe(200) + expect(getByPatternRes.data.file_proxy.file_proxy_id).toBe(httpProxyId) + expect(getByPatternRes.data.file_proxy.proxy_type).toBe("http") + + // Get non-existent proxy + const getNonExistentRes = await axios.get("/file_proxies/get", { + params: { file_proxy_id: "non-existent" }, + }) + expect(getNonExistentRes.status).toBe(200) + expect(getNonExistentRes.data.file_proxy).toBeNull() + + // List all proxies + const listRes = await axios.get("/file_proxies/list") + expect(listRes.status).toBe(200) + expect(listRes.data.file_proxies).toHaveLength(2) + + const diskProxy = listRes.data.file_proxies.find( + (p: any) => p.proxy_type === "disk", + ) + const httpProxy = listRes.data.file_proxies.find( + (p: any) => p.proxy_type === "http", + ) + expect(diskProxy).toBeDefined() + expect(httpProxy).toBeDefined() +}) + +test("file proxy create validation - duplicate pattern", async () => { + const { axios } = await getTestServer() + + // Create first proxy + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: "/tmp/test", + matching_pattern: "duplicate/*", + }) + + // Duplicate matching_pattern should fail with 400 + await expect( + axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: "https://example.com", + matching_pattern: "duplicate/*", + }), + ).rejects.toMatchObject({ + status: 400, + }) +}) diff --git a/tests/routes/file-proxy02.test.ts b/tests/routes/file-proxy02.test.ts new file mode 100644 index 0000000..c1314e8 --- /dev/null +++ b/tests/routes/file-proxy02.test.ts @@ -0,0 +1,126 @@ +import { test, expect } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" + +test("disk proxy file resolution", async () => { + const { axios } = await getTestServer() + + // Create a temp directory with test files + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-test-")) + + try { + // Create test files in the temp directory + await writeFile(join(tempDir, "test.txt"), "Hello from disk proxy!") + await writeFile(join(tempDir, "data.json"), '{"key": "value"}') + + // Create a subdirectory with a file + await mkdir(join(tempDir, "subdir")) + await writeFile( + join(tempDir, "subdir", "nested.txt"), + "Nested file content", + ) + + // Create a disk proxy pointing to the temp directory + const createRes = await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "disk-test/*", + }) + expect(createRes.status).toBe(200) + + // Test downloading a file through the proxy + const downloadRes = await axios.get("/files/download/disk-test/test.txt") + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Hello from disk proxy!") + expect(downloadRes.headers.get("content-type")).toBe("text/plain") + + // Test downloading JSON file + const jsonRes = await axios.get("/files/download/disk-test/data.json") + expect(jsonRes.status).toBe(200) + expect(jsonRes.data).toEqual({ key: "value" }) + expect(jsonRes.headers.get("content-type")).toBe("application/json") + + // Test downloading nested file + const nestedRes = await axios.get( + "/files/download/disk-test/subdir/nested.txt", + ) + expect(nestedRes.status).toBe(200) + expect(nestedRes.data).toBe("Nested file content") + + // Test 404 for non-existent file + await expect( + axios.get("/files/download/disk-test/non-existent.txt"), + ).rejects.toMatchObject({ + status: 404, + }) + } finally { + // Clean up temp directory + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("disk proxy with query param download", async () => { + const { axios } = await getTestServer() + + // Create a temp directory with a test file + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-query-test-")) + + try { + await writeFile( + join(tempDir, "query-test.txt"), + "Query param download test", + ) + + // Create a disk proxy + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "query-disk/*", + }) + + // Test downloading via query parameter + const downloadRes = await axios.get("/files/download", { + params: { file_path: "/query-disk/query-test.txt" }, + }) + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Query param download test") + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("disk proxy binary file", async () => { + const { axios } = await getTestServer() + + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-binary-test-")) + + try { + // Create a binary file + const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]) + await writeFile(join(tempDir, "binary.bin"), binaryData) + + // Create a disk proxy + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "binary-test/*", + }) + + // Test downloading binary file + const downloadRes = await axios.get( + "/files/download/binary-test/binary.bin", + { + responseType: "arrayBuffer", + }, + ) + expect(downloadRes.status).toBe(200) + expect(new Uint8Array(downloadRes.data)).toEqual(binaryData) + expect(downloadRes.headers.get("content-type")).toBe( + "application/octet-stream", + ) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/tests/routes/file-proxy03.test.ts b/tests/routes/file-proxy03.test.ts new file mode 100644 index 0000000..0596f78 --- /dev/null +++ b/tests/routes/file-proxy03.test.ts @@ -0,0 +1,107 @@ +import { test, expect } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" +import { Buffer } from "node:buffer" + +test("http proxy file resolution", async () => { + const { axios, url } = await getTestServer() + + // First, create a file on the server that we'll proxy to + await axios.post("/files/upsert", { + file_path: "/source/test-file.txt", + text_content: "Content served via HTTP proxy", + }) + + await axios.post("/files/upsert", { + file_path: "/source/data.json", + text_content: '{"proxied": true}', + }) + + // Create an HTTP proxy pointing to the server's static file endpoint + const createRes = await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: `${url}/files/static/source`, + matching_pattern: "http-test/*", + }) + expect(createRes.status).toBe(200) + expect(createRes.data.file_proxy.proxy_type).toBe("http") + + // Test downloading a file through the HTTP proxy + const downloadRes = await axios.get("/files/download/http-test/test-file.txt") + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Content served via HTTP proxy") + + // Test downloading JSON through proxy + const jsonRes = await axios.get("/files/download/http-test/data.json") + expect(jsonRes.status).toBe(200) + expect(jsonRes.data).toEqual({ proxied: true }) +}) + +test("http proxy 404 handling", async () => { + const { axios, url } = await getTestServer() + + // Create an HTTP proxy pointing to the server + await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: `${url}/files/static/nonexistent`, + matching_pattern: "http-404/*", + }) + + // Test that 404 is properly propagated + await expect( + axios.get("/files/download/http-404/missing.txt"), + ).rejects.toMatchObject({ + status: 404, + }) +}) + +test("http proxy with query param download", async () => { + const { axios, url } = await getTestServer() + + // Create source file + await axios.post("/files/upsert", { + file_path: "/query-source/file.txt", + text_content: "Query param HTTP proxy test", + }) + + // Create HTTP proxy + await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: `${url}/files/static/query-source`, + matching_pattern: "http-query/*", + }) + + // Test downloading via query parameter + const downloadRes = await axios.get("/files/download", { + params: { file_path: "/http-query/file.txt" }, + }) + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Query param HTTP proxy test") +}) + +test("http proxy binary file", async () => { + const { axios, url } = await getTestServer() + + // Create a binary file on the server + const binaryData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]) + const base64 = Buffer.from(binaryData).toString("base64") + await axios.post("/files/upsert", { + file_path: "/binary-source/image.png", + binary_content_b64: base64, + }) + + // Create HTTP proxy + await axios.post("/file_proxies/create", { + proxy_type: "http", + http_target_url: `${url}/files/static/binary-source`, + matching_pattern: "http-binary/*", + }) + + // Test downloading binary file through proxy + const downloadRes = await axios.get("/files/download/http-binary/image.png", { + responseType: "arrayBuffer", + }) + expect(downloadRes.status).toBe(200) + expect(new Uint8Array(downloadRes.data)).toEqual(binaryData) +}) diff --git a/tests/routes/file-proxy04.test.ts b/tests/routes/file-proxy04.test.ts new file mode 100644 index 0000000..4fae9de --- /dev/null +++ b/tests/routes/file-proxy04.test.ts @@ -0,0 +1,166 @@ +import { test, expect } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" + +test("database file takes precedence over proxy", async () => { + const { axios } = await getTestServer() + + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-precedence-")) + + try { + // Create a file on disk + await writeFile(join(tempDir, "test.txt"), "Disk content") + + // Create a disk proxy + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "precedence/*", + }) + + // Create a file in the database with the same path + await axios.post("/files/upsert", { + file_path: "/precedence/test.txt", + text_content: "Database content", + }) + + // Database file should take precedence + const downloadRes = await axios.get("/files/download/precedence/test.txt") + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Database content") + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("proxy pattern matching with deep paths", async () => { + const { axios } = await getTestServer() + + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-deep-")) + + try { + // Create deeply nested file + const { mkdir } = await import("node:fs/promises") + await mkdir(join(tempDir, "a", "b", "c"), { recursive: true }) + await writeFile( + join(tempDir, "a", "b", "c", "deep.txt"), + "Deep nested content", + ) + + // Create a disk proxy + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "deep/*", + }) + + // Test downloading deeply nested file + const downloadRes = await axios.get("/files/download/deep/a/b/c/deep.txt") + expect(downloadRes.status).toBe(200) + expect(downloadRes.data).toBe("Deep nested content") + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("multiple proxies with different patterns", async () => { + const { axios } = await getTestServer() + + const tempDir1 = await mkdtemp(join(tmpdir(), "file-proxy-multi1-")) + const tempDir2 = await mkdtemp(join(tmpdir(), "file-proxy-multi2-")) + + try { + // Create files in different directories + await writeFile(join(tempDir1, "file1.txt"), "Content from dir 1") + await writeFile(join(tempDir2, "file2.txt"), "Content from dir 2") + + // Create two different proxies + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir1, + matching_pattern: "multi1/*", + }) + + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir2, + matching_pattern: "multi2/*", + }) + + // Test that each proxy serves its own files + const res1 = await axios.get("/files/download/multi1/file1.txt") + expect(res1.data).toBe("Content from dir 1") + + const res2 = await axios.get("/files/download/multi2/file2.txt") + expect(res2.data).toBe("Content from dir 2") + + // Test that proxies don't cross + await expect( + axios.get("/files/download/multi1/file2.txt"), + ).rejects.toMatchObject({ + status: 404, + }) + + await expect( + axios.get("/files/download/multi2/file1.txt"), + ).rejects.toMatchObject({ + status: 404, + }) + } finally { + await rm(tempDir1, { recursive: true, force: true }) + await rm(tempDir2, { recursive: true, force: true }) + } +}) + +test("no proxy match returns 404", async () => { + const { axios } = await getTestServer() + + // Create a proxy with a specific pattern + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-nomatch-")) + + try { + await writeFile(join(tempDir, "exists.txt"), "File exists") + + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "specific/*", + }) + + // Path that doesn't match any proxy pattern should 404 + await expect( + axios.get("/files/download/unmatched/file.txt"), + ).rejects.toMatchObject({ + status: 404, + data: "File not found", + }) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) + +test("proxy pattern with leading slash normalization", async () => { + const { axios } = await getTestServer() + + const tempDir = await mkdtemp(join(tmpdir(), "file-proxy-slash-")) + + try { + await writeFile(join(tempDir, "normalized.txt"), "Normalized path content") + + // Create proxy with pattern (without leading slash in pattern) + await axios.post("/file_proxies/create", { + proxy_type: "disk", + disk_path: tempDir, + matching_pattern: "slash-test/*", + }) + + // Should work with leading slash in the request path + const res = await axios.get("/files/download/slash-test/normalized.txt") + expect(res.status).toBe(200) + expect(res.data).toBe("Normalized path content") + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +})