Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(node-fs): add support for multiple dirs #203

Merged
merged 8 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 57 additions & 42 deletions src/storage/node-fs.ts
Original file line number Diff line number Diff line change
@@ -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`,

Check warning on line 20 in src/storage/node-fs.ts

View check run for this annotation

Codecov / codecov/patch

src/storage/node-fs.ts#L18-L20

Added lines #L18 - L20 were not covered by tests
});
}
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;

Check warning on line 40 in src/storage/node-fs.ts

View check run for this annotation

Codecov / codecov/patch

src/storage/node-fs.ts#L39-L40

Added lines #L39 - L40 were not covered by tests
}
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}`,

Check warning on line 54 in src/storage/node-fs.ts

View check run for this annotation

Codecov / codecov/patch

src/storage/node-fs.ts#L52-L54

Added lines #L52 - L54 were not covered by tests
});
}
}
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);

Check warning on line 68 in src/storage/node-fs.ts

View check run for this annotation

Codecov / codecov/patch

src/storage/node-fs.ts#L68

Added line #L68 was not covered by tests
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();
},
};
}
Expand All @@ -85,3 +92,11 @@
}
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));
}
Binary file added test/assets2/bliss.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/assets2/unjs.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions test/fs-dirs.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});