diff --git a/src/storage/node-fs.ts b/src/storage/node-fs.ts index a8bdc65..2124077 100644 --- a/src/storage/node-fs.ts +++ b/src/storage/node-fs.ts @@ -1,72 +1,79 @@ -import type { Stats } from "node:fs"; import { resolve, parse, join } from "pathe"; import { createError } from "h3"; import { cachedPromise, getEnv } from "../utils"; import type { IPXStorage } from "../types"; export type NodeFSSOptions = { - dir?: string; + dir?: string | string[]; maxAge?: number; }; export function ipxFSStorage(_options: NodeFSSOptions = {}): IPXStorage { - const rootDir = resolve(_options.dir || getEnv("IPX_FS_DIR") || "."); + const dirs = resolveDirs(_options.dir); const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE"); - const _resolve = (id: string) => { - const resolved = join(rootDir, id); - if (!isValidPath(resolved) || !resolved.startsWith(rootDir)) { + const _getFS = cachedPromise(() => + import("node:fs/promises").catch(() => { throw createError({ - statusCode: 403, - statusText: `IPX_FORBIDDEN_PATH`, - message: `Forbidden path: ${id}`, + statusCode: 500, + statusText: `IPX_FILESYSTEM_ERROR`, + message: `Failed to resolve filesystem module`, }); - } - return resolved; - }; - - const _getFS = cachedPromise(() => import("node:fs/promises")); - - return { - name: "ipx:node-fs", - async getMeta(id) { - const fsPath = _resolve(id); + }), + ); - let stats: Stats; + const resolveFile = async (id: string) => { + const fs = await _getFS(); + for (const dir of dirs) { + const filePath = join(dir, id); + if (!isValidPath(filePath) || !filePath.startsWith(dir)) { + throw createError({ + statusCode: 403, + statusText: `IPX_FORBIDDEN_PATH`, + message: `Forbidden path: ${id}`, + }); + } try { - const fs = await _getFS(); - stats = await fs.stat(fsPath); + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + // Keep looking in other dirs we are looking for a file! + continue; + } + return { + stats, + read: () => fs.readFile(filePath), + }; } catch (error: any) { - throw error.code === "ENOENT" - ? createError({ - statusCode: 404, - statusText: `IPX_FILE_NOT_FOUND`, - message: `File not found: ${id}`, - }) - : createError({ - statusCode: 403, - statusText: `IPX_FORBIDDEN_FILE`, - message: `File access forbidden: (${error.code}) ${id}`, - }); - } - if (!stats.isFile()) { + if (error.code === "ENOENT") { + // Keep looking in other dirs + continue; + } throw createError({ - statusCode: 400, - statusText: `IPX_INVALID_FILE`, - message: `Path should be a file: ${id}`, + statusCode: 403, + statusText: `IPX_FORBIDDEN_FILE`, + message: `Cannot access file: ${id}`, }); } + } + throw createError({ + statusCode: 404, + statusText: `IPX_FILE_NOT_FOUND`, + message: `File not found: ${id}`, + }); + }; + return { + name: "ipx:node-fs", + async getMeta(id) { + const { stats } = await resolveFile(id); return { mtime: stats.mtime, maxAge, }; }, async getData(id) { - const fsPath = _resolve(id); - const fs = await _getFS(); - const contents = await fs.readFile(fsPath); - return contents; + const { read } = await resolveFile(id); + return read(); }, }; } @@ -85,3 +92,11 @@ function isValidPath(fp: string) { } return true; } + +function resolveDirs(dirs?: string | string[]) { + if (!dirs || !Array.isArray(dirs)) { + const dir = resolve(dirs || getEnv("IPX_FS_DIR") || "."); + return [dir]; + } + return dirs.map((dirs) => resolve(dirs)); +} diff --git a/test/assets2/bliss.jpg b/test/assets2/bliss.jpg new file mode 100644 index 0000000..1cda9a5 Binary files /dev/null and b/test/assets2/bliss.jpg differ diff --git a/test/assets2/unjs.jpg b/test/assets2/unjs.jpg new file mode 100644 index 0000000..82405a2 Binary files /dev/null and b/test/assets2/unjs.jpg differ diff --git a/test/fs-dirs.test.ts b/test/fs-dirs.test.ts new file mode 100644 index 0000000..bc982aa --- /dev/null +++ b/test/fs-dirs.test.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from "node:url"; +import { describe, it, expect, beforeAll } from "vitest"; +import { IPX, createIPX, ipxFSStorage } from "../src"; + +describe("ipx: fs with multiple dirs", () => { + let ipx: IPX; + + beforeAll(() => { + ipx = createIPX({ + storage: ipxFSStorage({ + dir: ["assets", "assets2"].map((d) => + fileURLToPath(new URL(d, import.meta.url)), + ), + }), + }); + }); + + it("local file: 1st layer", async () => { + const source = await ipx("giphy.gif"); + const { data, format } = await source.process(); + expect(data).toBeInstanceOf(Buffer); + expect(format).toBe("gif"); + }); + + it("local file: 2nd layer", async () => { + const source = await ipx("unjs.jpg"); + const { data, format } = await source.process(); + expect(data).toBeInstanceOf(Buffer); + expect(format).toBe("jpeg"); + }); + + it("local file: priority", async () => { + const source = await ipx("bliss.jpg"); + const { data, format, meta } = await source.process(); + expect(data).toBeInstanceOf(Buffer); + expect(format).toBe("jpeg"); + expect(meta?.height).toBe(2160); + }); + + it("error: not found", async () => { + const source = await ipx("unknown.png"); + await expect(() => source.process()).rejects.toThrowError( + "File not found: /unknown.png", + ); + }); + + it("error: forbidden path", async () => { + const source = await ipx("*.png"); + await expect(() => source.process()).rejects.toThrowError( + "Forbidden path: /*.png", + ); + }); +});