@webjsdev/server 0.8.15 → 0.8.16
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/index.d.ts +58 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/actions.js +12 -1
- package/src/file-storage.js +466 -0
package/index.d.ts
CHANGED
|
@@ -247,7 +247,7 @@ export declare function handleApi(
|
|
|
247
247
|
// ---------------------------------------------------------------------------
|
|
248
248
|
|
|
249
249
|
/** Scan the app for `.server.{js,ts}` files and build the RPC + expose index. */
|
|
250
|
-
export declare function buildActionIndex(appDir: string, dev: boolean): Promise<unknown>;
|
|
250
|
+
export declare function buildActionIndex(appDir: string, dev: boolean, opts?: { skipExposeLoad?: boolean }): Promise<unknown>;
|
|
251
251
|
/** Whether a file path is a `.server.{js,ts,mjs,mts}` server file. */
|
|
252
252
|
export declare function isServerFile(file: string): boolean;
|
|
253
253
|
/** SHA-256 hash of an action file's absolute path (the RPC endpoint addressing scheme). */
|
|
@@ -553,6 +553,63 @@ export declare function broadcast(
|
|
|
553
553
|
opts?: { except?: unknown },
|
|
554
554
|
): void;
|
|
555
555
|
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// file-storage.js (#247)
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
/** A streaming handle returned by `FileStore.get` (body is a stream, never buffered). */
|
|
561
|
+
export interface StoredObjectHandle {
|
|
562
|
+
body: ReadableStream | import('node:stream').Readable;
|
|
563
|
+
size: number;
|
|
564
|
+
contentType: string;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The result of `FileStore.put`. */
|
|
568
|
+
export interface PutResult {
|
|
569
|
+
key: string;
|
|
570
|
+
size: number;
|
|
571
|
+
contentType: string;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The pluggable file-storage interface. `diskStore` is the local-disk default;
|
|
576
|
+
* an S3-compatible adapter is a drop-in (same web-standard object shapes).
|
|
577
|
+
*/
|
|
578
|
+
export interface FileStore {
|
|
579
|
+
put(
|
|
580
|
+
key: string,
|
|
581
|
+
file: Blob | File | ReadableStream | Uint8Array,
|
|
582
|
+
opts?: { contentType?: string },
|
|
583
|
+
): Promise<PutResult>;
|
|
584
|
+
get(key: string): Promise<StoredObjectHandle | null>;
|
|
585
|
+
delete(key: string): Promise<void>;
|
|
586
|
+
url(key: string): string;
|
|
587
|
+
has?(key: string): Promise<boolean>;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The default uploads directory (relative to cwd), gitignore-friendly. */
|
|
591
|
+
export declare const DEFAULT_UPLOAD_DIR: string;
|
|
592
|
+
/** Local-disk file store (the default adapter). Streams writes. */
|
|
593
|
+
export declare function diskStore(opts?: { dir?: string; baseUrl?: string }): FileStore;
|
|
594
|
+
/** Set the default file store (call at startup to use a custom dir or S3). */
|
|
595
|
+
export declare function setFileStore(store: FileStore): void;
|
|
596
|
+
/** Get the default file store (`diskStore` under `<cwd>/.webjs/uploads` unless set). */
|
|
597
|
+
export declare function getFileStore(): FileStore;
|
|
598
|
+
/** Generate a random, opaque, traversal-safe key, preserving a whitelisted extension. */
|
|
599
|
+
export declare function generateKey(filename?: string): string;
|
|
600
|
+
/** Validate a key and return the absolute path it resolves to (throws on traversal). */
|
|
601
|
+
export declare function assertSafeKey(dir: string, key: string): string;
|
|
602
|
+
/** Mint a signed, expiring URL for a stored object (HMAC-SHA256). */
|
|
603
|
+
export declare function signedUrl(
|
|
604
|
+
key: string,
|
|
605
|
+
opts: { secret: string; expiresIn?: number; base?: string },
|
|
606
|
+
): string;
|
|
607
|
+
/** Verify a signed URL (or parsed params); constant-time, checks expiry + tamper. */
|
|
608
|
+
export declare function verifySignedUrl(
|
|
609
|
+
input: string | URL | URLSearchParams | { key?: string; exp?: string | number; sig?: string },
|
|
610
|
+
secret: string,
|
|
611
|
+
): { valid: boolean; key: string | null; reason?: string };
|
|
612
|
+
|
|
556
613
|
// ---------------------------------------------------------------------------
|
|
557
614
|
// json.js
|
|
558
615
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -46,6 +46,7 @@ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
|
|
|
46
46
|
export { revalidatePath, revalidateAll } from './src/html-cache.js';
|
|
47
47
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
|
48
48
|
export { broadcast } from './src/broadcast.js';
|
|
49
|
+
export { diskStore, setFileStore, getFileStore, generateKey, signedUrl, verifySignedUrl, assertSafeKey, DEFAULT_UPLOAD_DIR } from './src/file-storage.js';
|
|
49
50
|
export { json, readBody } from './src/json.js';
|
|
50
51
|
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
51
52
|
export { attachWebSocket } from './src/websocket.js';
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -151,9 +151,17 @@ async function rpcResponse(payload, init = {}) {
|
|
|
151
151
|
*
|
|
152
152
|
* @param {string} appDir
|
|
153
153
|
* @param {boolean} dev
|
|
154
|
+
* @param {{ skipExposeLoad?: boolean }} [opts]
|
|
155
|
+
* `skipExposeLoad: true` builds the (load-free) `fileToHash` / `hashToFile`
|
|
156
|
+
* maps WITHOUT importing any `expose()`-referencing module, so `httpRoutes`
|
|
157
|
+
* stays empty. A read-only introspection caller (the MCP `list_actions` tool,
|
|
158
|
+
* #262) uses this to derive RPC endpoint hashes without running a server
|
|
159
|
+
* module's top-level side effects (Prisma init, DB connect) or risking a
|
|
160
|
+
* stray stdout write. The request pipeline keeps the default (loads expose
|
|
161
|
+
* routes, which the router must know before a request can hit them).
|
|
154
162
|
* @returns {Promise<ActionIndex>}
|
|
155
163
|
*/
|
|
156
|
-
export async function buildActionIndex(appDir, dev) {
|
|
164
|
+
export async function buildActionIndex(appDir, dev, opts = {}) {
|
|
157
165
|
/** @type {Map<string,string>} */
|
|
158
166
|
const hashToFile = new Map();
|
|
159
167
|
/** @type {Map<string,string>} */
|
|
@@ -190,6 +198,9 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
190
198
|
// mention in a comment or string only over-matches, costing one harmless
|
|
191
199
|
// extra module load; the common pure-RPC file never names `expose` and so
|
|
192
200
|
// still defers entirely.
|
|
201
|
+
// A read-only caller (MCP introspection) only needs the file -> hash maps
|
|
202
|
+
// above, so skip the expose-load entirely (no module side effects).
|
|
203
|
+
if (opts.skipExposeLoad) continue;
|
|
193
204
|
let src = '';
|
|
194
205
|
try { src = await readFile(file, 'utf8'); } catch {}
|
|
195
206
|
if (!/\bexpose\b/.test(src)) continue;
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File storage primitive: a pluggable `FileStore` with a streaming local-disk
|
|
3
|
+
* default adapter (issue #247).
|
|
4
|
+
*
|
|
5
|
+
* webjs already round-trips a native `File` / `Blob` / `FormData` over the RPC
|
|
6
|
+
* wire (the serializer), but nothing decided WHERE the bytes land. This module
|
|
7
|
+
* is that answer. The model mirrors `cache.js` exactly: a documented interface,
|
|
8
|
+
* a default adapter (`diskStore`), and a module singleton (`setFileStore` /
|
|
9
|
+
* `getFileStore`) so an app swaps the backend in one call without touching any
|
|
10
|
+
* call site.
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* import { getFileStore, generateKey } from '@webjsdev/server';
|
|
14
|
+
* const key = generateKey(file.name); // opaque, traversal-safe
|
|
15
|
+
* await getFileStore().put(key, file); // streams to disk
|
|
16
|
+
* const handle = await getFileStore().get(key); // { body, size, contentType }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Design notes:
|
|
20
|
+
*
|
|
21
|
+
* - **Streaming write.** `put` never buffers the whole file. It pipes
|
|
22
|
+
* `file.stream()` -> `Readable.fromWeb` -> `createWriteStream` via
|
|
23
|
+
* `node:stream/promises.pipeline`, so a large upload uses constant memory.
|
|
24
|
+
* The upstream body-size cap (#237, `maxMultipartBytes`) bounds the size
|
|
25
|
+
* BEFORE the bytes ever reach the store, so the store does not re-implement
|
|
26
|
+
* a limit; it only stays streaming.
|
|
27
|
+
* - **Traversal-safe keys.** Every key is resolved to an absolute path under
|
|
28
|
+
* `dir` and rejected if it escapes, using the same `resolve` +
|
|
29
|
+
* `startsWith(dir + sep)` containment guard the `/public/*` serve path uses
|
|
30
|
+
* in dev.js. A key with `..`, an absolute path, a leading slash, a NUL, or a
|
|
31
|
+
* backslash is rejected BEFORE any fs operation.
|
|
32
|
+
* - **S3-pluggability.** The interface operates on web-standard objects only
|
|
33
|
+
* (`File` / `Blob` / `ReadableStream` / `Uint8Array` in, a `{ body, size,
|
|
34
|
+
* contentType }` handle out), so an S3 / R2 / GCS adapter is a drop-in: it
|
|
35
|
+
* implements the same `put` / `get` / `delete` / `url` and the call sites do
|
|
36
|
+
* not change. webjs ships no S3 SDK; see the JSDoc on `FileStore` below.
|
|
37
|
+
*
|
|
38
|
+
* @module file-storage
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createWriteStream, createReadStream } from 'node:fs';
|
|
42
|
+
import { mkdir, stat, unlink } from 'node:fs/promises';
|
|
43
|
+
import { dirname, join, resolve, sep, extname } from 'node:path';
|
|
44
|
+
import { Readable } from 'node:stream';
|
|
45
|
+
import { pipeline } from 'node:stream/promises';
|
|
46
|
+
import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} StoredObjectHandle
|
|
50
|
+
* @property {ReadableStream | import('node:stream').Readable} body
|
|
51
|
+
* The stored object's bytes as a STREAM (never the whole buffer), so a
|
|
52
|
+
* serving route can do `new Response(handle.body, { headers })` without
|
|
53
|
+
* reading the file into memory.
|
|
54
|
+
* @property {number} size Byte length of the stored object.
|
|
55
|
+
* @property {string} contentType The recorded MIME type (`application/octet-stream` when unknown).
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} PutResult
|
|
60
|
+
* @property {string} key The (validated) key the object was stored under.
|
|
61
|
+
* @property {number} size Byte length written.
|
|
62
|
+
* @property {string} contentType The recorded MIME type.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The pluggable file-storage interface. The default `diskStore` implements it
|
|
67
|
+
* for the local filesystem; an S3-compatible adapter (R2, GCS, MinIO) is a
|
|
68
|
+
* drop-in replacement because every method operates on web-standard objects and
|
|
69
|
+
* the call sites only ever touch this surface.
|
|
70
|
+
*
|
|
71
|
+
* An S3 adapter would implement the SAME four methods:
|
|
72
|
+
* - `put(key, file)`: `PutObject` (stream the body), returning `{ key, size, contentType }`.
|
|
73
|
+
* - `get(key)`: `GetObject`, returning `{ body, size, contentType }` (the SDK's response stream) or `null`.
|
|
74
|
+
* - `delete(key)`: `DeleteObject` (idempotent).
|
|
75
|
+
* - `url(key)`: the object / CDN URL (`https://cdn.example.com/<key>`).
|
|
76
|
+
* Because the shape is identical, `setFileStore(s3Store({ ... }))` switches the
|
|
77
|
+
* whole app with no call-site change.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {Object} FileStore
|
|
80
|
+
* @property {(key: string, file: Blob | File | ReadableStream | Uint8Array, opts?: { contentType?: string }) => Promise<PutResult>} put
|
|
81
|
+
* Stream the bytes to storage under `key`. Captures size + content-type.
|
|
82
|
+
* @property {(key: string) => Promise<StoredObjectHandle | null>} get
|
|
83
|
+
* Return a streaming handle for the stored object, or `null` when absent.
|
|
84
|
+
* @property {(key: string) => Promise<void>} delete
|
|
85
|
+
* Remove the stored object. Idempotent (a missing key is not an error).
|
|
86
|
+
* @property {(key: string) => string} url
|
|
87
|
+
* The URL at which the stored object is served (`<baseUrl>/<key>` for `diskStore`).
|
|
88
|
+
* @property {(key: string) => Promise<boolean>} [has]
|
|
89
|
+
* Whether the key exists (optional).
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/** Default storage root, relative to the app's cwd. gitignore-friendly. */
|
|
93
|
+
export const DEFAULT_UPLOAD_DIR = '.webjs/uploads';
|
|
94
|
+
|
|
95
|
+
/** Reserved suffix for the per-object content-type sidecar (`<key>.meta`). */
|
|
96
|
+
const META_SUFFIX = '.meta';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The extension whitelist for `generateKey`. Only these (lowercased) extensions
|
|
100
|
+
* are preserved on a generated key; anything else yields an extensionless key.
|
|
101
|
+
* The point is that a generated key is opaque + safe, NOT that it round-trips an
|
|
102
|
+
* arbitrary filename. This is a conservative set of common, inert media / doc
|
|
103
|
+
* extensions; an app needing more can pass its own key.
|
|
104
|
+
*/
|
|
105
|
+
const SAFE_EXTENSIONS = new Set([
|
|
106
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'bmp',
|
|
107
|
+
'pdf', 'txt', 'md', 'csv', 'json', 'xml',
|
|
108
|
+
'mp3', 'wav', 'ogg', 'mp4', 'webm', 'mov', 'm4a',
|
|
109
|
+
'zip', 'gz',
|
|
110
|
+
'woff', 'woff2', 'ttf', 'otf',
|
|
111
|
+
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate a storage key and reject anything that could escape the root or is
|
|
116
|
+
* otherwise unsafe. Throws on a bad key BEFORE any filesystem operation; this is
|
|
117
|
+
* the security-critical seam. Mirrors the `/public/*` containment guard
|
|
118
|
+
* (`resolve` + `startsWith(root + sep)`) in dev.js.
|
|
119
|
+
*
|
|
120
|
+
* Rejected: an empty / non-string key, a key with a NUL byte, an absolute path,
|
|
121
|
+
* a leading slash or backslash, any `..` segment, any backslash (Windows
|
|
122
|
+
* separator), and ANY key that does not resolve to a path strictly under `dir`.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} dir The (already absolute) storage root.
|
|
125
|
+
* @param {string} key The caller-supplied key.
|
|
126
|
+
* @returns {string} The absolute path the key resolves to, guaranteed under `dir`.
|
|
127
|
+
*/
|
|
128
|
+
export function assertSafeKey(dir, key) {
|
|
129
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
130
|
+
throw new Error('file-storage: key must be a non-empty string');
|
|
131
|
+
}
|
|
132
|
+
// A NUL byte truncates a C string, so a key like `a\0.txt` can desync the
|
|
133
|
+
// path the JS layer validated from the path the syscall opens. Reject it.
|
|
134
|
+
if (key.includes('\0')) {
|
|
135
|
+
throw new Error('file-storage: key must not contain a NUL byte');
|
|
136
|
+
}
|
|
137
|
+
// A backslash is a path separator on Windows and a traversal vector
|
|
138
|
+
// (`..\\win`), so reject it outright rather than reason about platform.
|
|
139
|
+
if (key.includes('\\')) {
|
|
140
|
+
throw new Error('file-storage: key must not contain a backslash');
|
|
141
|
+
}
|
|
142
|
+
// An absolute key or a leading slash would escape `dir` under join/resolve.
|
|
143
|
+
if (key.startsWith('/') || /^[A-Za-z]:/.test(key)) {
|
|
144
|
+
throw new Error('file-storage: key must be relative (no leading slash or drive)');
|
|
145
|
+
}
|
|
146
|
+
// Reject any `..` traversal segment up front for a clear error, even though
|
|
147
|
+
// the containment check below would also catch it.
|
|
148
|
+
const segments = key.split('/');
|
|
149
|
+
if (segments.some((s) => s === '..')) {
|
|
150
|
+
throw new Error('file-storage: key must not contain a ".." segment');
|
|
151
|
+
}
|
|
152
|
+
// The `.meta` suffix is RESERVED for the content-type sidecar `put` writes
|
|
153
|
+
// (`<key>.meta`). Rejecting it eliminates the only namespace collision: a user
|
|
154
|
+
// storing both `foo` and `foo.meta` would otherwise have `foo`'s sidecar
|
|
155
|
+
// clobber the `foo.meta` object. `generateKey` never produces a `.meta` key.
|
|
156
|
+
if (key.endsWith(META_SUFFIX)) {
|
|
157
|
+
throw new Error(`file-storage: key must not end in "${META_SUFFIX}" (reserved for metadata)`);
|
|
158
|
+
}
|
|
159
|
+
// Final containment guard: resolve to an absolute path and confirm it stays
|
|
160
|
+
// strictly under `dir`. This is the authoritative check (the rules above are
|
|
161
|
+
// fast clear-error rejections; this one cannot be fooled).
|
|
162
|
+
const abs = resolve(dir, key);
|
|
163
|
+
const root = dir.endsWith(sep) ? dir : dir + sep;
|
|
164
|
+
if (abs !== dir && !abs.startsWith(root)) {
|
|
165
|
+
throw new Error('file-storage: key resolves outside the storage root');
|
|
166
|
+
}
|
|
167
|
+
// `abs === dir` means the key resolved to the root directory itself (e.g. an
|
|
168
|
+
// empty-ish key), which is not a file path.
|
|
169
|
+
if (abs === dir || abs === dir.replace(/[\\/]+$/, '')) {
|
|
170
|
+
throw new Error('file-storage: key must name a file, not the storage root');
|
|
171
|
+
}
|
|
172
|
+
return abs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalize an arbitrary input (a `Blob` / `File` / `ReadableStream` /
|
|
177
|
+
* `Uint8Array`) into a Node `Readable` stream plus an optional content type,
|
|
178
|
+
* WITHOUT buffering the whole thing. This keeps the write path streaming.
|
|
179
|
+
*
|
|
180
|
+
* @param {Blob | File | ReadableStream | Uint8Array} file
|
|
181
|
+
* @returns {{ stream: import('node:stream').Readable, contentType: string | null }}
|
|
182
|
+
*/
|
|
183
|
+
function toNodeStream(file) {
|
|
184
|
+
// A Blob / File exposes a web ReadableStream via `.stream()`; converting it
|
|
185
|
+
// with Readable.fromWeb keeps it streaming (no `.arrayBuffer()`).
|
|
186
|
+
if (file && typeof file.stream === 'function' && typeof file.size === 'number') {
|
|
187
|
+
const contentType = typeof file.type === 'string' && file.type ? file.type : null;
|
|
188
|
+
return { stream: Readable.fromWeb(file.stream()), contentType };
|
|
189
|
+
}
|
|
190
|
+
// A web ReadableStream directly.
|
|
191
|
+
if (file && typeof file.getReader === 'function') {
|
|
192
|
+
return { stream: Readable.fromWeb(/** @type {ReadableStream} */ (file)), contentType: null };
|
|
193
|
+
}
|
|
194
|
+
// A Uint8Array / Buffer: wrap as a single-chunk readable. This DOES hold the
|
|
195
|
+
// bytes the caller already has in memory, but it does not COPY-buffer them
|
|
196
|
+
// (the caller already chose an in-memory input), and the write still streams.
|
|
197
|
+
if (file instanceof Uint8Array) {
|
|
198
|
+
return { stream: Readable.from([Buffer.from(file.buffer, file.byteOffset, file.byteLength)]), contentType: null };
|
|
199
|
+
}
|
|
200
|
+
throw new Error('file-storage: put() expects a Blob, File, ReadableStream, or Uint8Array');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Local-disk file store (the default adapter). Streams writes, never buffering
|
|
205
|
+
* the whole file. `dir` is created on demand.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ dir?: string, baseUrl?: string }} [opts]
|
|
208
|
+
* `dir` is the storage root (default `<cwd>/.webjs/uploads`). `baseUrl` is the
|
|
209
|
+
* path prefix `url(key)` returns (default `/uploads`); the app serves that
|
|
210
|
+
* prefix from a `route.{js,ts}` handler that streams `get(key)`.
|
|
211
|
+
* @returns {FileStore}
|
|
212
|
+
*/
|
|
213
|
+
export function diskStore(opts = {}) {
|
|
214
|
+
const dir = resolve(opts.dir || join(process.cwd(), DEFAULT_UPLOAD_DIR));
|
|
215
|
+
// Normalize baseUrl to `/prefix` with no trailing slash.
|
|
216
|
+
let baseUrl = opts.baseUrl == null ? '/uploads' : String(opts.baseUrl);
|
|
217
|
+
if (!baseUrl.startsWith('/')) baseUrl = '/' + baseUrl;
|
|
218
|
+
baseUrl = baseUrl.replace(/\/+$/, '');
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
async put(key, file, putOpts = {}) {
|
|
222
|
+
const abs = assertSafeKey(dir, key);
|
|
223
|
+
const { stream, contentType: detected } = toNodeStream(file);
|
|
224
|
+
const contentType = putOpts.contentType || detected || 'application/octet-stream';
|
|
225
|
+
// Ensure the parent directory exists (supports nested keys like `a/b/c.png`).
|
|
226
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
227
|
+
// Streaming write: pipe the source through to disk. `pipeline` handles
|
|
228
|
+
// backpressure + teardown, so memory stays bounded regardless of size.
|
|
229
|
+
try {
|
|
230
|
+
await pipeline(stream, createWriteStream(abs));
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// A mid-stream failure (source error, disk full) leaves a truncated
|
|
233
|
+
// file. Remove it so a later read never serves a partial object under a
|
|
234
|
+
// key the caller never received, then re-throw.
|
|
235
|
+
try { await unlink(abs); } catch {}
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
const { size } = await stat(abs);
|
|
239
|
+
// Record the content type alongside the bytes so `get` can return it. A
|
|
240
|
+
// sidecar `<file>.meta` JSON keeps the object itself byte-for-byte the
|
|
241
|
+
// upload (so it serves cleanly) while still capturing the type.
|
|
242
|
+
try {
|
|
243
|
+
const metaAbs = abs + META_SUFFIX;
|
|
244
|
+
await pipeline(
|
|
245
|
+
Readable.from([JSON.stringify({ contentType })]),
|
|
246
|
+
createWriteStream(metaAbs),
|
|
247
|
+
);
|
|
248
|
+
} catch {
|
|
249
|
+
// A meta-write failure is non-fatal: `get` falls back to octet-stream.
|
|
250
|
+
}
|
|
251
|
+
return { key, size, contentType };
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async get(key) {
|
|
255
|
+
const abs = assertSafeKey(dir, key);
|
|
256
|
+
let info;
|
|
257
|
+
try {
|
|
258
|
+
info = await stat(abs);
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (!info.isFile()) return null;
|
|
263
|
+
let contentType = 'application/octet-stream';
|
|
264
|
+
try {
|
|
265
|
+
const metaAbs = abs + META_SUFFIX;
|
|
266
|
+
const metaInfo = await stat(metaAbs);
|
|
267
|
+
if (metaInfo.isFile()) {
|
|
268
|
+
// The meta sidecar is tiny (a JSON object with a content type), so a
|
|
269
|
+
// small read here is fine and does not touch the object's bytes.
|
|
270
|
+
const { readFile } = await import('node:fs/promises');
|
|
271
|
+
const parsed = JSON.parse(await readFile(metaAbs, 'utf8'));
|
|
272
|
+
if (parsed && typeof parsed.contentType === 'string') contentType = parsed.contentType;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// No / unreadable meta: keep the octet-stream default.
|
|
276
|
+
}
|
|
277
|
+
// The body is a STREAM, so a serving route streams it to the client
|
|
278
|
+
// without reading the whole file into memory.
|
|
279
|
+
return {
|
|
280
|
+
body: Readable.toWeb(createReadStream(abs)),
|
|
281
|
+
size: info.size,
|
|
282
|
+
contentType,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async delete(key) {
|
|
287
|
+
const abs = assertSafeKey(dir, key);
|
|
288
|
+
// Idempotent: a missing file is not an error.
|
|
289
|
+
try { await unlink(abs); } catch {}
|
|
290
|
+
try { await unlink(abs + META_SUFFIX); } catch {}
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async has(key) {
|
|
294
|
+
const abs = assertSafeKey(dir, key);
|
|
295
|
+
try {
|
|
296
|
+
const info = await stat(abs);
|
|
297
|
+
return info.isFile();
|
|
298
|
+
} catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
url(key) {
|
|
304
|
+
// Validate the key so `url()` cannot mint a traversal path either.
|
|
305
|
+
assertSafeKey(dir, key);
|
|
306
|
+
return `${baseUrl}/${key}`;
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @type {FileStore | null} */
|
|
312
|
+
let _defaultFileStore = null;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Get the default file store. A `diskStore` rooted at `<cwd>/.webjs/uploads`
|
|
316
|
+
* unless explicitly set via `setFileStore()`. No auto-detection: the app
|
|
317
|
+
* decides. (Add the uploads directory to `.gitignore`.)
|
|
318
|
+
*
|
|
319
|
+
* @returns {FileStore}
|
|
320
|
+
*/
|
|
321
|
+
export function getFileStore() {
|
|
322
|
+
if (!_defaultFileStore) _defaultFileStore = diskStore();
|
|
323
|
+
return _defaultFileStore;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Set the default file store. Call at app startup to point uploads at a custom
|
|
328
|
+
* directory or an S3-compatible backend:
|
|
329
|
+
*
|
|
330
|
+
* ```js
|
|
331
|
+
* import { setFileStore, diskStore } from '@webjsdev/server';
|
|
332
|
+
* setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
333
|
+
* ```
|
|
334
|
+
*
|
|
335
|
+
* @param {FileStore} store
|
|
336
|
+
*/
|
|
337
|
+
export function setFileStore(store) {
|
|
338
|
+
_defaultFileStore = store;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Generate a random, opaque, traversal-safe storage key, preserving a sanitized
|
|
343
|
+
* (whitelisted) extension from `filename` when present. This is the RECOMMENDED
|
|
344
|
+
* way to derive a key: never trust a user-supplied filename as a key directly.
|
|
345
|
+
*
|
|
346
|
+
* A malicious `filename` (`'../../x.sh'`, `'a/b.png'`, `'evil\0.png'`) yields a
|
|
347
|
+
* fully opaque `<uuid>` or `<uuid>.<ext>` key with no path component and only a
|
|
348
|
+
* whitelisted extension, so the result is always safe to pass to `put`.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} [filename] The original filename (its extension may be preserved).
|
|
351
|
+
* @returns {string} A `<crypto.randomUUID()>` key, with a `.<ext>` suffix when the
|
|
352
|
+
* original extension is whitelisted.
|
|
353
|
+
*/
|
|
354
|
+
export function generateKey(filename) {
|
|
355
|
+
const id = randomUUID();
|
|
356
|
+
if (typeof filename !== 'string' || !filename) return id;
|
|
357
|
+
// Take only the basename's extension; `extname` ignores directory parts, so a
|
|
358
|
+
// path-y filename cannot inject a separator. Strip the leading dot, lowercase,
|
|
359
|
+
// and accept ONLY a whitelisted alnum extension.
|
|
360
|
+
const ext = extname(filename).replace(/^\./, '').toLowerCase();
|
|
361
|
+
if (ext && /^[a-z0-9]+$/.test(ext) && SAFE_EXTENSIONS.has(ext)) {
|
|
362
|
+
return `${id}.${ext}`;
|
|
363
|
+
}
|
|
364
|
+
return id;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Signed URLs (HMAC-SHA256 over key + expiry, base64url). A serving route can
|
|
369
|
+
// gate access without a session lookup: it verifies the signature, then streams
|
|
370
|
+
// `get(key)`. Minimal + standards-based; no library.
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/** base64url-encode a Buffer (no padding, URL-safe alphabet). */
|
|
374
|
+
function b64url(buf) {
|
|
375
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Compute the HMAC-SHA256 signature (base64url) over `key.exp`. */
|
|
379
|
+
function signKeyExp(key, exp, secret) {
|
|
380
|
+
return b64url(createHmac('sha256', secret).update(`${key}.${exp}`).digest());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Mint a signed, expiring URL for a stored object. The signature covers the
|
|
385
|
+
* exact key AND the expiry, so neither can be tampered with. The returned string
|
|
386
|
+
* is `<base>?key=<key>&exp=<unixSeconds>&sig=<base64url>` (base defaults to
|
|
387
|
+
* `getFileStore().url(key)`, so it lands on the app's serving route).
|
|
388
|
+
*
|
|
389
|
+
* @param {string} key The storage key.
|
|
390
|
+
* @param {{ secret: string, expiresIn?: number, base?: string }} opts
|
|
391
|
+
* `secret` signs the URL; `expiresIn` is seconds from now (default 3600);
|
|
392
|
+
* `base` is the URL path the query is appended to (default `getFileStore().url(key)`).
|
|
393
|
+
* @returns {string} The signed URL.
|
|
394
|
+
*/
|
|
395
|
+
export function signedUrl(key, opts) {
|
|
396
|
+
if (!opts || !opts.secret) throw new Error('signedUrl: a secret is required');
|
|
397
|
+
if (typeof key !== 'string' || !key) throw new Error('signedUrl: a key is required');
|
|
398
|
+
// Default to 1 hour ONLY when expiresIn is omitted. An explicit value is
|
|
399
|
+
// honored literally, so `0` / a negative number fails CLOSED (the minted exp
|
|
400
|
+
// is at or before now, so the URL is already expired) instead of silently
|
|
401
|
+
// granting a 1-hour URL.
|
|
402
|
+
const expiresIn = typeof opts.expiresIn === 'number' && Number.isFinite(opts.expiresIn)
|
|
403
|
+
? opts.expiresIn
|
|
404
|
+
: 3600;
|
|
405
|
+
const exp = Math.floor(Date.now() / 1000) + Math.floor(expiresIn);
|
|
406
|
+
const sig = signKeyExp(key, exp, opts.secret);
|
|
407
|
+
const base = opts.base != null ? opts.base : getFileStore().url(key);
|
|
408
|
+
const sep2 = base.includes('?') ? '&' : '?';
|
|
409
|
+
return `${base}${sep2}key=${encodeURIComponent(key)}&exp=${exp}&sig=${sig}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Verify a signed URL (or its parsed params). Returns `{ valid, key, reason }`.
|
|
414
|
+
* Fails on a wrong secret (signature mismatch), an expired URL, a tampered key,
|
|
415
|
+
* or missing params. The comparison is constant-time.
|
|
416
|
+
*
|
|
417
|
+
* @param {string | URL | URLSearchParams | { key?: string, exp?: string | number, sig?: string }} input
|
|
418
|
+
* A full URL string, a `URL`, a `URLSearchParams`, or a `{ key, exp, sig }` object.
|
|
419
|
+
* @param {string} secret The same secret passed to `signedUrl`.
|
|
420
|
+
* @returns {{ valid: boolean, key: string | null, reason?: string }}
|
|
421
|
+
*/
|
|
422
|
+
export function verifySignedUrl(input, secret) {
|
|
423
|
+
if (!secret) return { valid: false, key: null, reason: 'no secret' };
|
|
424
|
+
let key = null;
|
|
425
|
+
let exp = null;
|
|
426
|
+
let sig = null;
|
|
427
|
+
try {
|
|
428
|
+
if (typeof input === 'string') {
|
|
429
|
+
// Parse against a dummy base so a bare path string works too.
|
|
430
|
+
const u = new URL(input, 'http://x.invalid');
|
|
431
|
+
key = u.searchParams.get('key');
|
|
432
|
+
exp = u.searchParams.get('exp');
|
|
433
|
+
sig = u.searchParams.get('sig');
|
|
434
|
+
} else if (input instanceof URL) {
|
|
435
|
+
key = input.searchParams.get('key');
|
|
436
|
+
exp = input.searchParams.get('exp');
|
|
437
|
+
sig = input.searchParams.get('sig');
|
|
438
|
+
} else if (input instanceof URLSearchParams) {
|
|
439
|
+
key = input.get('key');
|
|
440
|
+
exp = input.get('exp');
|
|
441
|
+
sig = input.get('sig');
|
|
442
|
+
} else if (input && typeof input === 'object') {
|
|
443
|
+
key = input.key != null ? String(input.key) : null;
|
|
444
|
+
exp = input.exp != null ? String(input.exp) : null;
|
|
445
|
+
sig = input.sig != null ? String(input.sig) : null;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
return { valid: false, key: null, reason: 'unparseable' };
|
|
449
|
+
}
|
|
450
|
+
if (!key || !exp || !sig) return { valid: false, key: null, reason: 'missing params' };
|
|
451
|
+
const expNum = Number(exp);
|
|
452
|
+
if (!Number.isFinite(expNum)) return { valid: false, key: null, reason: 'bad expiry' };
|
|
453
|
+
// `>=` so an exp at the current second is expired: a URL minted with
|
|
454
|
+
// `expiresIn: 0` (exp === now) fails closed instead of being valid for the
|
|
455
|
+
// remainder of the current second.
|
|
456
|
+
if (Math.floor(Date.now() / 1000) >= expNum) return { valid: false, key, reason: 'expired' };
|
|
457
|
+
// Recompute the expected signature over the SAME key + exp and compare in
|
|
458
|
+
// constant time. A tampered key changes the expected signature, so it fails.
|
|
459
|
+
const expected = signKeyExp(key, expNum, secret);
|
|
460
|
+
const a = Buffer.from(sig);
|
|
461
|
+
const b = Buffer.from(expected);
|
|
462
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
463
|
+
return { valid: false, key, reason: 'signature mismatch' };
|
|
464
|
+
}
|
|
465
|
+
return { valid: true, key };
|
|
466
|
+
}
|