nuxt-filer 0.0.17 → 0.0.19
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 +62 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +12 -0
- package/dist/runtime/server/handlers/ipx.js +2 -3
- package/dist/runtime/server/provider.d.ts +6 -1
- package/dist/runtime/server/provider.js +3 -0
- package/dist/runtime/server/providers/drizzle.js +6 -0
- package/dist/runtime/server/providers/s3.js +6 -0
- package/dist/runtime/server/providers/unstorage.js +12 -0
- package/dist/runtime/server/utils/send.js +6 -5
- package/dist/runtime/server/utils/storage.d.ts +1 -0
- package/dist/runtime/server/utils/storage.js +6 -1
- package/dist/runtime/server/utils/thumbnail.d.ts +19 -0
- package/dist/runtime/server/utils/thumbnail.js +54 -0
- package/dist/runtime/server/utils/upload.d.ts +57 -0
- package/dist/runtime/server/utils/upload.js +81 -0
- package/dist/runtime/types.d.ts +5 -0
- package/package.json +15 -5
- package/src/runtime/server/handlers/ipx.ts +3 -3
- package/src/runtime/server/provider.ts +13 -1
- package/src/runtime/server/providers/drizzle.ts +9 -0
- package/src/runtime/server/providers/s3.ts +7 -0
- package/src/runtime/server/providers/unstorage.ts +20 -0
- package/src/runtime/server/utils/send.ts +16 -7
- package/src/runtime/server/utils/storage.ts +11 -1
- package/src/runtime/server/utils/thumbnail.ts +94 -0
- package/src/runtime/server/utils/upload.ts +157 -0
- package/src/runtime/types.ts +5 -0
package/README.md
CHANGED
|
@@ -117,6 +117,34 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
|
|
|
117
117
|
|
|
118
118
|
> Image processing requires the optional [`sharp`](https://sharp.pixelplumbing.com/) peer dependency. Install it (`npm i sharp`) only if you use `transform` / `transformImage()` — calling them without `sharp` throws a clear error. Without a `transform`, `upload()` stores the raw bytes unchanged and needs no extra dependency.
|
|
119
119
|
|
|
120
|
+
### Thumbnails and PDF previews with `generateThumbnail()`
|
|
121
|
+
|
|
122
|
+
Builds a preview image from a stored file: images are resized with Sharp, PDFs
|
|
123
|
+
get a page (the first by default) rendered and then resized. It returns
|
|
124
|
+
`null` instead of throwing for unsupported types, unreadable input, or missing
|
|
125
|
+
optional dependencies, so it can run on every upload:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const file = await readUploadedFile(event)
|
|
129
|
+
const id = await storage.upload('docs', file.data, { meta: { name: file.name, mime: file.type, type: 'document', version: 1 } })
|
|
130
|
+
|
|
131
|
+
const thumb = await generateThumbnail(file.data, file.type, { width: 300, height: 200 })
|
|
132
|
+
if (thumb) {
|
|
133
|
+
await storage.upload('docs', thumb.data, {
|
|
134
|
+
meta: { name: `thumb_${file.name}`, mime: thumb.mime, type: 'thumbnail', version: 1 },
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Options are those of `transformImage()` plus `page` (PDF page, default `1`).
|
|
140
|
+
Defaults: a 300×300 `inside` box, `webp`, and the first frame only for
|
|
141
|
+
animated images (`animated: true` keeps animation).
|
|
142
|
+
- Requires `sharp`; PDFs additionally need [`unpdf`](https://github.com/unjs/unpdf)
|
|
143
|
+
and `@napi-rs/canvas` (`npm i unpdf @napi-rs/canvas`). All are optional peer
|
|
144
|
+
dependencies; when one is missing, a warning is logged once and `null` returned.
|
|
145
|
+
- On-request transforms (the IPX route, or imgproxy) can't render PDFs, so a
|
|
146
|
+
stored preview like this is the way to show one.
|
|
147
|
+
|
|
120
148
|
### `useFileStorage()` API
|
|
121
149
|
|
|
122
150
|
| Method | Description |
|
|
@@ -124,8 +152,9 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
|
|
|
124
152
|
| `upload(groupId, data, options?)` | Store a file, returns its ID. `options.transform` runs the bytes through Sharp first (see above) |
|
|
125
153
|
| `list(groupId)` | List all files in a group |
|
|
126
154
|
| `get(groupId, id)` | Get a file with data and metadata |
|
|
155
|
+
| `head(groupId, id)` | Get a file's metadata and timestamps without reading its bytes |
|
|
127
156
|
| `getData(groupId, id)` | Get raw binary data only |
|
|
128
|
-
| `getMeta(id)` | Get metadata only |
|
|
157
|
+
| `getMeta(id)` | Get metadata only. Searches all groups, which is slow on large stores — prefer `head(groupId, id)` |
|
|
129
158
|
| `updateMeta(id, meta)` | Deep-merge metadata update |
|
|
130
159
|
| `remove(groupId, id)` | Delete a file |
|
|
131
160
|
| `clear(groupId)` | Delete all files in a group |
|
|
@@ -138,6 +167,35 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
|
|
|
138
167
|
| `external?.push(groupId, id, data, meta)` | Push to external system |
|
|
139
168
|
| `external?.pull(groupId, ref)` | Pull from external system |
|
|
140
169
|
|
|
170
|
+
### Validating uploads with `readUploadedFile()`
|
|
171
|
+
|
|
172
|
+
Reads a file from a `multipart/form-data` request and validates it, throwing a
|
|
173
|
+
400 (no file), 413 (too large) or 415 (type not allowed) with a message naming
|
|
174
|
+
the file and the limit:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// server/api/models.post.ts
|
|
178
|
+
export default defineEventHandler(async (event) => {
|
|
179
|
+
const file = await readUploadedFile(event, {
|
|
180
|
+
types: ['image', '.stl', '.3mf', 'application/pdf'],
|
|
181
|
+
maxSize: '50MB',
|
|
182
|
+
})
|
|
183
|
+
const id = await useFileStorage().upload(file.fields.group ?? 'uploads', file.data, {
|
|
184
|
+
meta: { name: file.name, mime: file.type, type: 'model', version: 1 },
|
|
185
|
+
})
|
|
186
|
+
return { id }
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `types` uses the syntax of the HTML `accept` attribute: exact MIME types
|
|
191
|
+
(`'image/png'`), families (`'image'` or `'image/*'`) and extensions
|
|
192
|
+
(`'.stl'`). Extensions help for formats browsers send without a MIME type.
|
|
193
|
+
- `maxSize` takes bytes or a string like `'500KB'`, `'2MB'` (1024-based). An
|
|
194
|
+
oversized request is rejected from its `Content-Length` before the body is read.
|
|
195
|
+
- `field` picks the form field (default `'file'`); the result's `fields` holds
|
|
196
|
+
the other, non-file form fields.
|
|
197
|
+
- `readUploadedFiles(event, { ..., max })` reads several files from the same field.
|
|
198
|
+
|
|
141
199
|
### Serving raw files with `sendStoredFile()`
|
|
142
200
|
|
|
143
201
|
The IPX route serves **images** with full HTTP caching. For everything else — original PDFs, non-image downloads, the unprocessed bytes of any file — `sendStoredFile()` streams a stored file back through an H3 event with the same revalidation story.
|
|
@@ -480,6 +538,9 @@ export default defineNitroPlugin(() => {
|
|
|
480
538
|
interface FileStorageProvider {
|
|
481
539
|
create(groupId: string, data: Buffer | Uint8Array, meta?: FileMeta): Promise<{ id: string }>
|
|
482
540
|
get(groupId: string, id: string): Promise<StoredFile | null>
|
|
541
|
+
// Optional: metadata without the bytes. Used by sendStoredFile and the IPX
|
|
542
|
+
// route to answer 304s without reading the file; falls back to get().
|
|
543
|
+
head?(groupId: string, id: string): Promise<StoredFile | null>
|
|
483
544
|
getData(groupId: string, id: string): Promise<Buffer | null>
|
|
484
545
|
getMeta(id: string): Promise<FileMeta | null>
|
|
485
546
|
list(groupId: string): Promise<StoredFile[]>
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -21,6 +21,18 @@ const module = defineNuxtModule({
|
|
|
21
21
|
name: "useFileStorage",
|
|
22
22
|
from: resolver.resolve("./runtime/server/utils/storage")
|
|
23
23
|
},
|
|
24
|
+
{
|
|
25
|
+
name: "readUploadedFile",
|
|
26
|
+
from: resolver.resolve("./runtime/server/utils/upload")
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "readUploadedFiles",
|
|
30
|
+
from: resolver.resolve("./runtime/server/utils/upload")
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "generateThumbnail",
|
|
34
|
+
from: resolver.resolve("./runtime/server/utils/thumbnail")
|
|
35
|
+
},
|
|
24
36
|
{
|
|
25
37
|
name: "transformImage",
|
|
26
38
|
from: resolver.resolve("./runtime/server/utils/image")
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "h3";
|
|
7
7
|
import * as ipxModule from "ipx";
|
|
8
8
|
import { ipxRoute } from "#nuxt-filer-image";
|
|
9
|
-
import { useFileStorageProvider } from "../provider.js";
|
|
9
|
+
import { headStoredFile, useFileStorageProvider } from "../provider.js";
|
|
10
10
|
function parseId(id) {
|
|
11
11
|
const trimmed = id.replace(/^\/+/, "");
|
|
12
12
|
const lastSlash = trimmed.lastIndexOf("/");
|
|
@@ -19,8 +19,7 @@ const filerStorage = {
|
|
|
19
19
|
const parsed = parseId(id);
|
|
20
20
|
if (!parsed) return void 0;
|
|
21
21
|
const [groupId, fileId] = parsed;
|
|
22
|
-
const
|
|
23
|
-
const file = await provider.get(groupId, fileId);
|
|
22
|
+
const file = await headStoredFile(useFileStorageProvider(), groupId, fileId);
|
|
24
23
|
if (!file) return void 0;
|
|
25
24
|
const mtime = file.updatedAt ?? file.createdAt ?? /* @__PURE__ */ new Date();
|
|
26
25
|
return {
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
import type { FileStorageProvider } from '../../runtime/types.js';
|
|
1
|
+
import type { FileStorageProvider, StoredFile } from '../../runtime/types.js';
|
|
2
2
|
export declare function setFileStorageProvider(provider: FileStorageProvider): void;
|
|
3
3
|
export declare function useFileStorageProvider(): FileStorageProvider;
|
|
4
|
+
/**
|
|
5
|
+
* A file's metadata without its bytes when the provider supports `head()`;
|
|
6
|
+
* otherwise `get()`, whose `data` callers can reuse instead of reading again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function headStoredFile(provider: FileStorageProvider, groupId: string, id: string): Promise<StoredFile | null>;
|
|
@@ -130,6 +130,12 @@ export function createDrizzleProvider(options) {
|
|
|
130
130
|
if (!data) return null;
|
|
131
131
|
return { id, groupId, data, meta: { ...EMPTY_META } };
|
|
132
132
|
},
|
|
133
|
+
async head(groupId, id) {
|
|
134
|
+
const c = await ready();
|
|
135
|
+
const row = await findRow(id, groupId);
|
|
136
|
+
if (row) return toStoredFile(c, row);
|
|
137
|
+
return await blobs.get(blobKey(groupId, id)) ? { id, groupId, meta: { ...EMPTY_META } } : null;
|
|
138
|
+
},
|
|
133
139
|
async getData(groupId, id) {
|
|
134
140
|
return blobs.get(blobKey(groupId, id));
|
|
135
141
|
},
|
|
@@ -65,6 +65,12 @@ export function createS3Provider(options) {
|
|
|
65
65
|
if (!data && !meta) return null;
|
|
66
66
|
return toStoredFile(id, groupId, meta, data ?? void 0);
|
|
67
67
|
},
|
|
68
|
+
async head(groupId, id) {
|
|
69
|
+
const client = await getClient();
|
|
70
|
+
const meta = await readMeta(client, metaKey(groupId, id));
|
|
71
|
+
if (!meta && !await client.head(dataKey(groupId, id))) return null;
|
|
72
|
+
return toStoredFile(id, groupId, meta);
|
|
73
|
+
},
|
|
68
74
|
async getData(groupId, id) {
|
|
69
75
|
return (await getClient()).get(dataKey(groupId, id));
|
|
70
76
|
},
|
|
@@ -38,6 +38,18 @@ export function createUnstorageProvider(storageName) {
|
|
|
38
38
|
updatedAt: metaData?._updatedAt ? new Date(metaData._updatedAt) : void 0
|
|
39
39
|
};
|
|
40
40
|
},
|
|
41
|
+
async head(groupId, id) {
|
|
42
|
+
const storage = getStorage();
|
|
43
|
+
const metaData = await storage.getItem(metaKey(groupId, id));
|
|
44
|
+
if (!metaData && !await storage.hasItem(dataKey(groupId, id))) return null;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
groupId,
|
|
48
|
+
meta: stripInternal(metaData),
|
|
49
|
+
createdAt: metaData?._createdAt ? new Date(metaData._createdAt) : void 0,
|
|
50
|
+
updatedAt: metaData?._updatedAt ? new Date(metaData._updatedAt) : void 0
|
|
51
|
+
};
|
|
52
|
+
},
|
|
41
53
|
async getData(groupId, id) {
|
|
42
54
|
return await getStorage().getItemRaw(dataKey(groupId, id));
|
|
43
55
|
},
|
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
setResponseStatus,
|
|
5
5
|
createError
|
|
6
6
|
} from "h3";
|
|
7
|
-
import { useFileStorageProvider } from "../provider.js";
|
|
7
|
+
import { headStoredFile, useFileStorageProvider } from "../provider.js";
|
|
8
8
|
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365;
|
|
9
9
|
function fileMtime(file) {
|
|
10
|
-
|
|
10
|
+
const mtime = file.updatedAt ?? file.createdAt;
|
|
11
|
+
return mtime ? new Date(Math.floor(mtime.getTime() / 1e3) * 1e3) : void 0;
|
|
11
12
|
}
|
|
12
13
|
function cacheValidators(file) {
|
|
13
14
|
const mtime = fileMtime(file);
|
|
@@ -20,7 +21,7 @@ function contentDisposition(type, name) {
|
|
|
20
21
|
}
|
|
21
22
|
export async function sendStoredFile(event, groupId, id, options = {}) {
|
|
22
23
|
const provider = useFileStorageProvider();
|
|
23
|
-
const file = await provider
|
|
24
|
+
const file = await headStoredFile(provider, groupId, id);
|
|
24
25
|
if (!file) {
|
|
25
26
|
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
26
27
|
}
|
|
@@ -36,7 +37,7 @@ export async function sendStoredFile(event, groupId, id, options = {}) {
|
|
|
36
37
|
setResponseHeader(event, "last-modified", lastModified.toUTCString());
|
|
37
38
|
const ifNoneMatch = getRequestHeader(event, "if-none-match");
|
|
38
39
|
const ifModifiedSince = getRequestHeader(event, "if-modified-since");
|
|
39
|
-
const notModified =
|
|
40
|
+
const notModified = ifNoneMatch ? !!etag && ifNoneMatch === etag : !!lastModified && !!ifModifiedSince && lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
40
41
|
if (notModified) {
|
|
41
42
|
setResponseStatus(event, 304);
|
|
42
43
|
return null;
|
|
@@ -54,7 +55,7 @@ export async function sendStoredFile(event, groupId, id, options = {}) {
|
|
|
54
55
|
options.filename ?? file.meta.name
|
|
55
56
|
)
|
|
56
57
|
);
|
|
57
|
-
const data = await provider.getData(groupId, id);
|
|
58
|
+
const data = file.data ?? await provider.getData(groupId, id);
|
|
58
59
|
if (!data) {
|
|
59
60
|
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
60
61
|
}
|
|
@@ -7,6 +7,7 @@ export declare const useFileStorage: () => {
|
|
|
7
7
|
}) => Promise<string>;
|
|
8
8
|
list: (groupId: string) => Promise<StoredFile[]>;
|
|
9
9
|
get: (groupId: string, id: string) => Promise<StoredFile | null>;
|
|
10
|
+
head: (groupId: string, id: string) => Promise<StoredFile | null>;
|
|
10
11
|
getData: (groupId: string, id: string) => Promise<Buffer | null>;
|
|
11
12
|
getMeta: (id: string) => Promise<FileMeta | null>;
|
|
12
13
|
updateMeta: (id: string, meta: Partial<FileMeta>) => Promise<void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defu } from "defu";
|
|
2
|
-
import { useFileStorageProvider } from "../provider.js";
|
|
2
|
+
import { headStoredFile, useFileStorageProvider } from "../provider.js";
|
|
3
3
|
import { transformImage } from "./image.js";
|
|
4
4
|
import { transformWithService, useImageService } from "./image-service-runtime.js";
|
|
5
5
|
export const useFileStorage = () => {
|
|
@@ -25,6 +25,10 @@ export const useFileStorage = () => {
|
|
|
25
25
|
async function get(groupId, id) {
|
|
26
26
|
return await provider.get(groupId, id);
|
|
27
27
|
}
|
|
28
|
+
async function head(groupId, id) {
|
|
29
|
+
const file = await headStoredFile(provider, groupId, id);
|
|
30
|
+
return file && { ...file, data: void 0 };
|
|
31
|
+
}
|
|
28
32
|
async function getData(groupId, id) {
|
|
29
33
|
return await provider.getData(groupId, id);
|
|
30
34
|
}
|
|
@@ -80,6 +84,7 @@ export const useFileStorage = () => {
|
|
|
80
84
|
upload,
|
|
81
85
|
list,
|
|
82
86
|
get,
|
|
87
|
+
head,
|
|
83
88
|
getData,
|
|
84
89
|
getMeta,
|
|
85
90
|
updateMeta,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ImageTransformOptions, ImageTransformResult } from '../../../runtime/types.js';
|
|
2
|
+
export interface ThumbnailOptions extends ImageTransformOptions {
|
|
3
|
+
/** PDF page to render. Default: `1`. */
|
|
4
|
+
page?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Generate a preview image for a stored file: images are resized with sharp,
|
|
8
|
+
* PDFs have a page (default: the first) rendered with `unpdf` and then resized.
|
|
9
|
+
*
|
|
10
|
+
* Returns `null` — never throws — when the type isn't supported, the input
|
|
11
|
+
* can't be decoded, or the optional dependencies aren't installed (`sharp`,
|
|
12
|
+
* plus `unpdf` and `@napi-rs/canvas` for PDFs; a warning is logged once).
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const thumb = await generateThumbnail(file.data, file.type, { width: 300, height: 200 })
|
|
16
|
+
* if (thumb) await storage.upload(group, thumb.data, { meta: { ...meta, mime: thumb.mime } })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateThumbnail(data: Buffer | Uint8Array, mime: string, options?: ThumbnailOptions): Promise<ImageTransformResult | null>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { consola } from "consola";
|
|
2
|
+
import { transformImage } from "./image.js";
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
width: 300,
|
|
5
|
+
height: 300,
|
|
6
|
+
fit: "inside",
|
|
7
|
+
format: "webp",
|
|
8
|
+
// A thumbnail shows the first frame; pass `animated: true` to keep animation.
|
|
9
|
+
animated: false
|
|
10
|
+
};
|
|
11
|
+
const warned = /* @__PURE__ */ new Set();
|
|
12
|
+
function warnOnce(key, message) {
|
|
13
|
+
if (warned.has(key)) return;
|
|
14
|
+
warned.add(key);
|
|
15
|
+
consola.warn(message);
|
|
16
|
+
}
|
|
17
|
+
function isMissingModule(error) {
|
|
18
|
+
for (let e = error; e; e = e.cause) {
|
|
19
|
+
if (e.code === "ERR_MODULE_NOT_FOUND" || e.code === "MODULE_NOT_FOUND") return true;
|
|
20
|
+
if (/Cannot find (?:module|package)|Failed to load url/i.test(String(e.message))) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
async function renderPdfPage(data, page, width) {
|
|
25
|
+
const { renderPageAsImage } = await import("unpdf");
|
|
26
|
+
const png = await renderPageAsImage(new Uint8Array(data), page, {
|
|
27
|
+
canvasImport: () => import("@napi-rs/canvas"),
|
|
28
|
+
width
|
|
29
|
+
});
|
|
30
|
+
return Buffer.from(png);
|
|
31
|
+
}
|
|
32
|
+
export async function generateThumbnail(data, mime, options = {}) {
|
|
33
|
+
const { page = 1, ...transform } = { ...DEFAULTS, ...options };
|
|
34
|
+
const type = mime.toLowerCase().split(";")[0].trim();
|
|
35
|
+
try {
|
|
36
|
+
if (type === "application/pdf") {
|
|
37
|
+
const width = Math.min(2 * Math.max(transform.width ?? 0, transform.height ?? 0, 300), 4096);
|
|
38
|
+
const png = await renderPdfPage(data, page, width);
|
|
39
|
+
return await transformImage(png, transform);
|
|
40
|
+
}
|
|
41
|
+
if (type.startsWith("image/")) {
|
|
42
|
+
return await transformImage(data, transform);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isMissingModule(error) || /optional peer dependency `sharp`/.test(String(error?.message))) {
|
|
47
|
+
warnOnce(
|
|
48
|
+
type,
|
|
49
|
+
type === "application/pdf" ? "[nuxt-filer] generateThumbnail: PDF previews need the optional dependencies `unpdf`, `@napi-rs/canvas` and `sharp`." : "[nuxt-filer] generateThumbnail: image previews need the optional dependency `sharp`."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
/** A file read from a `multipart/form-data` request. */
|
|
3
|
+
export interface UploadedFile {
|
|
4
|
+
/** The file's bytes. */
|
|
5
|
+
data: Buffer;
|
|
6
|
+
/** The client-supplied filename. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** MIME type as sent by the client, `application/octet-stream` when missing. */
|
|
9
|
+
type: string;
|
|
10
|
+
/** Size in bytes. */
|
|
11
|
+
size: number;
|
|
12
|
+
/** The request's non-file form fields, e.g. `{ group: 'models' }`. */
|
|
13
|
+
fields: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export interface ReadUploadedFileOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Accepted types, using the syntax of the HTML `accept` attribute plus a
|
|
18
|
+
* shorthand for families: an exact MIME type (`'image/png'`), a family
|
|
19
|
+
* (`'image'` or `'image/*'`), or an extension (`'.stl'`). Any match is
|
|
20
|
+
* enough. Default: anything.
|
|
21
|
+
*/
|
|
22
|
+
types?: string[];
|
|
23
|
+
/** Max size per file: bytes, or a string like `'500KB'` / `'2MB'` / `'1GB'` (1024-based). */
|
|
24
|
+
maxSize?: number | string;
|
|
25
|
+
/** Form field holding the file(s). Default: `'file'`. */
|
|
26
|
+
field?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ReadUploadedFilesOptions extends ReadUploadedFileOptions {
|
|
29
|
+
/** Max number of files. Default: unlimited. */
|
|
30
|
+
max?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Parse `'2MB'`-style sizes (1024-based); numbers are bytes. */
|
|
33
|
+
export declare function parseSize(size: number | string): number;
|
|
34
|
+
/** Whether a file matches one of the accepted `types` (see {@link ReadUploadedFileOptions.types}). */
|
|
35
|
+
export declare function matchesType(file: {
|
|
36
|
+
name: string;
|
|
37
|
+
type: string;
|
|
38
|
+
}, types: string[]): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Read and validate a single uploaded file from a `multipart/form-data`
|
|
41
|
+
* request. Throws an H3 error — 400 when no file is present, 413 when it
|
|
42
|
+
* exceeds `maxSize`, 415 when its type isn't accepted — with a short
|
|
43
|
+
* `statusMessage` and a `message` naming the file and the limit.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* export default defineEventHandler(async (event) => {
|
|
47
|
+
* const file = await readUploadedFile(event, { types: ['image'], maxSize: '5MB' })
|
|
48
|
+
* const id = await useFileStorage().upload('avatars', file.data, {
|
|
49
|
+
* meta: { name: file.name, mime: file.type, type: 'image', version: 1 },
|
|
50
|
+
* })
|
|
51
|
+
* return { id }
|
|
52
|
+
* })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function readUploadedFile(event: H3Event, options?: ReadUploadedFileOptions): Promise<UploadedFile>;
|
|
56
|
+
/** Like {@link readUploadedFile}, for several files in the same form field. */
|
|
57
|
+
export declare function readUploadedFiles(event: H3Event, options?: ReadUploadedFilesOptions): Promise<UploadedFile[]>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createError,
|
|
3
|
+
getRequestHeader,
|
|
4
|
+
readMultipartFormData
|
|
5
|
+
} from "h3";
|
|
6
|
+
const UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
7
|
+
export function parseSize(size) {
|
|
8
|
+
if (typeof size === "number") return size;
|
|
9
|
+
const match = size.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/);
|
|
10
|
+
if (!match) throw new Error(`[nuxt-filer] invalid size "${size}" (use e.g. 500KB, 2MB, 1GB)`);
|
|
11
|
+
return Math.floor(Number(match[1]) * UNITS[match[2]]);
|
|
12
|
+
}
|
|
13
|
+
function formatSize(bytes) {
|
|
14
|
+
for (const unit of ["GB", "MB", "KB"]) {
|
|
15
|
+
if (bytes >= UNITS[unit] && bytes % UNITS[unit] === 0) return `${bytes / UNITS[unit]} ${unit}`;
|
|
16
|
+
}
|
|
17
|
+
return bytes >= UNITS.MB ? `${(bytes / UNITS.MB).toFixed(1)} MB` : `${bytes} B`;
|
|
18
|
+
}
|
|
19
|
+
export function matchesType(file, types) {
|
|
20
|
+
const mime = file.type.toLowerCase();
|
|
21
|
+
const name = file.name.toLowerCase();
|
|
22
|
+
return types.some((raw) => {
|
|
23
|
+
const type = raw.trim().toLowerCase();
|
|
24
|
+
if (type.startsWith(".")) return name.endsWith(type);
|
|
25
|
+
if (type.endsWith("/*")) return mime.startsWith(type.slice(0, -1));
|
|
26
|
+
if (!type.includes("/")) return mime.startsWith(`${type}/`);
|
|
27
|
+
return mime === type;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const MULTIPART_OVERHEAD = 64 * 1024;
|
|
31
|
+
async function readFiles(event, options) {
|
|
32
|
+
const field = options.field ?? "file";
|
|
33
|
+
const maxSize = options.maxSize === void 0 ? void 0 : parseSize(options.maxSize);
|
|
34
|
+
if (maxSize !== void 0 && options.max !== void 0) {
|
|
35
|
+
const length = Number(getRequestHeader(event, "content-length"));
|
|
36
|
+
if (length > (maxSize + MULTIPART_OVERHEAD) * options.max) {
|
|
37
|
+
throw createError({ statusCode: 413, statusMessage: "File too large", message: `File too large (max ${formatSize(maxSize)})` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const parts = await readMultipartFormData(event) ?? [];
|
|
41
|
+
const fields = {};
|
|
42
|
+
for (const part of parts) {
|
|
43
|
+
if (part.name && part.filename === void 0) fields[part.name] = part.data.toString("utf8");
|
|
44
|
+
}
|
|
45
|
+
const files = parts.filter((part) => part.name === field && part.filename).map((part) => ({
|
|
46
|
+
data: part.data,
|
|
47
|
+
name: part.filename,
|
|
48
|
+
type: part.type || "application/octet-stream",
|
|
49
|
+
size: part.data.length,
|
|
50
|
+
fields
|
|
51
|
+
}));
|
|
52
|
+
if (!files.length) {
|
|
53
|
+
throw createError({ statusCode: 400, statusMessage: "No file provided", message: `No file provided (expected form field "${field}")` });
|
|
54
|
+
}
|
|
55
|
+
if (options.max !== void 0 && files.length > options.max) {
|
|
56
|
+
throw createError({ statusCode: 400, statusMessage: "Too many files", message: `Too many files (max ${options.max})` });
|
|
57
|
+
}
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
if (maxSize !== void 0 && file.size > maxSize) {
|
|
60
|
+
throw createError({
|
|
61
|
+
statusCode: 413,
|
|
62
|
+
statusMessage: "File too large",
|
|
63
|
+
message: `File "${file.name}" is too large (max ${formatSize(maxSize)})`
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (options.types && !matchesType(file, options.types)) {
|
|
67
|
+
throw createError({
|
|
68
|
+
statusCode: 415,
|
|
69
|
+
statusMessage: "Unsupported file type",
|
|
70
|
+
message: `File type of "${file.name}" is not allowed (allowed: ${options.types.join(", ")})`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return files;
|
|
75
|
+
}
|
|
76
|
+
export async function readUploadedFile(event, options = {}) {
|
|
77
|
+
return (await readFiles(event, { ...options, max: 1 }))[0];
|
|
78
|
+
}
|
|
79
|
+
export async function readUploadedFiles(event, options = {}) {
|
|
80
|
+
return readFiles(event, options);
|
|
81
|
+
}
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -135,6 +135,11 @@ export interface FileStorageProvider {
|
|
|
135
135
|
id: string;
|
|
136
136
|
}>;
|
|
137
137
|
get(groupId: string, id: string): Promise<StoredFile | null>;
|
|
138
|
+
/**
|
|
139
|
+
* Like {@link get} but without reading the bytes (`data` is undefined).
|
|
140
|
+
* Optional: when a provider doesn't implement it, callers fall back to `get()`.
|
|
141
|
+
*/
|
|
142
|
+
head?(groupId: string, id: string): Promise<StoredFile | null>;
|
|
138
143
|
getData(groupId: string, id: string): Promise<Buffer | null>;
|
|
139
144
|
getMeta(id: string): Promise<FileMeta | null>;
|
|
140
145
|
list(groupId: string): Promise<StoredFile[]>;
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-filer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"description": "File storage module for Nuxt",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/Niki2k1/
|
|
7
|
+
"url": "git+https://github.com/Niki2k1/ablage.git"
|
|
8
8
|
},
|
|
9
|
-
"homepage": "https://github.com/Niki2k1/
|
|
9
|
+
"homepage": "https://github.com/Niki2k1/ablage#readme",
|
|
10
10
|
"bugs": {
|
|
11
|
-
"url": "https://github.com/Niki2k1/
|
|
11
|
+
"url": "https://github.com/Niki2k1/ablage/issues"
|
|
12
12
|
},
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"author": "Niki2k1",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@electric-sql/pglite": "^0.5.8",
|
|
50
|
+
"@napi-rs/canvas": "^1.0.10",
|
|
50
51
|
"@nuxt/devtools": "^3.2.4",
|
|
51
52
|
"@nuxt/eslint-config": "^1.15.2",
|
|
52
53
|
"@nuxt/image": "^2.1.0",
|
|
@@ -63,17 +64,23 @@
|
|
|
63
64
|
"nuxt": "^4.4.2",
|
|
64
65
|
"sharp": "^0.35.5",
|
|
65
66
|
"typescript": "~6.0.2",
|
|
67
|
+
"unpdf": "^1.8.1",
|
|
66
68
|
"vitest": "^4.1.2",
|
|
67
69
|
"vue-tsc": "^3.2.6"
|
|
68
70
|
},
|
|
69
71
|
"peerDependencies": {
|
|
72
|
+
"@napi-rs/canvas": "^0.1.69 || ^1.0.0",
|
|
70
73
|
"@nuxt/image": "^2.0.0",
|
|
71
74
|
"aws4fetch": "^1.0.20",
|
|
72
75
|
"drizzle-orm": ">=0.36.0 <1.0.0 || >=1.0.0-0 <2.0.0",
|
|
73
76
|
"ipx": "^3.0.0 || ^4.0.0-0",
|
|
74
|
-
"sharp": "^0.33.0 || ^0.34.0 || ^0.35.0"
|
|
77
|
+
"sharp": "^0.33.0 || ^0.34.0 || ^0.35.0",
|
|
78
|
+
"unpdf": "^1.8.0"
|
|
75
79
|
},
|
|
76
80
|
"peerDependenciesMeta": {
|
|
81
|
+
"@napi-rs/canvas": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
77
84
|
"@nuxt/image": {
|
|
78
85
|
"optional": true
|
|
79
86
|
},
|
|
@@ -88,6 +95,9 @@
|
|
|
88
95
|
},
|
|
89
96
|
"sharp": {
|
|
90
97
|
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"unpdf": {
|
|
100
|
+
"optional": true
|
|
91
101
|
}
|
|
92
102
|
},
|
|
93
103
|
"scripts": {
|
|
@@ -12,7 +12,7 @@ import * as ipxModule from 'ipx';
|
|
|
12
12
|
import type { IPXStorage } from 'ipx';
|
|
13
13
|
// @ts-expect-error virtual module injected by the module
|
|
14
14
|
import { ipxRoute } from '#nuxt-filer-image';
|
|
15
|
-
import { useFileStorageProvider } from '../provider';
|
|
15
|
+
import { headStoredFile, useFileStorageProvider } from '../provider';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Maps an IPX `id` (the path after the modifiers segment) to a `(groupId, fileId)`
|
|
@@ -32,8 +32,8 @@ const filerStorage: IPXStorage = {
|
|
|
32
32
|
const parsed = parseId(id);
|
|
33
33
|
if (!parsed) return undefined;
|
|
34
34
|
const [groupId, fileId] = parsed;
|
|
35
|
-
|
|
36
|
-
const file = await
|
|
35
|
+
// Metadata only; IPX reads the bytes through getData() when it renders.
|
|
36
|
+
const file = await headStoredFile(useFileStorageProvider(), groupId, fileId);
|
|
37
37
|
if (!file) return undefined;
|
|
38
38
|
const mtime = file.updatedAt ?? file.createdAt ?? new Date();
|
|
39
39
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileStorageProvider } from '../../runtime/types';
|
|
1
|
+
import type { FileStorageProvider, StoredFile } from '../../runtime/types';
|
|
2
2
|
|
|
3
3
|
let _provider: FileStorageProvider | null = null;
|
|
4
4
|
|
|
@@ -14,3 +14,15 @@ export function useFileStorageProvider(): FileStorageProvider {
|
|
|
14
14
|
}
|
|
15
15
|
return _provider;
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A file's metadata without its bytes when the provider supports `head()`;
|
|
20
|
+
* otherwise `get()`, whose `data` callers can reuse instead of reading again.
|
|
21
|
+
*/
|
|
22
|
+
export async function headStoredFile(
|
|
23
|
+
provider: FileStorageProvider,
|
|
24
|
+
groupId: string,
|
|
25
|
+
id: string
|
|
26
|
+
): Promise<StoredFile | null> {
|
|
27
|
+
return provider.head ? provider.head(groupId, id) : provider.get(groupId, id);
|
|
28
|
+
}
|
|
@@ -230,6 +230,15 @@ export function createDrizzleProvider(options: DrizzleProviderOptions): FileStor
|
|
|
230
230
|
return { id, groupId, data, meta: { ...EMPTY_META } };
|
|
231
231
|
},
|
|
232
232
|
|
|
233
|
+
async head(groupId, id) {
|
|
234
|
+
const c = await ready();
|
|
235
|
+
const row = await findRow(id, groupId);
|
|
236
|
+
if (row) return toStoredFile(c, row);
|
|
237
|
+
// No row: only bytes stored without metadata (e.g. not yet imported).
|
|
238
|
+
// The blob store has no existence check, so this rare path reads them.
|
|
239
|
+
return (await blobs.get(blobKey(groupId, id))) ? { id, groupId, meta: { ...EMPTY_META } } : null;
|
|
240
|
+
},
|
|
241
|
+
|
|
233
242
|
async getData(groupId, id) {
|
|
234
243
|
return blobs.get(blobKey(groupId, id));
|
|
235
244
|
},
|
|
@@ -148,6 +148,13 @@ export function createS3Provider(options: S3ProviderOptions): FileStorageProvide
|
|
|
148
148
|
return toStoredFile(id, groupId, meta, data ?? undefined);
|
|
149
149
|
},
|
|
150
150
|
|
|
151
|
+
async head(groupId, id) {
|
|
152
|
+
const client = await getClient();
|
|
153
|
+
const meta = await readMeta(client, metaKey(groupId, id));
|
|
154
|
+
if (!meta && !(await client.head(dataKey(groupId, id)))) return null;
|
|
155
|
+
return toStoredFile(id, groupId, meta);
|
|
156
|
+
},
|
|
157
|
+
|
|
151
158
|
async getData(groupId, id) {
|
|
152
159
|
return (await getClient()).get(dataKey(groupId, id));
|
|
153
160
|
},
|
|
@@ -67,6 +67,26 @@ export function createUnstorageProvider(
|
|
|
67
67
|
};
|
|
68
68
|
},
|
|
69
69
|
|
|
70
|
+
async head(groupId, id) {
|
|
71
|
+
const storage = getStorage();
|
|
72
|
+
const metaData = await storage.getItem<
|
|
73
|
+
FileMeta & { _createdAt?: string; _updatedAt?: string }
|
|
74
|
+
>(metaKey(groupId, id));
|
|
75
|
+
if (!metaData && !(await storage.hasItem(dataKey(groupId, id)))) return null;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
groupId,
|
|
80
|
+
meta: stripInternal(metaData),
|
|
81
|
+
createdAt: metaData?._createdAt
|
|
82
|
+
? new Date(metaData._createdAt)
|
|
83
|
+
: undefined,
|
|
84
|
+
updatedAt: metaData?._updatedAt
|
|
85
|
+
? new Date(metaData._updatedAt)
|
|
86
|
+
: undefined,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
|
|
70
90
|
async getData(groupId, id) {
|
|
71
91
|
return await getStorage().getItemRaw<Buffer>(dataKey(groupId, id));
|
|
72
92
|
},
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createError,
|
|
7
7
|
} from 'h3';
|
|
8
8
|
import type { StoredFile } from '../../../runtime/types';
|
|
9
|
-
import { useFileStorageProvider } from '../provider';
|
|
9
|
+
import { headStoredFile, useFileStorageProvider } from '../provider';
|
|
10
10
|
|
|
11
11
|
export type { StoredFile };
|
|
12
12
|
|
|
@@ -27,9 +27,14 @@ export interface SendStoredFileOptions {
|
|
|
27
27
|
|
|
28
28
|
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365; // 1 year, matching the IPX route.
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The mtime the IPX route also uses: prefer `updatedAt`, fall back to
|
|
32
|
+
* `createdAt`. Truncated to whole seconds like HTTP dates, so an echoed
|
|
33
|
+
* `if-modified-since` compares equal instead of looking older.
|
|
34
|
+
*/
|
|
31
35
|
function fileMtime(file: StoredFile): Date | undefined {
|
|
32
|
-
|
|
36
|
+
const mtime = file.updatedAt ?? file.createdAt;
|
|
37
|
+
return mtime ? new Date(Math.floor(mtime.getTime() / 1000) * 1000) : undefined;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
@@ -77,7 +82,9 @@ export async function sendStoredFile(
|
|
|
77
82
|
): Promise<Buffer | null> {
|
|
78
83
|
const provider = useFileStorageProvider();
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
// Metadata only, so a 304 never reads the bytes (with providers that
|
|
86
|
+
// implement `head()`; otherwise `get()` already loaded them and are reused).
|
|
87
|
+
const file = await headStoredFile(provider, groupId, id);
|
|
81
88
|
if (!file) {
|
|
82
89
|
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
83
90
|
}
|
|
@@ -97,8 +104,10 @@ export async function sendStoredFile(
|
|
|
97
104
|
// Conditional request handling — answer 304 before touching the bytes.
|
|
98
105
|
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
|
|
99
106
|
const ifModifiedSince = getRequestHeader(event, 'if-modified-since');
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
// RFC 9110: if-none-match takes precedence; if-modified-since applies only
|
|
108
|
+
// when it's absent.
|
|
109
|
+
const notModified = ifNoneMatch
|
|
110
|
+
? !!etag && ifNoneMatch === etag
|
|
102
111
|
: !!lastModified
|
|
103
112
|
&& !!ifModifiedSince
|
|
104
113
|
&& lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
@@ -121,7 +130,7 @@ export async function sendStoredFile(
|
|
|
121
130
|
)
|
|
122
131
|
);
|
|
123
132
|
|
|
124
|
-
const data = await provider.getData(groupId, id);
|
|
133
|
+
const data = file.data ?? (await provider.getData(groupId, id));
|
|
125
134
|
if (!data) {
|
|
126
135
|
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
127
136
|
}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ImageTransformOptions,
|
|
8
8
|
ImageTransformResult,
|
|
9
9
|
} from '../../../runtime/types';
|
|
10
|
-
import { useFileStorageProvider } from '../provider';
|
|
10
|
+
import { headStoredFile, useFileStorageProvider } from '../provider';
|
|
11
11
|
import { transformImage } from './image';
|
|
12
12
|
import { transformWithService, useImageService } from './image-service-runtime';
|
|
13
13
|
|
|
@@ -61,6 +61,15 @@ export const useFileStorage = () => {
|
|
|
61
61
|
return await provider.get(groupId, id);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Like {@link get} but without the bytes, when the provider supports it. */
|
|
65
|
+
async function head(
|
|
66
|
+
groupId: string,
|
|
67
|
+
id: string
|
|
68
|
+
): Promise<StoredFile | null> {
|
|
69
|
+
const file = await headStoredFile(provider, groupId, id);
|
|
70
|
+
return file && { ...file, data: undefined };
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
async function getData(
|
|
65
74
|
groupId: string,
|
|
66
75
|
id: string
|
|
@@ -154,6 +163,7 @@ export const useFileStorage = () => {
|
|
|
154
163
|
upload,
|
|
155
164
|
list,
|
|
156
165
|
get,
|
|
166
|
+
head,
|
|
157
167
|
getData,
|
|
158
168
|
getMeta,
|
|
159
169
|
updateMeta,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { consola } from 'consola';
|
|
2
|
+
import type {
|
|
3
|
+
ImageTransformOptions,
|
|
4
|
+
ImageTransformResult,
|
|
5
|
+
} from '../../../runtime/types';
|
|
6
|
+
import { transformImage } from './image';
|
|
7
|
+
|
|
8
|
+
export interface ThumbnailOptions extends ImageTransformOptions {
|
|
9
|
+
/** PDF page to render. Default: `1`. */
|
|
10
|
+
page?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULTS: ThumbnailOptions = {
|
|
14
|
+
width: 300,
|
|
15
|
+
height: 300,
|
|
16
|
+
fit: 'inside',
|
|
17
|
+
format: 'webp',
|
|
18
|
+
// A thumbnail shows the first frame; pass `animated: true` to keep animation.
|
|
19
|
+
animated: false,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const warned = new Set<string>();
|
|
23
|
+
function warnOnce(key: string, message: string) {
|
|
24
|
+
if (warned.has(key)) return;
|
|
25
|
+
warned.add(key);
|
|
26
|
+
consola.warn(message);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether an import failed because the package isn't installed (also when wrapped in `cause`). */
|
|
30
|
+
function isMissingModule(error: unknown): boolean {
|
|
31
|
+
for (let e = error as { code?: string; message?: string; cause?: unknown } | undefined; e; e = e.cause as typeof e) {
|
|
32
|
+
if (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'MODULE_NOT_FOUND') return true;
|
|
33
|
+
if (/Cannot find (?:module|package)|Failed to load url/i.test(String(e.message))) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render one PDF page to a PNG with `unpdf` (+ `@napi-rs/canvas` on Node). */
|
|
39
|
+
async function renderPdfPage(data: Uint8Array, page: number, width: number): Promise<Buffer> {
|
|
40
|
+
const { renderPageAsImage } = await import('unpdf');
|
|
41
|
+
// pdf.js may transfer (detach) the buffer it's given; hand it a copy so the
|
|
42
|
+
// caller's bytes stay intact.
|
|
43
|
+
const png = await renderPageAsImage(new Uint8Array(data), page, {
|
|
44
|
+
canvasImport: () => import('@napi-rs/canvas'),
|
|
45
|
+
width,
|
|
46
|
+
});
|
|
47
|
+
return Buffer.from(png);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate a preview image for a stored file: images are resized with sharp,
|
|
52
|
+
* PDFs have a page (default: the first) rendered with `unpdf` and then resized.
|
|
53
|
+
*
|
|
54
|
+
* Returns `null` — never throws — when the type isn't supported, the input
|
|
55
|
+
* can't be decoded, or the optional dependencies aren't installed (`sharp`,
|
|
56
|
+
* plus `unpdf` and `@napi-rs/canvas` for PDFs; a warning is logged once).
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* const thumb = await generateThumbnail(file.data, file.type, { width: 300, height: 200 })
|
|
60
|
+
* if (thumb) await storage.upload(group, thumb.data, { meta: { ...meta, mime: thumb.mime } })
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export async function generateThumbnail(
|
|
64
|
+
data: Buffer | Uint8Array,
|
|
65
|
+
mime: string,
|
|
66
|
+
options: ThumbnailOptions = {},
|
|
67
|
+
): Promise<ImageTransformResult | null> {
|
|
68
|
+
const { page = 1, ...transform } = { ...DEFAULTS, ...options };
|
|
69
|
+
const type = mime.toLowerCase().split(';')[0]!.trim();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
if (type === 'application/pdf') {
|
|
73
|
+
// Render with headroom over the target box so the resize stays sharp.
|
|
74
|
+
const width = Math.min(2 * Math.max(transform.width ?? 0, transform.height ?? 0, 300), 4096);
|
|
75
|
+
const png = await renderPdfPage(data, page, width);
|
|
76
|
+
return await transformImage(png, transform);
|
|
77
|
+
}
|
|
78
|
+
if (type.startsWith('image/')) {
|
|
79
|
+
return await transformImage(data, transform);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (isMissingModule(error) || /optional peer dependency `sharp`/.test(String((error as Error)?.message))) {
|
|
85
|
+
warnOnce(
|
|
86
|
+
type,
|
|
87
|
+
type === 'application/pdf'
|
|
88
|
+
? '[nuxt-filer] generateThumbnail: PDF previews need the optional dependencies `unpdf`, `@napi-rs/canvas` and `sharp`.'
|
|
89
|
+
: '[nuxt-filer] generateThumbnail: image previews need the optional dependency `sharp`.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type H3Event,
|
|
3
|
+
createError,
|
|
4
|
+
getRequestHeader,
|
|
5
|
+
readMultipartFormData,
|
|
6
|
+
} from 'h3';
|
|
7
|
+
|
|
8
|
+
/** A file read from a `multipart/form-data` request. */
|
|
9
|
+
export interface UploadedFile {
|
|
10
|
+
/** The file's bytes. */
|
|
11
|
+
data: Buffer;
|
|
12
|
+
/** The client-supplied filename. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** MIME type as sent by the client, `application/octet-stream` when missing. */
|
|
15
|
+
type: string;
|
|
16
|
+
/** Size in bytes. */
|
|
17
|
+
size: number;
|
|
18
|
+
/** The request's non-file form fields, e.g. `{ group: 'models' }`. */
|
|
19
|
+
fields: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReadUploadedFileOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Accepted types, using the syntax of the HTML `accept` attribute plus a
|
|
25
|
+
* shorthand for families: an exact MIME type (`'image/png'`), a family
|
|
26
|
+
* (`'image'` or `'image/*'`), or an extension (`'.stl'`). Any match is
|
|
27
|
+
* enough. Default: anything.
|
|
28
|
+
*/
|
|
29
|
+
types?: string[];
|
|
30
|
+
/** Max size per file: bytes, or a string like `'500KB'` / `'2MB'` / `'1GB'` (1024-based). */
|
|
31
|
+
maxSize?: number | string;
|
|
32
|
+
/** Form field holding the file(s). Default: `'file'`. */
|
|
33
|
+
field?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReadUploadedFilesOptions extends ReadUploadedFileOptions {
|
|
37
|
+
/** Max number of files. Default: unlimited. */
|
|
38
|
+
max?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UNITS: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
42
|
+
|
|
43
|
+
/** Parse `'2MB'`-style sizes (1024-based); numbers are bytes. */
|
|
44
|
+
export function parseSize(size: number | string): number {
|
|
45
|
+
if (typeof size === 'number') return size;
|
|
46
|
+
const match = size.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/);
|
|
47
|
+
if (!match) throw new Error(`[nuxt-filer] invalid size "${size}" (use e.g. 500KB, 2MB, 1GB)`);
|
|
48
|
+
return Math.floor(Number(match[1]) * UNITS[match[2]!]!);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatSize(bytes: number): string {
|
|
52
|
+
for (const unit of ['GB', 'MB', 'KB'] as const) {
|
|
53
|
+
if (bytes >= UNITS[unit]! && bytes % UNITS[unit]! === 0) return `${bytes / UNITS[unit]!} ${unit}`;
|
|
54
|
+
}
|
|
55
|
+
return bytes >= UNITS.MB! ? `${(bytes / UNITS.MB!).toFixed(1)} MB` : `${bytes} B`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a file matches one of the accepted `types` (see {@link ReadUploadedFileOptions.types}). */
|
|
59
|
+
export function matchesType(file: { name: string; type: string }, types: string[]): boolean {
|
|
60
|
+
const mime = file.type.toLowerCase();
|
|
61
|
+
const name = file.name.toLowerCase();
|
|
62
|
+
return types.some((raw) => {
|
|
63
|
+
const type = raw.trim().toLowerCase();
|
|
64
|
+
if (type.startsWith('.')) return name.endsWith(type);
|
|
65
|
+
if (type.endsWith('/*')) return mime.startsWith(type.slice(0, -1));
|
|
66
|
+
if (!type.includes('/')) return mime.startsWith(`${type}/`);
|
|
67
|
+
return mime === type;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Generous per-part allowance for multipart boundaries and headers, so the
|
|
72
|
+
// Content-Length precheck never rejects a request that fits the limit.
|
|
73
|
+
const MULTIPART_OVERHEAD = 64 * 1024;
|
|
74
|
+
|
|
75
|
+
async function readFiles(event: H3Event, options: ReadUploadedFilesOptions): Promise<UploadedFile[]> {
|
|
76
|
+
const field = options.field ?? 'file';
|
|
77
|
+
const maxSize = options.maxSize === undefined ? undefined : parseSize(options.maxSize);
|
|
78
|
+
|
|
79
|
+
// Reject obviously oversized requests before buffering the body.
|
|
80
|
+
if (maxSize !== undefined && options.max !== undefined) {
|
|
81
|
+
const length = Number(getRequestHeader(event, 'content-length'));
|
|
82
|
+
if (length > (maxSize + MULTIPART_OVERHEAD) * options.max) {
|
|
83
|
+
throw createError({ statusCode: 413, statusMessage: 'File too large', message: `File too large (max ${formatSize(maxSize)})` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parts = (await readMultipartFormData(event)) ?? [];
|
|
88
|
+
const fields: Record<string, string> = {};
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
if (part.name && part.filename === undefined) fields[part.name] = part.data.toString('utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const files = parts
|
|
94
|
+
.filter((part) => part.name === field && part.filename)
|
|
95
|
+
.map((part): UploadedFile => ({
|
|
96
|
+
data: part.data,
|
|
97
|
+
name: part.filename!,
|
|
98
|
+
type: part.type || 'application/octet-stream',
|
|
99
|
+
size: part.data.length,
|
|
100
|
+
fields,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
if (!files.length) {
|
|
104
|
+
throw createError({ statusCode: 400, statusMessage: 'No file provided', message: `No file provided (expected form field "${field}")` });
|
|
105
|
+
}
|
|
106
|
+
if (options.max !== undefined && files.length > options.max) {
|
|
107
|
+
throw createError({ statusCode: 400, statusMessage: 'Too many files', message: `Too many files (max ${options.max})` });
|
|
108
|
+
}
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (maxSize !== undefined && file.size > maxSize) {
|
|
111
|
+
throw createError({
|
|
112
|
+
statusCode: 413,
|
|
113
|
+
statusMessage: 'File too large',
|
|
114
|
+
message: `File "${file.name}" is too large (max ${formatSize(maxSize)})`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (options.types && !matchesType(file, options.types)) {
|
|
118
|
+
throw createError({
|
|
119
|
+
statusCode: 415,
|
|
120
|
+
statusMessage: 'Unsupported file type',
|
|
121
|
+
message: `File type of "${file.name}" is not allowed (allowed: ${options.types.join(', ')})`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return files;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read and validate a single uploaded file from a `multipart/form-data`
|
|
130
|
+
* request. Throws an H3 error — 400 when no file is present, 413 when it
|
|
131
|
+
* exceeds `maxSize`, 415 when its type isn't accepted — with a short
|
|
132
|
+
* `statusMessage` and a `message` naming the file and the limit.
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* export default defineEventHandler(async (event) => {
|
|
136
|
+
* const file = await readUploadedFile(event, { types: ['image'], maxSize: '5MB' })
|
|
137
|
+
* const id = await useFileStorage().upload('avatars', file.data, {
|
|
138
|
+
* meta: { name: file.name, mime: file.type, type: 'image', version: 1 },
|
|
139
|
+
* })
|
|
140
|
+
* return { id }
|
|
141
|
+
* })
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export async function readUploadedFile(
|
|
145
|
+
event: H3Event,
|
|
146
|
+
options: ReadUploadedFileOptions = {},
|
|
147
|
+
): Promise<UploadedFile> {
|
|
148
|
+
return (await readFiles(event, { ...options, max: 1 }))[0]!;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Like {@link readUploadedFile}, for several files in the same form field. */
|
|
152
|
+
export async function readUploadedFiles(
|
|
153
|
+
event: H3Event,
|
|
154
|
+
options: ReadUploadedFilesOptions = {},
|
|
155
|
+
): Promise<UploadedFile[]> {
|
|
156
|
+
return readFiles(event, options);
|
|
157
|
+
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -152,6 +152,11 @@ export interface FileStorageProvider {
|
|
|
152
152
|
meta?: FileMeta
|
|
153
153
|
): Promise<{ id: string }>;
|
|
154
154
|
get(groupId: string, id: string): Promise<StoredFile | null>;
|
|
155
|
+
/**
|
|
156
|
+
* Like {@link get} but without reading the bytes (`data` is undefined).
|
|
157
|
+
* Optional: when a provider doesn't implement it, callers fall back to `get()`.
|
|
158
|
+
*/
|
|
159
|
+
head?(groupId: string, id: string): Promise<StoredFile | null>;
|
|
155
160
|
getData(groupId: string, id: string): Promise<Buffer | null>;
|
|
156
161
|
getMeta(id: string): Promise<FileMeta | null>;
|
|
157
162
|
list(groupId: string): Promise<StoredFile[]>;
|