nuxt-filer 0.0.11 → 0.0.13
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 +58 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +8 -0
- package/dist/runtime/server/providers/s3.d.ts +40 -0
- package/dist/runtime/server/providers/s3.js +215 -0
- package/dist/runtime/server/utils/send.d.ts +33 -0
- package/dist/runtime/server/utils/send.js +64 -0
- package/package.json +16 -19
- package/src/runtime/server/providers/s3.ts +314 -0
- package/src/runtime/server/utils/send.ts +135 -0
package/README.md
CHANGED
|
@@ -136,6 +136,30 @@ const res = await transformImage(buffer, { width: 64, format: 'webp' })
|
|
|
136
136
|
| `external?.push(groupId, id, data, meta)` | Push to external system |
|
|
137
137
|
| `external?.pull(groupId, ref)` | Pull from external system |
|
|
138
138
|
|
|
139
|
+
### Serving raw files with `sendStoredFile()`
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// server/api/files/[groupId]/[id].get.ts
|
|
145
|
+
export default defineEventHandler((event) => {
|
|
146
|
+
const { groupId, id } = getRouterParams(event)
|
|
147
|
+
return sendStoredFile(event, groupId, id) // 404 if missing
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
It sets `content-type` from `meta.mime`, a `content-disposition` filename from `meta.name`, and `cache-control` / `last-modified` / `etag`, honoring `if-modified-since` / `if-none-match` (`304`) — just like the IPX route. `HEAD` requests get the headers without a body.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
sendStoredFile(event, groupId, id, {
|
|
155
|
+
disposition: 'attachment', // force a download ('inline' is the default)
|
|
156
|
+
filename: 'invoice-2026.pdf', // override the download name (default: meta.name)
|
|
157
|
+
maxAge: 3600, // cache-control max-age in seconds (default: 1 year; 0 = no-cache)
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`sendStoredFile` is auto-imported in your server routes — no need to import it.
|
|
162
|
+
|
|
139
163
|
## `@nuxt/image` Integration
|
|
140
164
|
|
|
141
165
|
If `@nuxt/image` is installed alongside `nuxt-filer`, the module automatically registers a `filer` image provider and an IPX endpoint that pulls bytes from your storage provider, runs them through Sharp, and returns the result.
|
|
@@ -169,9 +193,42 @@ filer: {
|
|
|
169
193
|
|
|
170
194
|
`@nuxt/image` and `ipx` are declared as optional peer dependencies — they only need to be installed if you want to use this integration.
|
|
171
195
|
|
|
196
|
+
## S3 storage
|
|
197
|
+
|
|
198
|
+
For durable object storage (AWS S3, Cloudflare R2, MinIO, …) use the built-in
|
|
199
|
+
`createS3Provider`. It stores each file as a data object plus a JSON metadata
|
|
200
|
+
sidecar, and — unlike unstorage's generic `s3` driver — `list()`/`findByMeta()`
|
|
201
|
+
use real S3 prefix listing with continuation pagination, so a group stays
|
|
202
|
+
correctly scoped even inside a large or shared bucket.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// nuxt.config.ts
|
|
206
|
+
export default defineNuxtConfig({
|
|
207
|
+
modules: ['nuxt-filer'],
|
|
208
|
+
filer: { provider: 'custom' },
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// server/plugins/file-provider.ts
|
|
214
|
+
export default defineNitroPlugin(() => {
|
|
215
|
+
const { s3 } = useRuntimeConfig()
|
|
216
|
+
setFileStorageProvider(createS3Provider({
|
|
217
|
+
accessKeyId: s3.accessKeyId,
|
|
218
|
+
secretAccessKey: s3.secretAccessKey,
|
|
219
|
+
endpoint: s3.endpoint, // e.g. https://<acct>.r2.cloudflarestorage.com
|
|
220
|
+
region: s3.region, // R2: 'auto'
|
|
221
|
+
bucket: s3.bucket,
|
|
222
|
+
// prefix: 'media/', // optional: namespace within a shared bucket
|
|
223
|
+
}))
|
|
224
|
+
})
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
> `createS3Provider` requires the optional [`aws4fetch`](https://github.com/mhart/aws4fetch) peer dependency (`npm i aws4fetch`). Pass a custom `client` to use a different transport or to unit-test without network.
|
|
228
|
+
|
|
172
229
|
## Custom Provider
|
|
173
230
|
|
|
174
|
-
For advanced use cases (database-backed metadata,
|
|
231
|
+
For advanced use cases (database-backed metadata, external file sync), implement the `FileStorageProvider` interface and register it in a Nitro plugin:
|
|
175
232
|
|
|
176
233
|
```ts
|
|
177
234
|
// nuxt.config.ts
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -22,6 +22,10 @@ const module = defineNuxtModule({
|
|
|
22
22
|
{
|
|
23
23
|
name: "transformImage",
|
|
24
24
|
from: resolver.resolve("./runtime/server/utils/image")
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "sendStoredFile",
|
|
28
|
+
from: resolver.resolve("./runtime/server/utils/send")
|
|
25
29
|
}
|
|
26
30
|
]);
|
|
27
31
|
const providerSpecifier = resolver.resolve("./runtime/server/provider");
|
|
@@ -33,6 +37,10 @@ const module = defineNuxtModule({
|
|
|
33
37
|
{
|
|
34
38
|
name: "createUnstorageProvider",
|
|
35
39
|
from: resolver.resolve("./runtime/server/providers/unstorage")
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "createS3Provider",
|
|
43
|
+
from: resolver.resolve("./runtime/server/providers/s3")
|
|
36
44
|
}
|
|
37
45
|
]);
|
|
38
46
|
const typesSpecifier = "nuxt-filer/runtime/types";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FileStorageProvider } from '../../../runtime/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal S3 object-store surface the provider needs. Abstracted so the
|
|
4
|
+
* provider can be unit-tested with an in-memory fake (pass `client`), and so
|
|
5
|
+
* the real implementation (aws4fetch) stays isolated.
|
|
6
|
+
*
|
|
7
|
+
* `listKeys` MUST list with a server-side prefix and paginate through every
|
|
8
|
+
* page — that's the whole point of this provider over unstorage's generic s3
|
|
9
|
+
* driver, which lists the bucket root and caps at 1000 keys.
|
|
10
|
+
*/
|
|
11
|
+
export interface S3Client {
|
|
12
|
+
put(key: string, body: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
13
|
+
get(key: string): Promise<Buffer | null>;
|
|
14
|
+
head(key: string): Promise<boolean>;
|
|
15
|
+
delete(key: string): Promise<void>;
|
|
16
|
+
listKeys(prefix: string): AsyncGenerator<string, void, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface S3ProviderOptions {
|
|
19
|
+
accessKeyId?: string;
|
|
20
|
+
secretAccessKey?: string;
|
|
21
|
+
/** S3 API endpoint, e.g. https://<acct>.r2.cloudflarestorage.com */
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
/** SigV4 region. R2 uses 'auto' (the default). */
|
|
24
|
+
region?: string;
|
|
25
|
+
bucket?: string;
|
|
26
|
+
/** Optional key prefix to namespace files within a shared bucket. */
|
|
27
|
+
prefix?: string;
|
|
28
|
+
/** Inject a custom S3 client (testing or alternative transport). */
|
|
29
|
+
client?: S3Client;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* S3-backed {@link FileStorageProvider}. Binary data and a JSON metadata
|
|
33
|
+
* sidecar are stored as separate objects per file, mirroring the built-in
|
|
34
|
+
* unstorage provider's semantics — but `list`/`findByMeta` use real S3 prefix
|
|
35
|
+
* listing with continuation pagination, so a group is correctly scoped even
|
|
36
|
+
* inside a large shared bucket.
|
|
37
|
+
*
|
|
38
|
+
* Key layout: `${prefix}${groupId}/data/${id}` and `${prefix}${groupId}/meta/${id}`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createS3Provider(options: S3ProviderOptions): FileStorageProvider;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export function createS3Provider(options) {
|
|
3
|
+
const prefix = options.prefix ? options.prefix.replace(/\/+$/, "") + "/" : "";
|
|
4
|
+
let clientPromise;
|
|
5
|
+
const getClient = () => clientPromise ??= options.client ? Promise.resolve(options.client) : createAwsS3Client(options);
|
|
6
|
+
const dataKey = (groupId, id) => `${prefix}${groupId}/data/${id}`;
|
|
7
|
+
const metaKey = (groupId, id) => `${prefix}${groupId}/meta/${id}`;
|
|
8
|
+
const metaPrefix = (groupId) => `${prefix}${groupId}/meta/`;
|
|
9
|
+
const groupIdFromMetaKey = (key) => {
|
|
10
|
+
const rel = key.slice(prefix.length);
|
|
11
|
+
return rel.slice(0, rel.lastIndexOf("/meta/"));
|
|
12
|
+
};
|
|
13
|
+
const idFromKey = (key) => key.slice(key.lastIndexOf("/") + 1);
|
|
14
|
+
const readMeta = async (client, key) => {
|
|
15
|
+
const raw = await client.get(key);
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(raw.toString("utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const toStoredFile = (id, groupId, meta, data) => ({
|
|
24
|
+
id,
|
|
25
|
+
groupId,
|
|
26
|
+
data: data ?? void 0,
|
|
27
|
+
meta: stripInternal(meta),
|
|
28
|
+
createdAt: meta?._createdAt ? new Date(meta._createdAt) : void 0,
|
|
29
|
+
updatedAt: meta?._updatedAt ? new Date(meta._updatedAt) : void 0
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
async create(groupId, data, meta) {
|
|
33
|
+
const client = await getClient();
|
|
34
|
+
const id = randomUUID();
|
|
35
|
+
await client.put(dataKey(groupId, id), data, meta?.mime);
|
|
36
|
+
if (meta) {
|
|
37
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
38
|
+
await client.put(
|
|
39
|
+
metaKey(groupId, id),
|
|
40
|
+
Buffer.from(JSON.stringify({ ...meta, _createdAt: now, _updatedAt: now })),
|
|
41
|
+
"application/json"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { id };
|
|
45
|
+
},
|
|
46
|
+
async get(groupId, id) {
|
|
47
|
+
const client = await getClient();
|
|
48
|
+
const [data, meta] = await Promise.all([
|
|
49
|
+
client.get(dataKey(groupId, id)),
|
|
50
|
+
readMeta(client, metaKey(groupId, id))
|
|
51
|
+
]);
|
|
52
|
+
if (!data && !meta) return null;
|
|
53
|
+
return toStoredFile(id, groupId, meta, data ?? void 0);
|
|
54
|
+
},
|
|
55
|
+
async getData(groupId, id) {
|
|
56
|
+
return (await getClient()).get(dataKey(groupId, id));
|
|
57
|
+
},
|
|
58
|
+
async getMeta(id) {
|
|
59
|
+
const client = await getClient();
|
|
60
|
+
for await (const key of client.listKeys(prefix)) {
|
|
61
|
+
if (key.endsWith(`/meta/${id}`)) {
|
|
62
|
+
return stripInternal(await readMeta(client, key));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
},
|
|
67
|
+
async list(groupId) {
|
|
68
|
+
const client = await getClient();
|
|
69
|
+
const files = [];
|
|
70
|
+
for await (const key of client.listKeys(metaPrefix(groupId))) {
|
|
71
|
+
const meta = await readMeta(client, key);
|
|
72
|
+
if (!meta) continue;
|
|
73
|
+
files.push(toStoredFile(idFromKey(key), groupId, meta));
|
|
74
|
+
}
|
|
75
|
+
return files;
|
|
76
|
+
},
|
|
77
|
+
async update(id, meta) {
|
|
78
|
+
const client = await getClient();
|
|
79
|
+
for await (const key of client.listKeys(prefix)) {
|
|
80
|
+
if (!key.endsWith(`/meta/${id}`)) continue;
|
|
81
|
+
const existing = await readMeta(client, key) ?? {};
|
|
82
|
+
await client.put(
|
|
83
|
+
key,
|
|
84
|
+
Buffer.from(
|
|
85
|
+
JSON.stringify({ ...existing, ...meta, _updatedAt: (/* @__PURE__ */ new Date()).toISOString() })
|
|
86
|
+
),
|
|
87
|
+
"application/json"
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`File metadata not found: ${id}`);
|
|
92
|
+
},
|
|
93
|
+
async remove(groupId, id) {
|
|
94
|
+
const client = await getClient();
|
|
95
|
+
await Promise.all([
|
|
96
|
+
client.delete(dataKey(groupId, id)),
|
|
97
|
+
client.delete(metaKey(groupId, id))
|
|
98
|
+
]);
|
|
99
|
+
},
|
|
100
|
+
async clear(groupId) {
|
|
101
|
+
const client = await getClient();
|
|
102
|
+
for await (const key of client.listKeys(`${prefix}${groupId}/`)) {
|
|
103
|
+
await client.delete(key);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async has(groupId, id) {
|
|
107
|
+
return (await getClient()).head(dataKey(groupId, id));
|
|
108
|
+
},
|
|
109
|
+
async findByMeta(filter) {
|
|
110
|
+
const client = await getClient();
|
|
111
|
+
const scanPrefix = filter.groupId ? metaPrefix(filter.groupId) : prefix;
|
|
112
|
+
for await (const key of client.listKeys(scanPrefix)) {
|
|
113
|
+
if (!key.includes("/meta/")) continue;
|
|
114
|
+
const meta = await readMeta(client, key);
|
|
115
|
+
if (!meta) continue;
|
|
116
|
+
if (meta[filter.key] === filter.value) {
|
|
117
|
+
return toStoredFile(idFromKey(key), groupIdFromMetaKey(key), meta);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function stripInternal(meta) {
|
|
125
|
+
if (!meta) return { name: "", mime: "", type: "", version: 0 };
|
|
126
|
+
const { _createdAt, _updatedAt, ...rest } = meta;
|
|
127
|
+
return rest;
|
|
128
|
+
}
|
|
129
|
+
async function createAwsS3Client(options) {
|
|
130
|
+
for (const key of ["accessKeyId", "secretAccessKey", "endpoint", "bucket"]) {
|
|
131
|
+
if (!options[key]) {
|
|
132
|
+
throw new Error(`[nuxt-filer] createS3Provider: missing required option "${key}"`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let AwsClient;
|
|
136
|
+
try {
|
|
137
|
+
({ AwsClient } = await import("aws4fetch"));
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'[nuxt-filer] createS3Provider needs the optional "aws4fetch" dependency. Install it: npm i aws4fetch'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const aws = new AwsClient({
|
|
144
|
+
service: "s3",
|
|
145
|
+
accessKeyId: options.accessKeyId,
|
|
146
|
+
secretAccessKey: options.secretAccessKey,
|
|
147
|
+
region: options.region || "auto"
|
|
148
|
+
});
|
|
149
|
+
const base = `${options.endpoint.replace(/\/+$/, "")}/${options.bucket}`;
|
|
150
|
+
const objectUrl = (key) => `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
151
|
+
const signedFetch = (url, init) => aws.sign(url, init).then((req) => fetch(req));
|
|
152
|
+
return {
|
|
153
|
+
async put(key, body, contentType) {
|
|
154
|
+
const res = await signedFetch(objectUrl(key), {
|
|
155
|
+
method: "PUT",
|
|
156
|
+
body,
|
|
157
|
+
headers: contentType ? { "content-type": contentType } : void 0
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw new Error(`[nuxt-filer] S3 PUT ${key}: ${res.status} ${res.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
async get(key) {
|
|
164
|
+
const res = await signedFetch(objectUrl(key));
|
|
165
|
+
if (res.status === 404) return null;
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
throw new Error(`[nuxt-filer] S3 GET ${key}: ${res.status} ${res.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
return Buffer.from(await res.arrayBuffer());
|
|
170
|
+
},
|
|
171
|
+
async head(key) {
|
|
172
|
+
const res = await signedFetch(objectUrl(key), { method: "HEAD" });
|
|
173
|
+
if (res.status === 404) return false;
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
throw new Error(`[nuxt-filer] S3 HEAD ${key}: ${res.status} ${res.statusText}`);
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
},
|
|
179
|
+
async delete(key) {
|
|
180
|
+
const res = await signedFetch(objectUrl(key), { method: "DELETE" });
|
|
181
|
+
if (!res.ok && res.status !== 404) {
|
|
182
|
+
throw new Error(`[nuxt-filer] S3 DELETE ${key}: ${res.status} ${res.statusText}`);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
async *listKeys(keyPrefix) {
|
|
186
|
+
let token;
|
|
187
|
+
do {
|
|
188
|
+
const url = new URL(base);
|
|
189
|
+
url.searchParams.set("list-type", "2");
|
|
190
|
+
if (keyPrefix) url.searchParams.set("prefix", keyPrefix);
|
|
191
|
+
if (token) url.searchParams.set("continuation-token", token);
|
|
192
|
+
const res = await signedFetch(url.toString());
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
throw new Error(`[nuxt-filer] S3 LIST ${keyPrefix}: ${res.status} ${res.statusText}`);
|
|
195
|
+
}
|
|
196
|
+
const xml = await res.text();
|
|
197
|
+
for (const key of parseListKeys(xml)) yield key;
|
|
198
|
+
token = matchTag(xml, "IsTruncated") === "true" ? matchTag(xml, "NextContinuationToken") : void 0;
|
|
199
|
+
} while (token);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function parseListKeys(xml) {
|
|
204
|
+
const keys = [];
|
|
205
|
+
const re = /<Key>([\s\S]*?)<\/Key>/g;
|
|
206
|
+
let match;
|
|
207
|
+
while (match = re.exec(xml)) keys.push(decodeXml(match[1]));
|
|
208
|
+
return keys;
|
|
209
|
+
}
|
|
210
|
+
function matchTag(xml, tag) {
|
|
211
|
+
return xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
|
|
212
|
+
}
|
|
213
|
+
function decodeXml(value) {
|
|
214
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
215
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
import type { StoredFile } from '../../../runtime/types.js';
|
|
3
|
+
export type { StoredFile };
|
|
4
|
+
export interface SendStoredFileOptions {
|
|
5
|
+
/**
|
|
6
|
+
* `content-disposition` type. `'inline'` (the default) lets the browser
|
|
7
|
+
* render the file in place; `'attachment'` forces a download.
|
|
8
|
+
*/
|
|
9
|
+
disposition?: 'inline' | 'attachment';
|
|
10
|
+
/** Override the download filename. Defaults to the stored `meta.name`. */
|
|
11
|
+
filename?: string;
|
|
12
|
+
/**
|
|
13
|
+
* `cache-control` max-age in seconds. Default: one year — matching the IPX
|
|
14
|
+
* route. Pass `0` to mark the response uncacheable.
|
|
15
|
+
*/
|
|
16
|
+
maxAge?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stream a stored file back through an H3 event with the same HTTP revalidation
|
|
20
|
+
* story as the IPX image route: `content-type` from `meta.mime`, a
|
|
21
|
+
* `content-disposition` filename from `meta.name`, and
|
|
22
|
+
* `cache-control` / `last-modified` / `etag` honoring `if-modified-since` /
|
|
23
|
+
* `if-none-match` (304). Throws a 404 when the file does not exist.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* // server/api/files/[groupId]/[id].get.ts
|
|
27
|
+
* export default defineEventHandler((event) => {
|
|
28
|
+
* const { groupId, id } = getRouterParams(event)
|
|
29
|
+
* return sendStoredFile(event, groupId, id)
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function sendStoredFile(event: H3Event, groupId: string, id: string, options?: SendStoredFileOptions): Promise<Buffer | null>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRequestHeader,
|
|
3
|
+
setResponseHeader,
|
|
4
|
+
setResponseStatus,
|
|
5
|
+
createError
|
|
6
|
+
} from "h3";
|
|
7
|
+
import { useFileStorageProvider } from "../provider.js";
|
|
8
|
+
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365;
|
|
9
|
+
function fileMtime(file) {
|
|
10
|
+
return file.updatedAt ?? file.createdAt ?? void 0;
|
|
11
|
+
}
|
|
12
|
+
function cacheValidators(file) {
|
|
13
|
+
const mtime = fileMtime(file);
|
|
14
|
+
if (!mtime) return {};
|
|
15
|
+
return { etag: `W/"${mtime.getTime().toString(16)}"`, lastModified: mtime };
|
|
16
|
+
}
|
|
17
|
+
function contentDisposition(type, name) {
|
|
18
|
+
const asciiName = name.replace(/[^\x20-\x7E]|["\\]/g, "_");
|
|
19
|
+
return `${type}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
20
|
+
}
|
|
21
|
+
export async function sendStoredFile(event, groupId, id, options = {}) {
|
|
22
|
+
const provider = useFileStorageProvider();
|
|
23
|
+
const file = await provider.get(groupId, id);
|
|
24
|
+
if (!file) {
|
|
25
|
+
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
26
|
+
}
|
|
27
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE;
|
|
28
|
+
const { etag, lastModified } = cacheValidators(file);
|
|
29
|
+
setResponseHeader(
|
|
30
|
+
event,
|
|
31
|
+
"cache-control",
|
|
32
|
+
maxAge > 0 ? `public, max-age=${maxAge}` : "no-cache"
|
|
33
|
+
);
|
|
34
|
+
if (etag) setResponseHeader(event, "etag", etag);
|
|
35
|
+
if (lastModified)
|
|
36
|
+
setResponseHeader(event, "last-modified", lastModified.toUTCString());
|
|
37
|
+
const ifNoneMatch = getRequestHeader(event, "if-none-match");
|
|
38
|
+
const ifModifiedSince = getRequestHeader(event, "if-modified-since");
|
|
39
|
+
const notModified = etag ? ifNoneMatch === etag : !!lastModified && !!ifModifiedSince && lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
40
|
+
if (notModified) {
|
|
41
|
+
setResponseStatus(event, 304);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
setResponseHeader(
|
|
45
|
+
event,
|
|
46
|
+
"content-type",
|
|
47
|
+
file.meta.mime || "application/octet-stream"
|
|
48
|
+
);
|
|
49
|
+
setResponseHeader(
|
|
50
|
+
event,
|
|
51
|
+
"content-disposition",
|
|
52
|
+
contentDisposition(
|
|
53
|
+
options.disposition ?? "inline",
|
|
54
|
+
options.filename ?? file.meta.name
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
const data = await provider.getData(groupId, id);
|
|
58
|
+
if (!data) {
|
|
59
|
+
throw createError({ statusCode: 404, statusMessage: "File not found" });
|
|
60
|
+
}
|
|
61
|
+
setResponseHeader(event, "content-length", data.length);
|
|
62
|
+
if (event.method === "HEAD") return null;
|
|
63
|
+
return data;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-filer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "File storage module for Nuxt",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,17 +36,6 @@
|
|
|
36
36
|
"dist",
|
|
37
37
|
"src/runtime"
|
|
38
38
|
],
|
|
39
|
-
"scripts": {
|
|
40
|
-
"prepack": "nuxt-module-build build",
|
|
41
|
-
"dev": "npm run dev:prepare && nuxi dev playground",
|
|
42
|
-
"dev:build": "nuxi build playground",
|
|
43
|
-
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
|
|
44
|
-
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
45
|
-
"lint": "eslint .",
|
|
46
|
-
"test": "vitest run",
|
|
47
|
-
"test:watch": "vitest watch",
|
|
48
|
-
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
49
|
-
},
|
|
50
39
|
"dependencies": {
|
|
51
40
|
"@nuxt/kit": "^4.4.2",
|
|
52
41
|
"consola": "^3.4.2",
|
|
@@ -56,6 +45,7 @@
|
|
|
56
45
|
"@nuxt/devtools": "^3.2.4",
|
|
57
46
|
"@nuxt/eslint-config": "^1.15.2",
|
|
58
47
|
"@nuxt/image": "^2.0.0",
|
|
48
|
+
"aws4fetch": "^1.0.20",
|
|
59
49
|
"@nuxt/module-builder": "^1.0.2",
|
|
60
50
|
"@nuxt/schema": "^4.4.2",
|
|
61
51
|
"@nuxt/test-utils": "^4.0.0",
|
|
@@ -71,6 +61,7 @@
|
|
|
71
61
|
},
|
|
72
62
|
"peerDependencies": {
|
|
73
63
|
"@nuxt/image": "^2.0.0",
|
|
64
|
+
"aws4fetch": "^1.0.20",
|
|
74
65
|
"ipx": "^3.0.0",
|
|
75
66
|
"sharp": "^0.33.0 || ^0.34.0"
|
|
76
67
|
},
|
|
@@ -78,6 +69,9 @@
|
|
|
78
69
|
"@nuxt/image": {
|
|
79
70
|
"optional": true
|
|
80
71
|
},
|
|
72
|
+
"aws4fetch": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
81
75
|
"ipx": {
|
|
82
76
|
"optional": true
|
|
83
77
|
},
|
|
@@ -85,11 +79,14 @@
|
|
|
85
79
|
"optional": true
|
|
86
80
|
}
|
|
87
81
|
},
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
82
|
+
"scripts": {
|
|
83
|
+
"dev": "npm run dev:prepare && nuxi dev playground",
|
|
84
|
+
"dev:build": "nuxi build playground",
|
|
85
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
|
|
86
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
87
|
+
"lint": "eslint .",
|
|
88
|
+
"test": "vitest run",
|
|
89
|
+
"test:watch": "vitest watch",
|
|
90
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
94
91
|
}
|
|
95
|
-
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
FileStorageProvider,
|
|
4
|
+
FileMeta,
|
|
5
|
+
StoredFile,
|
|
6
|
+
} from '../../../runtime/types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal S3 object-store surface the provider needs. Abstracted so the
|
|
10
|
+
* provider can be unit-tested with an in-memory fake (pass `client`), and so
|
|
11
|
+
* the real implementation (aws4fetch) stays isolated.
|
|
12
|
+
*
|
|
13
|
+
* `listKeys` MUST list with a server-side prefix and paginate through every
|
|
14
|
+
* page — that's the whole point of this provider over unstorage's generic s3
|
|
15
|
+
* driver, which lists the bucket root and caps at 1000 keys.
|
|
16
|
+
*/
|
|
17
|
+
export interface S3Client {
|
|
18
|
+
put(key: string, body: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
19
|
+
get(key: string): Promise<Buffer | null>;
|
|
20
|
+
head(key: string): Promise<boolean>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
listKeys(prefix: string): AsyncGenerator<string, void, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface S3ProviderOptions {
|
|
26
|
+
accessKeyId?: string;
|
|
27
|
+
secretAccessKey?: string;
|
|
28
|
+
/** S3 API endpoint, e.g. https://<acct>.r2.cloudflarestorage.com */
|
|
29
|
+
endpoint?: string;
|
|
30
|
+
/** SigV4 region. R2 uses 'auto' (the default). */
|
|
31
|
+
region?: string;
|
|
32
|
+
bucket?: string;
|
|
33
|
+
/** Optional key prefix to namespace files within a shared bucket. */
|
|
34
|
+
prefix?: string;
|
|
35
|
+
/** Inject a custom S3 client (testing or alternative transport). */
|
|
36
|
+
client?: S3Client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type InternalMeta = FileMeta & { _createdAt?: string; _updatedAt?: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* S3-backed {@link FileStorageProvider}. Binary data and a JSON metadata
|
|
43
|
+
* sidecar are stored as separate objects per file, mirroring the built-in
|
|
44
|
+
* unstorage provider's semantics — but `list`/`findByMeta` use real S3 prefix
|
|
45
|
+
* listing with continuation pagination, so a group is correctly scoped even
|
|
46
|
+
* inside a large shared bucket.
|
|
47
|
+
*
|
|
48
|
+
* Key layout: `${prefix}${groupId}/data/${id}` and `${prefix}${groupId}/meta/${id}`.
|
|
49
|
+
*/
|
|
50
|
+
export function createS3Provider(options: S3ProviderOptions): FileStorageProvider {
|
|
51
|
+
const prefix = options.prefix ? options.prefix.replace(/\/+$/, '') + '/' : '';
|
|
52
|
+
|
|
53
|
+
let clientPromise: Promise<S3Client> | undefined;
|
|
54
|
+
const getClient = () =>
|
|
55
|
+
(clientPromise ??= options.client
|
|
56
|
+
? Promise.resolve(options.client)
|
|
57
|
+
: createAwsS3Client(options));
|
|
58
|
+
|
|
59
|
+
const dataKey = (groupId: string, id: string) => `${prefix}${groupId}/data/${id}`;
|
|
60
|
+
const metaKey = (groupId: string, id: string) => `${prefix}${groupId}/meta/${id}`;
|
|
61
|
+
const metaPrefix = (groupId: string) => `${prefix}${groupId}/meta/`;
|
|
62
|
+
|
|
63
|
+
/** `${prefix}${groupId}/meta/${id}` → groupId (groups may contain '/'). */
|
|
64
|
+
const groupIdFromMetaKey = (key: string) => {
|
|
65
|
+
const rel = key.slice(prefix.length);
|
|
66
|
+
return rel.slice(0, rel.lastIndexOf('/meta/'));
|
|
67
|
+
};
|
|
68
|
+
const idFromKey = (key: string) => key.slice(key.lastIndexOf('/') + 1);
|
|
69
|
+
|
|
70
|
+
const readMeta = async (client: S3Client, key: string): Promise<InternalMeta | null> => {
|
|
71
|
+
const raw = await client.get(key);
|
|
72
|
+
if (!raw) return null;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(raw.toString('utf8')) as InternalMeta;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const toStoredFile = (
|
|
81
|
+
id: string,
|
|
82
|
+
groupId: string,
|
|
83
|
+
meta: InternalMeta | null,
|
|
84
|
+
data?: Buffer,
|
|
85
|
+
): StoredFile => ({
|
|
86
|
+
id,
|
|
87
|
+
groupId,
|
|
88
|
+
data: data ?? undefined,
|
|
89
|
+
meta: stripInternal(meta),
|
|
90
|
+
createdAt: meta?._createdAt ? new Date(meta._createdAt) : undefined,
|
|
91
|
+
updatedAt: meta?._updatedAt ? new Date(meta._updatedAt) : undefined,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async create(groupId, data, meta) {
|
|
96
|
+
const client = await getClient();
|
|
97
|
+
const id = randomUUID();
|
|
98
|
+
|
|
99
|
+
await client.put(dataKey(groupId, id), data, meta?.mime);
|
|
100
|
+
|
|
101
|
+
if (meta) {
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
await client.put(
|
|
104
|
+
metaKey(groupId, id),
|
|
105
|
+
Buffer.from(JSON.stringify({ ...meta, _createdAt: now, _updatedAt: now })),
|
|
106
|
+
'application/json',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { id };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async get(groupId, id) {
|
|
114
|
+
const client = await getClient();
|
|
115
|
+
const [data, meta] = await Promise.all([
|
|
116
|
+
client.get(dataKey(groupId, id)),
|
|
117
|
+
readMeta(client, metaKey(groupId, id)),
|
|
118
|
+
]);
|
|
119
|
+
if (!data && !meta) return null;
|
|
120
|
+
return toStoredFile(id, groupId, meta, data ?? undefined);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async getData(groupId, id) {
|
|
124
|
+
return (await getClient()).get(dataKey(groupId, id));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async getMeta(id) {
|
|
128
|
+
// No groupId: scan meta objects (bounded by `prefix` when set). The
|
|
129
|
+
// serving path uses get/getData (which carry a groupId), so this is the
|
|
130
|
+
// only non-group-scoped lookup.
|
|
131
|
+
const client = await getClient();
|
|
132
|
+
for await (const key of client.listKeys(prefix)) {
|
|
133
|
+
if (key.endsWith(`/meta/${id}`)) {
|
|
134
|
+
return stripInternal(await readMeta(client, key));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async list(groupId) {
|
|
141
|
+
const client = await getClient();
|
|
142
|
+
const files: StoredFile[] = [];
|
|
143
|
+
for await (const key of client.listKeys(metaPrefix(groupId))) {
|
|
144
|
+
const meta = await readMeta(client, key);
|
|
145
|
+
if (!meta) continue;
|
|
146
|
+
files.push(toStoredFile(idFromKey(key), groupId, meta));
|
|
147
|
+
}
|
|
148
|
+
return files;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async update(id, meta) {
|
|
152
|
+
const client = await getClient();
|
|
153
|
+
for await (const key of client.listKeys(prefix)) {
|
|
154
|
+
if (!key.endsWith(`/meta/${id}`)) continue;
|
|
155
|
+
const existing = (await readMeta(client, key)) ?? ({} as InternalMeta);
|
|
156
|
+
await client.put(
|
|
157
|
+
key,
|
|
158
|
+
Buffer.from(
|
|
159
|
+
JSON.stringify({ ...existing, ...meta, _updatedAt: new Date().toISOString() }),
|
|
160
|
+
),
|
|
161
|
+
'application/json',
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`File metadata not found: ${id}`);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async remove(groupId, id) {
|
|
169
|
+
const client = await getClient();
|
|
170
|
+
await Promise.all([
|
|
171
|
+
client.delete(dataKey(groupId, id)),
|
|
172
|
+
client.delete(metaKey(groupId, id)),
|
|
173
|
+
]);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async clear(groupId) {
|
|
177
|
+
const client = await getClient();
|
|
178
|
+
for await (const key of client.listKeys(`${prefix}${groupId}/`)) {
|
|
179
|
+
await client.delete(key);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async has(groupId, id) {
|
|
184
|
+
return (await getClient()).head(dataKey(groupId, id));
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async findByMeta(filter) {
|
|
188
|
+
const client = await getClient();
|
|
189
|
+
const scanPrefix = filter.groupId ? metaPrefix(filter.groupId) : prefix;
|
|
190
|
+
for await (const key of client.listKeys(scanPrefix)) {
|
|
191
|
+
if (!key.includes('/meta/')) continue;
|
|
192
|
+
const meta = await readMeta(client, key);
|
|
193
|
+
if (!meta) continue;
|
|
194
|
+
if (meta[filter.key] === filter.value) {
|
|
195
|
+
return toStoredFile(idFromKey(key), groupIdFromMetaKey(key), meta);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stripInternal(meta: InternalMeta | null): FileMeta {
|
|
204
|
+
if (!meta) return { name: '', mime: '', type: '', version: 0 };
|
|
205
|
+
const { _createdAt, _updatedAt, ...rest } = meta;
|
|
206
|
+
return rest as FileMeta;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Default {@link S3Client} backed by aws4fetch (SigV4 over fetch). */
|
|
210
|
+
async function createAwsS3Client(options: S3ProviderOptions): Promise<S3Client> {
|
|
211
|
+
for (const key of ['accessKeyId', 'secretAccessKey', 'endpoint', 'bucket'] as const) {
|
|
212
|
+
if (!options[key]) {
|
|
213
|
+
throw new Error(`[nuxt-filer] createS3Provider: missing required option "${key}"`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let AwsClient: typeof import('aws4fetch').AwsClient;
|
|
218
|
+
try {
|
|
219
|
+
({ AwsClient } = await import('aws4fetch'));
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Error(
|
|
222
|
+
'[nuxt-filer] createS3Provider needs the optional "aws4fetch" dependency. Install it: npm i aws4fetch',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const aws = new AwsClient({
|
|
227
|
+
service: 's3',
|
|
228
|
+
accessKeyId: options.accessKeyId!,
|
|
229
|
+
secretAccessKey: options.secretAccessKey!,
|
|
230
|
+
region: options.region || 'auto',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const base = `${options.endpoint!.replace(/\/+$/, '')}/${options.bucket}`;
|
|
234
|
+
const objectUrl = (key: string) =>
|
|
235
|
+
`${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
236
|
+
|
|
237
|
+
const signedFetch = (url: string, init?: RequestInit) =>
|
|
238
|
+
aws.sign(url, init).then((req) => fetch(req));
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
async put(key, body, contentType) {
|
|
242
|
+
const res = await signedFetch(objectUrl(key), {
|
|
243
|
+
method: 'PUT',
|
|
244
|
+
body: body as BodyInit,
|
|
245
|
+
headers: contentType ? { 'content-type': contentType } : undefined,
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
throw new Error(`[nuxt-filer] S3 PUT ${key}: ${res.status} ${res.statusText}`);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
async get(key) {
|
|
252
|
+
const res = await signedFetch(objectUrl(key));
|
|
253
|
+
if (res.status === 404) return null;
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
throw new Error(`[nuxt-filer] S3 GET ${key}: ${res.status} ${res.statusText}`);
|
|
256
|
+
}
|
|
257
|
+
return Buffer.from(await res.arrayBuffer());
|
|
258
|
+
},
|
|
259
|
+
async head(key) {
|
|
260
|
+
const res = await signedFetch(objectUrl(key), { method: 'HEAD' });
|
|
261
|
+
if (res.status === 404) return false;
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
throw new Error(`[nuxt-filer] S3 HEAD ${key}: ${res.status} ${res.statusText}`);
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
},
|
|
267
|
+
async delete(key) {
|
|
268
|
+
const res = await signedFetch(objectUrl(key), { method: 'DELETE' });
|
|
269
|
+
if (!res.ok && res.status !== 404) {
|
|
270
|
+
throw new Error(`[nuxt-filer] S3 DELETE ${key}: ${res.status} ${res.statusText}`);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
async *listKeys(keyPrefix) {
|
|
274
|
+
let token: string | undefined;
|
|
275
|
+
do {
|
|
276
|
+
const url = new URL(base);
|
|
277
|
+
url.searchParams.set('list-type', '2');
|
|
278
|
+
if (keyPrefix) url.searchParams.set('prefix', keyPrefix);
|
|
279
|
+
if (token) url.searchParams.set('continuation-token', token);
|
|
280
|
+
|
|
281
|
+
const res = await signedFetch(url.toString());
|
|
282
|
+
if (!res.ok) {
|
|
283
|
+
throw new Error(`[nuxt-filer] S3 LIST ${keyPrefix}: ${res.status} ${res.statusText}`);
|
|
284
|
+
}
|
|
285
|
+
const xml = await res.text();
|
|
286
|
+
for (const key of parseListKeys(xml)) yield key;
|
|
287
|
+
|
|
288
|
+
token =
|
|
289
|
+
matchTag(xml, 'IsTruncated') === 'true' ? matchTag(xml, 'NextContinuationToken') : undefined;
|
|
290
|
+
} while (token);
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseListKeys(xml: string): string[] {
|
|
296
|
+
const keys: string[] = [];
|
|
297
|
+
const re = /<Key>([\s\S]*?)<\/Key>/g;
|
|
298
|
+
let match: RegExpExecArray | null;
|
|
299
|
+
while ((match = re.exec(xml))) keys.push(decodeXml(match[1]!));
|
|
300
|
+
return keys;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function matchTag(xml: string, tag: string): string | undefined {
|
|
304
|
+
return xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function decodeXml(value: string): string {
|
|
308
|
+
return value
|
|
309
|
+
.replace(/</g, '<')
|
|
310
|
+
.replace(/>/g, '>')
|
|
311
|
+
.replace(/"/g, '"')
|
|
312
|
+
.replace(/'/g, "'")
|
|
313
|
+
.replace(/&/g, '&');
|
|
314
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type H3Event,
|
|
3
|
+
getRequestHeader,
|
|
4
|
+
setResponseHeader,
|
|
5
|
+
setResponseStatus,
|
|
6
|
+
createError,
|
|
7
|
+
} from 'h3';
|
|
8
|
+
import type { StoredFile } from '../../../runtime/types';
|
|
9
|
+
import { useFileStorageProvider } from '../provider';
|
|
10
|
+
|
|
11
|
+
export type { StoredFile };
|
|
12
|
+
|
|
13
|
+
export interface SendStoredFileOptions {
|
|
14
|
+
/**
|
|
15
|
+
* `content-disposition` type. `'inline'` (the default) lets the browser
|
|
16
|
+
* render the file in place; `'attachment'` forces a download.
|
|
17
|
+
*/
|
|
18
|
+
disposition?: 'inline' | 'attachment';
|
|
19
|
+
/** Override the download filename. Defaults to the stored `meta.name`. */
|
|
20
|
+
filename?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `cache-control` max-age in seconds. Default: one year — matching the IPX
|
|
23
|
+
* route. Pass `0` to mark the response uncacheable.
|
|
24
|
+
*/
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 365; // 1 year, matching the IPX route.
|
|
29
|
+
|
|
30
|
+
/** The mtime the IPX route also uses: prefer `updatedAt`, fall back to `createdAt`. */
|
|
31
|
+
function fileMtime(file: StoredFile): Date | undefined {
|
|
32
|
+
return file.updatedAt ?? file.createdAt ?? undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a weak validator from the file's mtime so revalidation can be answered
|
|
37
|
+
* without reading the bytes back. Mirrors the IPX route, which keys its etag /
|
|
38
|
+
* last-modified off the same mtime.
|
|
39
|
+
*/
|
|
40
|
+
function cacheValidators(file: StoredFile): {
|
|
41
|
+
etag?: string;
|
|
42
|
+
lastModified?: Date;
|
|
43
|
+
} {
|
|
44
|
+
const mtime = fileMtime(file);
|
|
45
|
+
if (!mtime) return {};
|
|
46
|
+
return { etag: `W/"${mtime.getTime().toString(16)}"`, lastModified: mtime };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** RFC 6266 `content-disposition` value with an ASCII fallback + UTF-8 form. */
|
|
50
|
+
function contentDisposition(type: string, name: string): string {
|
|
51
|
+
// Strip anything outside printable ASCII (plus quote/backslash) for the
|
|
52
|
+
// legacy `filename=`; the `filename*=` form carries the real UTF-8 name.
|
|
53
|
+
const asciiName = name.replace(/[^\x20-\x7E]|["\\]/g, '_');
|
|
54
|
+
return `${type}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stream a stored file back through an H3 event with the same HTTP revalidation
|
|
59
|
+
* story as the IPX image route: `content-type` from `meta.mime`, a
|
|
60
|
+
* `content-disposition` filename from `meta.name`, and
|
|
61
|
+
* `cache-control` / `last-modified` / `etag` honoring `if-modified-since` /
|
|
62
|
+
* `if-none-match` (304). Throws a 404 when the file does not exist.
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* // server/api/files/[groupId]/[id].get.ts
|
|
66
|
+
* export default defineEventHandler((event) => {
|
|
67
|
+
* const { groupId, id } = getRouterParams(event)
|
|
68
|
+
* return sendStoredFile(event, groupId, id)
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export async function sendStoredFile(
|
|
73
|
+
event: H3Event,
|
|
74
|
+
groupId: string,
|
|
75
|
+
id: string,
|
|
76
|
+
options: SendStoredFileOptions = {}
|
|
77
|
+
): Promise<Buffer | null> {
|
|
78
|
+
const provider = useFileStorageProvider();
|
|
79
|
+
|
|
80
|
+
const file = await provider.get(groupId, id);
|
|
81
|
+
if (!file) {
|
|
82
|
+
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE;
|
|
86
|
+
const { etag, lastModified } = cacheValidators(file);
|
|
87
|
+
|
|
88
|
+
setResponseHeader(
|
|
89
|
+
event,
|
|
90
|
+
'cache-control',
|
|
91
|
+
maxAge > 0 ? `public, max-age=${maxAge}` : 'no-cache'
|
|
92
|
+
);
|
|
93
|
+
if (etag) setResponseHeader(event, 'etag', etag);
|
|
94
|
+
if (lastModified)
|
|
95
|
+
setResponseHeader(event, 'last-modified', lastModified.toUTCString());
|
|
96
|
+
|
|
97
|
+
// Conditional request handling — answer 304 before touching the bytes.
|
|
98
|
+
const ifNoneMatch = getRequestHeader(event, 'if-none-match');
|
|
99
|
+
const ifModifiedSince = getRequestHeader(event, 'if-modified-since');
|
|
100
|
+
const notModified = etag
|
|
101
|
+
? ifNoneMatch === etag
|
|
102
|
+
: !!lastModified
|
|
103
|
+
&& !!ifModifiedSince
|
|
104
|
+
&& lastModified.getTime() <= Date.parse(ifModifiedSince);
|
|
105
|
+
if (notModified) {
|
|
106
|
+
setResponseStatus(event, 304);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setResponseHeader(
|
|
111
|
+
event,
|
|
112
|
+
'content-type',
|
|
113
|
+
file.meta.mime || 'application/octet-stream'
|
|
114
|
+
);
|
|
115
|
+
setResponseHeader(
|
|
116
|
+
event,
|
|
117
|
+
'content-disposition',
|
|
118
|
+
contentDisposition(
|
|
119
|
+
options.disposition ?? 'inline',
|
|
120
|
+
options.filename ?? file.meta.name
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const data = await provider.getData(groupId, id);
|
|
125
|
+
if (!data) {
|
|
126
|
+
throw createError({ statusCode: 404, statusMessage: 'File not found' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setResponseHeader(event, 'content-length', data.length);
|
|
130
|
+
|
|
131
|
+
// HEAD requests get the full header set but no body.
|
|
132
|
+
if (event.method === 'HEAD') return null;
|
|
133
|
+
|
|
134
|
+
return data;
|
|
135
|
+
}
|