@porulle/adapter-local-storage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/package.json +49 -0
- package/src/index.ts +125 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @porulle/adapter-local-storage
|
|
2
|
+
|
|
3
|
+
`StorageAdapter` that writes to the local filesystem. Useful for development and single-node deployments. **Not for production** unless you've thought hard about backups, disk pressure, and multi-instance behavior — use S3 or R2 there.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineConfig } from "@porulle/core";
|
|
9
|
+
import { localStorageAdapter } from "@porulle/adapter-local-storage";
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
storage: localStorageAdapter({
|
|
13
|
+
basePath: "./.data/media",
|
|
14
|
+
baseUrl: "http://localhost:4000/assets",
|
|
15
|
+
}),
|
|
16
|
+
// …
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then serve the `basePath` directory at `baseUrl` (the example apps mount Hono's `serveStatic` for `/assets/*`).
|
|
21
|
+
|
|
22
|
+
## Path-traversal protection
|
|
23
|
+
|
|
24
|
+
Every key passes through `resolveSafePath()` which:
|
|
25
|
+
|
|
26
|
+
- Resolves the key against `basePath`
|
|
27
|
+
- Verifies the result stays inside `basePath`
|
|
28
|
+
- Rejects `..`, NUL bytes, absolute paths, and any input that would escape the storage root
|
|
29
|
+
|
|
30
|
+
This closed a CVE-class arbitrary-file-write surface (a key like `../../../../etc/passwd` would otherwise write outside the storage root). See `packages/core/test/` for the regression tests.
|
|
31
|
+
|
|
32
|
+
## What it implements
|
|
33
|
+
|
|
34
|
+
`upload`, `getUrl`, `getSignedUrl`, `delete`, `list` — all return `Result<T>`.
|
|
35
|
+
|
|
36
|
+
## See also
|
|
37
|
+
|
|
38
|
+
- [`SECURITY.md`](../../../SECURITY.md) — storage hardening
|
|
39
|
+
- `@porulle/adapter-s3`, `@porulle/adapter-r2` — production storage adapters
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type StorageAdapter } from "@porulle/core";
|
|
2
|
+
export interface LocalStorageAdapterOptions {
|
|
3
|
+
basePath: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function localStorageAdapter(options: LocalStorageAdapterOptions): StorageAdapter;
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAS1E,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAiCD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,cAAc,CA6EvF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { Err, Ok } from "@porulle/core";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a key against basePath and verifies the result stays inside
|
|
6
|
+
* basePath. Rejects path-traversal payloads like `../../etc/passwd`,
|
|
7
|
+
* absolute paths, and any other input that would escape the storage root.
|
|
8
|
+
*
|
|
9
|
+
* Without this guard an upload with key `../../something` would write
|
|
10
|
+
* outside the storage root (CVE-class: arbitrary file write).
|
|
11
|
+
*/
|
|
12
|
+
function resolveSafePath(basePath, key) {
|
|
13
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
14
|
+
throw new Error("Invalid storage key: empty");
|
|
15
|
+
}
|
|
16
|
+
// Reject NUL bytes, which can truncate path checks in some libc impls
|
|
17
|
+
if (key.includes("\0")) {
|
|
18
|
+
throw new Error("Invalid storage key: contains NUL byte");
|
|
19
|
+
}
|
|
20
|
+
const normalizedBase = resolve(basePath);
|
|
21
|
+
const candidate = resolve(normalizedBase, key);
|
|
22
|
+
// Candidate must be exactly basePath or a descendant; protect against
|
|
23
|
+
// boundary-collision (e.g. base=/data, candidate=/data-evil)
|
|
24
|
+
if (candidate !== normalizedBase &&
|
|
25
|
+
!candidate.startsWith(normalizedBase + sep)) {
|
|
26
|
+
throw new Error(`Invalid storage key: path escapes storage root (${key})`);
|
|
27
|
+
}
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
30
|
+
export function localStorageAdapter(options) {
|
|
31
|
+
const baseUrl = options.baseUrl ?? "http://localhost:3000/assets";
|
|
32
|
+
async function ensureParent(path) {
|
|
33
|
+
await mkdir(dirname(path), { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
providerId: "local-storage",
|
|
37
|
+
async upload(key, data, contentType) {
|
|
38
|
+
const path = resolveSafePath(options.basePath, key);
|
|
39
|
+
await ensureParent(path);
|
|
40
|
+
if (data instanceof ReadableStream) {
|
|
41
|
+
const reader = data.getReader();
|
|
42
|
+
const chunks = [];
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
if (value)
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
}
|
|
50
|
+
const merged = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
51
|
+
await writeFile(path, merged);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
await writeFile(path, Buffer.from(data));
|
|
55
|
+
}
|
|
56
|
+
return Ok({
|
|
57
|
+
key,
|
|
58
|
+
url: `${baseUrl}/${key}`,
|
|
59
|
+
contentType,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
async getUrl(key) {
|
|
63
|
+
return Ok(`${baseUrl}/${key}`);
|
|
64
|
+
},
|
|
65
|
+
async getSignedUrl(key, expiresIn) {
|
|
66
|
+
return Ok(`${baseUrl}/${key}?expiresIn=${expiresIn}`);
|
|
67
|
+
},
|
|
68
|
+
async delete(key) {
|
|
69
|
+
const path = resolveSafePath(options.basePath, key);
|
|
70
|
+
await rm(path, { force: true });
|
|
71
|
+
return Ok(undefined);
|
|
72
|
+
},
|
|
73
|
+
async list(prefix) {
|
|
74
|
+
const folder = resolveSafePath(options.basePath, prefix);
|
|
75
|
+
try {
|
|
76
|
+
const files = await readdir(folder, { recursive: true });
|
|
77
|
+
const output = [];
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const filePath = join(folder, file);
|
|
80
|
+
const info = await stat(filePath);
|
|
81
|
+
if (info.isDirectory())
|
|
82
|
+
continue;
|
|
83
|
+
const content = await readFile(filePath);
|
|
84
|
+
output.push({
|
|
85
|
+
key: file,
|
|
86
|
+
url: `${baseUrl}/${prefix}/${file}`,
|
|
87
|
+
contentType: "application/octet-stream",
|
|
88
|
+
size: content.byteLength,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return Ok(output);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return Err({
|
|
95
|
+
code: "LIST_FAILED",
|
|
96
|
+
message: error instanceof Error ? error.message : "Failed to list files.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@porulle/adapter-local-storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"bun": "./src/index.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
|
|
15
|
+
"check-types": "tsc --noEmit",
|
|
16
|
+
"lint": "eslint . --max-warnings 1000",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@porulle/core": "workspace:*"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@repo/eslint-config": "*",
|
|
24
|
+
"@repo/typescript-config": "*",
|
|
25
|
+
"@types/node": "^24.5.2",
|
|
26
|
+
"eslint": "^9.39.1",
|
|
27
|
+
"typescript": "5.9.2",
|
|
28
|
+
"vitest": "^3.2.4"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"description": "StorageAdapter that writes to the local filesystem. Useful for development and single-node deployments. **Not for production** unless you've thought hard about backups, disk pressure, and multi-instan",
|
|
39
|
+
"homepage": "https://porulle-docs.vercel.app",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/asyncdotengineering/porulle/issues"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/asyncdotengineering/porulle.git",
|
|
46
|
+
"directory": "packages/adapters/adapter-local-storage"
|
|
47
|
+
},
|
|
48
|
+
"author": "Porulle contributors"
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { Err, Ok, type Result, type StorageAdapter } from "@porulle/core";
|
|
4
|
+
|
|
5
|
+
interface StoredFile {
|
|
6
|
+
key: string;
|
|
7
|
+
url: string;
|
|
8
|
+
contentType: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LocalStorageAdapterOptions {
|
|
13
|
+
basePath: string;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a key against basePath and verifies the result stays inside
|
|
19
|
+
* basePath. Rejects path-traversal payloads like `../../etc/passwd`,
|
|
20
|
+
* absolute paths, and any other input that would escape the storage root.
|
|
21
|
+
*
|
|
22
|
+
* Without this guard an upload with key `../../something` would write
|
|
23
|
+
* outside the storage root (CVE-class: arbitrary file write).
|
|
24
|
+
*/
|
|
25
|
+
function resolveSafePath(basePath: string, key: string): string {
|
|
26
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
27
|
+
throw new Error("Invalid storage key: empty");
|
|
28
|
+
}
|
|
29
|
+
// Reject NUL bytes, which can truncate path checks in some libc impls
|
|
30
|
+
if (key.includes("\0")) {
|
|
31
|
+
throw new Error("Invalid storage key: contains NUL byte");
|
|
32
|
+
}
|
|
33
|
+
const normalizedBase = resolve(basePath);
|
|
34
|
+
const candidate = resolve(normalizedBase, key);
|
|
35
|
+
// Candidate must be exactly basePath or a descendant; protect against
|
|
36
|
+
// boundary-collision (e.g. base=/data, candidate=/data-evil)
|
|
37
|
+
if (
|
|
38
|
+
candidate !== normalizedBase &&
|
|
39
|
+
!candidate.startsWith(normalizedBase + sep)
|
|
40
|
+
) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Invalid storage key: path escapes storage root (${key})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function localStorageAdapter(options: LocalStorageAdapterOptions): StorageAdapter {
|
|
49
|
+
const baseUrl = options.baseUrl ?? "http://localhost:3000/assets";
|
|
50
|
+
|
|
51
|
+
async function ensureParent(path: string): Promise<void> {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
providerId: "local-storage",
|
|
57
|
+
async upload(key: string, data: ArrayBuffer | ReadableStream, contentType: string): Promise<Result<StoredFile>> {
|
|
58
|
+
const path = resolveSafePath(options.basePath, key);
|
|
59
|
+
await ensureParent(path);
|
|
60
|
+
|
|
61
|
+
if (data instanceof ReadableStream) {
|
|
62
|
+
const reader = data.getReader();
|
|
63
|
+
const chunks: Uint8Array[] = [];
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) break;
|
|
67
|
+
if (value) chunks.push(value);
|
|
68
|
+
}
|
|
69
|
+
const merged = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
70
|
+
await writeFile(path, merged);
|
|
71
|
+
} else {
|
|
72
|
+
await writeFile(path, Buffer.from(data));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Ok({
|
|
76
|
+
key,
|
|
77
|
+
url: `${baseUrl}/${key}`,
|
|
78
|
+
contentType,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async getUrl(key: string): Promise<Result<string>> {
|
|
83
|
+
return Ok(`${baseUrl}/${key}`);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async getSignedUrl(key: string, expiresIn: number): Promise<Result<string>> {
|
|
87
|
+
return Ok(`${baseUrl}/${key}?expiresIn=${expiresIn}`);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async delete(key: string): Promise<Result<void>> {
|
|
91
|
+
const path = resolveSafePath(options.basePath, key);
|
|
92
|
+
await rm(path, { force: true });
|
|
93
|
+
return Ok(undefined);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async list(prefix: string): Promise<Result<StoredFile[]>> {
|
|
97
|
+
const folder = resolveSafePath(options.basePath, prefix);
|
|
98
|
+
try {
|
|
99
|
+
const files = await readdir(folder, { recursive: true }) as string[];
|
|
100
|
+
const output: StoredFile[] = [];
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const filePath = join(folder, file);
|
|
104
|
+
const info = await stat(filePath);
|
|
105
|
+
if (info.isDirectory()) continue;
|
|
106
|
+
|
|
107
|
+
const content = await readFile(filePath);
|
|
108
|
+
output.push({
|
|
109
|
+
key: file,
|
|
110
|
+
url: `${baseUrl}/${prefix}/${file}`,
|
|
111
|
+
contentType: "application/octet-stream",
|
|
112
|
+
size: content.byteLength,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Ok(output);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return Err({
|
|
119
|
+
code: "LIST_FAILED",
|
|
120
|
+
message: error instanceof Error ? error.message : "Failed to list files.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|