@rezwanul7/storage 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +207 -2
- package/dist/fileRules.types-CGXBnwzd.d.cts +11 -0
- package/dist/fileRules.types-CGXBnwzd.d.ts +11 -0
- package/dist/index.cjs +475 -0
- package/dist/index.d.cts +116 -0
- package/dist/index.d.ts +116 -0
- package/dist/index.js +464 -0
- package/dist/server/index.cjs +545 -0
- package/dist/server/index.d.cts +269 -0
- package/dist/server/index.d.ts +269 -0
- package/dist/server/index.js +535 -0
- package/dist/zod/index.cjs +62 -0
- package/dist/zod/index.d.cts +22 -0
- package/dist/zod/index.d.ts +22 -0
- package/dist/zod/index.js +59 -0
- package/package.json +84 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rezwanul Islam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,208 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rezwanul7/storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Check files that users upload and keep them with a Provider of your choice. The server reads each file's bytes to
|
|
4
|
+
find its real type, rejects dangerous and disguised files, checks your Rules, and stores the Upload through a Storage.
|
|
5
|
+
The same Rules give the browser early feedback before anything is sent. The terms are defined in the
|
|
6
|
+
[glossary](./GLOSSARY.md).
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @rezwanul7/storage
|
|
12
|
+
# or
|
|
13
|
+
pnpm add @rezwanul7/storage
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`zod` is an optional peer dependency, used only by the form schemas. Install it if you import
|
|
17
|
+
`@rezwanul7/storage/zod`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add zod
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The supported range is `^4.0.0`.
|
|
24
|
+
|
|
25
|
+
## Entry points
|
|
26
|
+
|
|
27
|
+
| Import | Contents | Runs in |
|
|
28
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------- | ---------------- |
|
|
29
|
+
| `@rezwanul7/storage` | `SharedFileRules`, file sizes, `detectFileTypeFromName`, `getFileExtension`, File categories | Browser and Node |
|
|
30
|
+
| `@rezwanul7/storage/zod` | `createSingleFileZodSchema`, `createMultiFileZodSchema` (needs `zod` installed) | Browser and Node |
|
|
31
|
+
| `@rezwanul7/storage/server` | `createFileUpload`, `ServerValidationRules`, `FileStorage`, `LocalFileStorage`, `StorageError` | Node |
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// uploadRules.ts: one set of Rules for the browser and the server
|
|
37
|
+
import type { ServerValidationRules } from "@rezwanul7/storage/server";
|
|
38
|
+
|
|
39
|
+
export const avatarRules: ServerValidationRules = {
|
|
40
|
+
maxFileSize: "2MB",
|
|
41
|
+
allowedExtensions: ["png", "jpg", "jpeg"],
|
|
42
|
+
allowedMimeTypes: ["image/png", "image/jpeg"],
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// In a form: checks size and extension before uploading
|
|
48
|
+
import { z } from "zod";
|
|
49
|
+
import { createSingleFileZodSchema } from "@rezwanul7/storage/zod";
|
|
50
|
+
|
|
51
|
+
const schema = z.object({ avatar: createSingleFileZodSchema(avatarRules) });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// On the server: checks the bytes, then stores
|
|
56
|
+
import { createFileUpload, LocalFileStorage } from "@rezwanul7/storage/server";
|
|
57
|
+
|
|
58
|
+
const storage = new LocalFileStorage({
|
|
59
|
+
root: "./uploads",
|
|
60
|
+
urlPrefix: "/uploads",
|
|
61
|
+
});
|
|
62
|
+
const uploader = createFileUpload({ storage, logger });
|
|
63
|
+
|
|
64
|
+
const result = await uploader.store(file, avatarRules, {
|
|
65
|
+
directory: "avatars",
|
|
66
|
+
});
|
|
67
|
+
if (!result.ok) return { fieldErrors: { avatar: result.errors[0] } };
|
|
68
|
+
|
|
69
|
+
const [uploaded] = result.files;
|
|
70
|
+
// Save uploaded.provider and uploaded.storageId: you need both to read or delete the file later.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`logger` is optional. Pass a `@rezwanul7/logger` instance, or anything with `error(event, data)`, to hear about a file
|
|
74
|
+
a failed Upload couldn't clean up (`storage.rollback_delete_failed`). Without one, nothing is logged.
|
|
75
|
+
|
|
76
|
+
## Wiring it into your app
|
|
77
|
+
|
|
78
|
+
Build the Storage once, in your own code, from your own configuration:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
export function createStorage(provider = config.storageProvider): FileStorage {
|
|
82
|
+
switch (provider) {
|
|
83
|
+
case "local":
|
|
84
|
+
return new LocalFileStorage({
|
|
85
|
+
root: config.uploadRoot,
|
|
86
|
+
urlPrefix: "/uploads",
|
|
87
|
+
});
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`Unknown storage provider: ${provider}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- Save `provider` with each stored file. To read or delete it, build the Storage for that Provider, not the current
|
|
95
|
+
default, so files stay reachable after you switch Providers.
|
|
96
|
+
- `LocalFileStorage` keeps files under `root`, and its `url` isn't served. Keep `root` outside any folder your web
|
|
97
|
+
server serves, and serve files from a route that checks access, streams `read(storageId)`, and sets `Content-Type`,
|
|
98
|
+
`Content-Disposition` and `X-Content-Type-Options: nosniff`.
|
|
99
|
+
- In Next.js, import the server entry from a module that starts with `import "server-only"`, so it can't end up in a
|
|
100
|
+
client bundle. The package doesn't import `server-only` itself, so it works in any Node app.
|
|
101
|
+
|
|
102
|
+
## Recipes
|
|
103
|
+
|
|
104
|
+
**Allow a File category**
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import {
|
|
108
|
+
getExtensionsByCategory,
|
|
109
|
+
getMimeTypesByCategory,
|
|
110
|
+
} from "@rezwanul7/storage";
|
|
111
|
+
|
|
112
|
+
const rules: ServerValidationRules = {
|
|
113
|
+
maxFileSize: "10MB",
|
|
114
|
+
allowedExtensions: getExtensionsByCategory("Spreadsheet"),
|
|
115
|
+
allowedMimeTypes: getMimeTypesByCategory("Spreadsheet"),
|
|
116
|
+
};
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Categories matched by MIME prefix only, such as `"Image"`, have no exact MIME list: `getMimeTypesByCategory` throws for
|
|
120
|
+
them. List the MIME types yourself.
|
|
121
|
+
|
|
122
|
+
**Store several files**: all or nothing. Errors are keyed by the file's index.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const result = await uploader.store(files, rules, { directory: "imports" });
|
|
126
|
+
if (!result.ok) {
|
|
127
|
+
// {1: ["File exceeds maximum size of 10.0 MB."]}
|
|
128
|
+
const fieldErrors = Object.fromEntries(
|
|
129
|
+
Object.entries(result.errors).map(([i, messages]) => [
|
|
130
|
+
`files.${i}`,
|
|
131
|
+
messages,
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Read, serve or delete a stored file**
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const storage = createStorage(row.provider);
|
|
141
|
+
const file = await storage.read(row.storageId); // {stream, size}, or null if nothing is stored there
|
|
142
|
+
await storage.delete(row.storageId);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Add your own check**: return a message to reject the file, or nothing to accept it.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
customValidators: [
|
|
149
|
+
({ detectedMime, size }) =>
|
|
150
|
+
detectedMime === "image/png" && size > 5 * 1024 * 1024 ? "PNG images can't exceed 5MB." : undefined,
|
|
151
|
+
],
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Results and errors
|
|
155
|
+
|
|
156
|
+
`store` has two kinds of failure:
|
|
157
|
+
|
|
158
|
+
- **The files break the Rules**: it returns `{ok: false, errors}` and stores nothing. The messages are safe to show.
|
|
159
|
+
- **The Provider fails**: it deletes the files it already stored and throws a `StorageError`. Its message is for your
|
|
160
|
+
logs; show your own.
|
|
161
|
+
|
|
162
|
+
| `code` | When | What to do |
|
|
163
|
+
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------- |
|
|
164
|
+
| `STORAGE_UPLOAD_FAILED` | The Storage couldn't store a file; `cause` is the Provider's error | Tell the user to try again; log the error |
|
|
165
|
+
| `STORAGE_INVALID_ID` | A Storage ID points outside the Provider: bad data or a tampered ID | Treat the file as missing, and log it |
|
|
166
|
+
|
|
167
|
+
Map `StorageError` to your app's error types where you call the package, because the same failure can mean different
|
|
168
|
+
things in different places:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const result = await uploader
|
|
172
|
+
.store(file, rules, { directory })
|
|
173
|
+
.catch((error) => {
|
|
174
|
+
if (error instanceof StorageError)
|
|
175
|
+
throw new MyAppError("The file couldn't be stored. Try again.", {
|
|
176
|
+
cause: error,
|
|
177
|
+
});
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## What uploads are checked for
|
|
183
|
+
|
|
184
|
+
`store` reads each file's bytes and rejects it when:
|
|
185
|
+
|
|
186
|
+
- its type can't be identified from its content, name or declared type;
|
|
187
|
+
- its name or declared MIME type doesn't match its content (a renamed executable);
|
|
188
|
+
- it is an executable or script, whatever the Rules allow;
|
|
189
|
+
- its content has no recognisable signature (typically text) and still holds binary data, or isn't valid JSON or
|
|
190
|
+
JSONL for those extensions;
|
|
191
|
+
- it breaks the Rules: size, allowed or blocked MIME types and extensions, then your own checks, in that order.
|
|
192
|
+
|
|
193
|
+
The stored file's `mime` and `ext` are what the bytes say, not what the browser declared.
|
|
194
|
+
|
|
195
|
+
## Adding a Provider
|
|
196
|
+
|
|
197
|
+
1. Implement `FileStorage` with its own `provider` name. Pick each file's key under the given `directory`, and take
|
|
198
|
+
settings, including the Provider's client, as constructor options.
|
|
199
|
+
2. Throw `StorageError("STORAGE_INVALID_ID", …)` for a Storage ID outside the Provider.
|
|
200
|
+
3. Add it to your app's factory. Keep the existing Storages there, so files stored with them stay readable.
|
|
201
|
+
|
|
202
|
+
## API reference
|
|
203
|
+
|
|
204
|
+
Every export, parameter, and default is listed in the [API reference](./docs/api/README.md), generated from the source.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT License. See [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The size and extension Rules that both the browser-side zod schemas and the server check. */
|
|
2
|
+
interface SharedFileRules {
|
|
3
|
+
/** Largest size allowed: bytes, or a size such as `"10MB"`. */
|
|
4
|
+
maxFileSize?: number | string;
|
|
5
|
+
/** Smallest size allowed: bytes, or a size such as `"1KB"`. */
|
|
6
|
+
minFileSize?: number | string;
|
|
7
|
+
/** Extensions allowed, lower case and without the dot. Any is allowed when omitted. */
|
|
8
|
+
allowedExtensions?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type { SharedFileRules as S };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The size and extension Rules that both the browser-side zod schemas and the server check. */
|
|
2
|
+
interface SharedFileRules {
|
|
3
|
+
/** Largest size allowed: bytes, or a size such as `"10MB"`. */
|
|
4
|
+
maxFileSize?: number | string;
|
|
5
|
+
/** Smallest size allowed: bytes, or a size such as `"1KB"`. */
|
|
6
|
+
minFileSize?: number | string;
|
|
7
|
+
/** Extensions allowed, lower case and without the dot. Any is allowed when omitted. */
|
|
8
|
+
allowedExtensions?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type { SharedFileRules as S };
|