@webjsdev/server 0.8.14 → 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 +707 -0
- package/index.js +1 -0
- package/package.json +15 -4
- package/src/actions.js +12 -1
- package/src/check.d.ts +38 -0
- package/src/file-storage.js +466 -0
- package/src/testing.d.ts +123 -0
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
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"default": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"./check": {
|
|
14
|
+
"types": "./src/check.d.ts",
|
|
15
|
+
"default": "./src/check.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./src/testing.d.ts",
|
|
19
|
+
"default": "./src/testing.js"
|
|
20
|
+
},
|
|
11
21
|
"./webjs-config.schema.json": "./webjs-config.schema.json"
|
|
12
22
|
},
|
|
13
23
|
"files": [
|
|
14
24
|
"index.js",
|
|
25
|
+
"index.d.ts",
|
|
15
26
|
"src",
|
|
16
27
|
"webjs-config.schema.json",
|
|
17
28
|
"README.md"
|
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;
|
package/src/check.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type overlay for `@webjsdev/server/check` (the convention validator backing
|
|
3
|
+
* `webjs check`, exported on its own subpath so the CLI can load it without
|
|
4
|
+
* booting the full server).
|
|
5
|
+
*
|
|
6
|
+
* The runtime is packages/server/src/check.js (JSDoc-annotated JavaScript);
|
|
7
|
+
* this overlay exists so a TypeScript consumer's `import { checkConventions }
|
|
8
|
+
* from '@webjsdev/server/check'` resolves precise types instead of emitting
|
|
9
|
+
* TS7016. Zero runtime cost.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single convention violation reported by `checkConventions`. */
|
|
13
|
+
export interface Violation {
|
|
14
|
+
/** The rule name that fired (e.g. `no-browser-globals-in-render`). */
|
|
15
|
+
rule: string;
|
|
16
|
+
/** The offending file path. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** What is wrong. */
|
|
19
|
+
message: string;
|
|
20
|
+
/** How to fix it. */
|
|
21
|
+
fix: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A rule's name + human description, for help text / docs. */
|
|
25
|
+
export interface RuleDescriptor {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** All available correctness rules with descriptions. */
|
|
31
|
+
export declare const RULES: RuleDescriptor[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run every correctness check against an app directory, returning the list of
|
|
35
|
+
* violations (empty when the app is clean). Correctness-only; report-only, no
|
|
36
|
+
* autofix.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkConventions(appDir: string): Promise<Violation[]>;
|
|
@@ -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
|
+
}
|