@server/next 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.d.ts +9 -15
  2. package/index.js +312 -258
  3. package/package.json +20 -3
package/index.d.ts CHANGED
@@ -5,13 +5,6 @@ type LimitOptions = {
5
5
  minSize?: number | string;
6
6
  fileType?: string[];
7
7
  };
8
- declare class UploadPipeline {
9
- private _bucket;
10
- private _limits;
11
- constructor(bucket?: Bucket | string | null);
12
- limit(options: LimitOptions): this;
13
- processFile(originalName: string, data: Buffer, contentType: string): Promise<UploadedFile>;
14
- }
15
8
 
16
9
  type CookieOptions = string | string[] | Cookie | Cookie[] | null;
17
10
  interface ResponseData {
@@ -24,7 +17,7 @@ declare class Reply$1 {
24
17
  status(status: number): this;
25
18
  type(type?: string): this;
26
19
  download(name?: string): this;
27
- headers(key: string | Record<string, string>, value?: string): this;
20
+ headers(key: string | Record<string, string | string[]>, value?: string | string[]): this;
28
21
  cache(value: CacheOption): this;
29
22
  cookies(key: string | Record<string, CookieOptions>, value?: CookieOptions): this;
30
23
  json(body: unknown): Response;
@@ -88,18 +81,16 @@ type Cookie = {
88
81
  };
89
82
  type RouterMethod = "*" | Method;
90
83
  type FileInfo = {
91
- exists: boolean;
92
84
  size: number;
93
- date: Date | null;
94
- type?: string | null;
85
+ type: string | null;
86
+ modified: Date;
95
87
  };
96
88
  type BucketFile = {
97
89
  readonly path: string;
98
- readonly id: string;
99
90
  readonly name: string;
100
91
  readonly type?: string;
101
92
  exists(): Promise<boolean>;
102
- info?(): Promise<FileInfo>;
93
+ info?(): Promise<FileInfo | null>;
103
94
  write(content: string | Buffer | ReadableStream, options?: {
104
95
  type?: string;
105
96
  }): Promise<void>;
@@ -114,7 +105,6 @@ type Bucket = {
114
105
  };
115
106
  type UploadedFile = {
116
107
  name: string;
117
- id: string;
118
108
  path: string;
119
109
  type: string;
120
110
  size: number;
@@ -195,6 +185,7 @@ type SecurityOptions = {
195
185
  referrerPolicy?: boolean | string;
196
186
  hsts?: boolean | string;
197
187
  xssProtection?: boolean;
188
+ traversalProtection?: boolean;
198
189
  csp?: boolean | string;
199
190
  coop?: boolean | string;
200
191
  corp?: boolean | string;
@@ -202,6 +193,7 @@ type SecurityOptions = {
202
193
  };
203
194
  type SecuritySettings = {
204
195
  trustProxy: boolean;
196
+ traversalProtection: boolean;
205
197
  headers: Record<string, string>;
206
198
  hsts: string | null;
207
199
  };
@@ -232,7 +224,9 @@ type Settings = {
232
224
  port: number;
233
225
  secret: string;
234
226
  public?: Bucket;
235
- uploads?: Bucket | UploadPipeline;
227
+ uploads?: ({
228
+ bucket: Bucket;
229
+ } & LimitOptions) | null;
236
230
  store?: KVStore;
237
231
  cookies?: KVStore;
238
232
  session?: {
package/index.js CHANGED
@@ -39,6 +39,10 @@ var ServerError_default = TypedServerError;
39
39
 
40
40
  // src/errors/index.ts
41
41
  ServerError_default.extend({
42
+ PATH_TRAVERSAL: {
43
+ status: 400,
44
+ message: "The route param '{param}' tries to climb the path ('{value}'). If this route legitimately receives paths, set security: { traversalProtection: false }"
45
+ },
42
46
  NO_STORE: "You need a 'store' to write 'ctx.session'",
43
47
  NO_STORE_WRITE: "You need a 'store' to write 'ctx.session.{key}'",
44
48
  NO_STORE_READ: "You need a 'store' to read 'ctx.session.{key}'",
@@ -76,6 +80,7 @@ ServerError_default.extend({
76
80
  REGISTER_INVALID_PASSWORD: "The password you wrote is not correct",
77
81
  REGISTER_EMAIL_EXISTS: "Email is already registered"
78
82
  });
83
+ var errors_default = ServerError_default;
79
84
 
80
85
  // src/polyfill.ts
81
86
  globalThis.env = {};
@@ -98,191 +103,6 @@ var StatusError = class extends Error {
98
103
  }
99
104
  };
100
105
 
101
- // src/helpers/bucket.ts
102
- import * as fs from "fs";
103
- import * as fsp from "fs/promises";
104
- import * as path from "path";
105
-
106
- // src/helpers/mimes.ts
107
- var mimes_default = {
108
- aac: "audio/aac",
109
- abw: "application/x-abiword",
110
- arc: "application/x-freearc",
111
- avif: "image/avif",
112
- avi: "video/x-msvideo",
113
- azw: "application/vnd.amazon.ebook",
114
- bin: "application/octet-stream",
115
- bmp: "image/bmp",
116
- bz: "application/x-bzip",
117
- bz2: "application/x-bzip2",
118
- cda: "application/x-cdf",
119
- csh: "application/x-csh",
120
- css: "text/css",
121
- csv: "text/csv",
122
- doc: "application/msword",
123
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
124
- eot: "application/vnd.ms-fontobject",
125
- epub: "application/epub+zip",
126
- gz: "application/gzip",
127
- gif: "image/gif",
128
- htm: "text/html",
129
- html: "text/html",
130
- ico: "image/vnd.microsoft.icon",
131
- ics: "text/calendar",
132
- jar: "application/java-archive",
133
- jpeg: "image/jpeg",
134
- jpg: "image/jpeg",
135
- js: "text/javascript",
136
- json: "application/json",
137
- jsonld: "application/ld+json",
138
- md: "text/markdown",
139
- mid: "audio/midi",
140
- midi: "audio/midi",
141
- mjs: "text/javascript",
142
- mp3: "audio/mpeg",
143
- mp4: "video/mp4",
144
- mpeg: "video/mpeg",
145
- mpkg: "application/vnd.apple.installer+xml",
146
- odp: "application/vnd.oasis.opendocument.presentation",
147
- ods: "application/vnd.oasis.opendocument.spreadsheet",
148
- odt: "application/vnd.oasis.opendocument.text",
149
- oga: "audio/ogg",
150
- ogv: "video/ogg",
151
- ogx: "application/ogg",
152
- opus: "audio/opus",
153
- otf: "font/otf",
154
- png: "image/png",
155
- pdf: "application/pdf",
156
- php: "application/x-httpd-php",
157
- ppt: "application/vnd.ms-powerpoint",
158
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
159
- rar: "application/vnd.rar",
160
- rtf: "application/rtf",
161
- sh: "application/x-sh",
162
- svg: "image/svg+xml",
163
- tar: "application/x-tar",
164
- text: "text/plain",
165
- tif: "image/tiff",
166
- tiff: "image/tiff",
167
- ts: "video/mp2t",
168
- ttf: "font/ttf",
169
- txt: "text/plain",
170
- vsd: "application/vnd.visio",
171
- wav: "audio/wav",
172
- weba: "audio/webm",
173
- webm: "video/webm",
174
- webp: "image/webp",
175
- woff: "font/woff",
176
- woff2: "font/woff2",
177
- xhtml: "application/xhtml+xml",
178
- xls: "application/vnd.ms-excel",
179
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
180
- xml: "application/xml",
181
- xul: "application/vnd.mozilla.xul+xml",
182
- zip: "application/zip",
183
- "3gp": "video/3gpp",
184
- "3g2": "video/3gpp2",
185
- "7z": "application/x-7z-compressed"
186
- };
187
-
188
- // src/helpers/bucket.ts
189
- function localBucket(root) {
190
- const base = path.resolve(root);
191
- const resolveKey = (name) => {
192
- if (!name) throw new Error("File name is required");
193
- const full = path.resolve(base, name.replace(/^\/+/, ""));
194
- if (full !== base && !full.startsWith(base + path.sep)) {
195
- throw new Error(`Path "${name}" escapes the bucket root`);
196
- }
197
- return full;
198
- };
199
- const file2 = (name, win) => {
200
- const full = resolveKey(name);
201
- const type2 = mimes_default[path.extname(name).slice(1).toLowerCase()];
202
- const read = () => {
203
- let opts;
204
- if (win) {
205
- opts = { start: win.start };
206
- if (Number.isFinite(win.end)) opts.end = Math.max(win.start, win.end - 1);
207
- }
208
- const nodeStream = fs.createReadStream(full, opts);
209
- return new ReadableStream({
210
- start(controller) {
211
- nodeStream.on("data", (chunk) => controller.enqueue(chunk));
212
- nodeStream.on("end", () => controller.close());
213
- nodeStream.on("error", (err) => controller.error(err));
214
- },
215
- cancel() {
216
- nodeStream.destroy();
217
- }
218
- });
219
- };
220
- return {
221
- path: full,
222
- id: name.replace(/^\/+/, ""),
223
- name: path.basename(name),
224
- type: type2,
225
- async exists() {
226
- const stats = await fsp.stat(full).catch(() => null);
227
- return !!stats?.isFile();
228
- },
229
- async info() {
230
- const stats = await fsp.stat(full).catch(() => null);
231
- const exists = !!stats?.isFile();
232
- const total = stats?.size ?? 0;
233
- const size = win ? Math.max(0, Math.min(win.end, total) - win.start) : total;
234
- return { exists, size, date: stats?.mtime ?? null, type: type2 };
235
- },
236
- // Read-only view of [start, end), composed relative to the current window.
237
- slice(start, end) {
238
- const base2 = win?.start ?? 0;
239
- const cap = win?.end ?? Number.POSITIVE_INFINITY;
240
- const s = Math.min(cap, base2 + Math.max(0, start));
241
- const e = end === void 0 ? cap : Math.min(cap, base2 + end);
242
- return file2(name, { start: s, end: e });
243
- },
244
- async write(content) {
245
- await fsp.mkdir(path.dirname(full), { recursive: true });
246
- if (content instanceof ReadableStream) {
247
- const writable = fs.createWriteStream(full);
248
- for await (const chunk of content) {
249
- writable.write(chunk);
250
- }
251
- await new Promise((resolve2, reject) => {
252
- writable.on("error", reject);
253
- writable.end(() => resolve2());
254
- });
255
- return;
256
- }
257
- await fsp.writeFile(full, content);
258
- },
259
- stream() {
260
- return read();
261
- },
262
- async bytes() {
263
- if (win) return new Uint8Array(await new Response(read()).arrayBuffer());
264
- return new Uint8Array(await fsp.readFile(full));
265
- },
266
- async remove() {
267
- await fsp.unlink(full).catch(() => {
268
- });
269
- }
270
- };
271
- };
272
- return {
273
- file: file2,
274
- folder: (prefix) => localBucket(path.join(base, prefix))
275
- };
276
- }
277
- function bucket(root) {
278
- if (!root) return null;
279
- if (typeof root === "string") return localBucket(root);
280
- if (typeof root.file === "function") return root;
281
- throw new Error(
282
- "Invalid bucket: pass a directory path or a `bucket` instance (with .file())"
283
- );
284
- }
285
-
286
106
  // src/helpers/createId.ts
287
107
  var alphabet = "useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict";
288
108
  var random = (bytes) => crypto.getRandomValues(new Uint8Array(bytes));
@@ -349,52 +169,36 @@ async function saveFileToBucket(originalName, data, bucket2, contentType) {
349
169
  await file2.write(data, { type: contentType });
350
170
  return {
351
171
  name: originalName,
352
- id,
353
172
  path: file2.path,
354
173
  type: contentType,
355
174
  size: data.length
356
175
  };
357
176
  }
358
- var UploadPipeline = class {
359
- _bucket;
360
- _limits = {};
361
- constructor(bucket2) {
362
- this._bucket = bucket(bucket2 ?? void 0);
177
+ function validateFile(originalName, data, contentType, limits) {
178
+ const { maxSize, minSize, fileType } = limits;
179
+ if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
180
+ throw new Error(
181
+ `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
182
+ );
363
183
  }
364
- limit(options) {
365
- this._limits = { ...this._limits, ...options };
366
- return this;
184
+ if (minSize !== void 0 && data.length < parseBytes(minSize)) {
185
+ throw new Error(
186
+ `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
187
+ );
367
188
  }
368
- async processFile(originalName, data, contentType) {
369
- const { maxSize, minSize, fileType } = this._limits;
370
- if (maxSize !== void 0 && data.length > parseBytes(maxSize)) {
371
- throw new Error(
372
- `File "${originalName}" is too large (${data.length} bytes, limit is ${maxSize})`
373
- );
374
- }
375
- if (minSize !== void 0 && data.length < parseBytes(minSize)) {
189
+ if (fileType && fileType.length > 0) {
190
+ const ext2 = getExt(originalName);
191
+ const mime = contentType.toLowerCase();
192
+ const allowed = fileType.some(
193
+ (t) => t.toLowerCase() === mime || t.toLowerCase() === ext2
194
+ );
195
+ if (!allowed) {
376
196
  throw new Error(
377
- `File "${originalName}" is too small (${data.length} bytes, minimum is ${minSize})`
197
+ `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
378
198
  );
379
199
  }
380
- if (fileType && fileType.length > 0) {
381
- const ext2 = getExt(originalName);
382
- const mime = contentType.toLowerCase();
383
- const allowed = fileType.some(
384
- (t) => t.toLowerCase() === mime || t.toLowerCase() === ext2
385
- );
386
- if (!allowed) {
387
- throw new Error(
388
- `File type not allowed for "${originalName}" (got "${contentType}", allowed: ${fileType.join(", ")})`
389
- );
390
- }
391
- }
392
- if (!this._bucket) {
393
- throw new Error(`No upload destination configured (missing bucket)`);
394
- }
395
- return saveFileToBucket(originalName, data, this._bucket, contentType);
396
200
  }
397
- };
201
+ }
398
202
 
399
203
  // src/helpers/bodyLimit.ts
400
204
  var INF = Number.POSITIVE_INFINITY;
@@ -416,6 +220,88 @@ var tooLarge = (max) => new StatusError(
416
220
  413
417
221
  );
418
222
 
223
+ // src/helpers/mimes.ts
224
+ var mimes_default = {
225
+ aac: "audio/aac",
226
+ abw: "application/x-abiword",
227
+ arc: "application/x-freearc",
228
+ avif: "image/avif",
229
+ avi: "video/x-msvideo",
230
+ azw: "application/vnd.amazon.ebook",
231
+ bin: "application/octet-stream",
232
+ bmp: "image/bmp",
233
+ bz: "application/x-bzip",
234
+ bz2: "application/x-bzip2",
235
+ cda: "application/x-cdf",
236
+ csh: "application/x-csh",
237
+ css: "text/css; charset=utf-8",
238
+ csv: "text/csv; charset=utf-8",
239
+ doc: "application/msword",
240
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
241
+ eot: "application/vnd.ms-fontobject",
242
+ epub: "application/epub+zip",
243
+ gz: "application/gzip",
244
+ gif: "image/gif",
245
+ htm: "text/html; charset=utf-8",
246
+ html: "text/html; charset=utf-8",
247
+ ico: "image/vnd.microsoft.icon",
248
+ ics: "text/calendar; charset=utf-8",
249
+ jar: "application/java-archive",
250
+ jpeg: "image/jpeg",
251
+ jpg: "image/jpeg",
252
+ js: "text/javascript; charset=utf-8",
253
+ json: "application/json",
254
+ jsonld: "application/ld+json",
255
+ md: "text/markdown; charset=utf-8",
256
+ mid: "audio/midi",
257
+ midi: "audio/midi",
258
+ mjs: "text/javascript; charset=utf-8",
259
+ mp3: "audio/mpeg",
260
+ mp4: "video/mp4",
261
+ mpeg: "video/mpeg",
262
+ mpkg: "application/vnd.apple.installer+xml",
263
+ odp: "application/vnd.oasis.opendocument.presentation",
264
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
265
+ odt: "application/vnd.oasis.opendocument.text",
266
+ oga: "audio/ogg",
267
+ ogv: "video/ogg",
268
+ ogx: "application/ogg",
269
+ opus: "audio/opus",
270
+ otf: "font/otf",
271
+ png: "image/png",
272
+ pdf: "application/pdf",
273
+ php: "application/x-httpd-php",
274
+ ppt: "application/vnd.ms-powerpoint",
275
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
276
+ rar: "application/vnd.rar",
277
+ rtf: "application/rtf",
278
+ sh: "application/x-sh",
279
+ svg: "image/svg+xml",
280
+ tar: "application/x-tar",
281
+ text: "text/plain; charset=utf-8",
282
+ tif: "image/tiff",
283
+ tiff: "image/tiff",
284
+ ts: "video/mp2t",
285
+ ttf: "font/ttf",
286
+ txt: "text/plain; charset=utf-8",
287
+ vsd: "application/vnd.visio",
288
+ wav: "audio/wav",
289
+ weba: "audio/webm",
290
+ webm: "video/webm",
291
+ webp: "image/webp",
292
+ woff: "font/woff",
293
+ woff2: "font/woff2",
294
+ xhtml: "application/xhtml+xml",
295
+ xls: "application/vnd.ms-excel",
296
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
297
+ xml: "application/xml",
298
+ xul: "application/vnd.mozilla.xul+xml",
299
+ zip: "application/zip",
300
+ "3gp": "video/3gpp",
301
+ "3g2": "video/3gpp2",
302
+ "7z": "application/x-7z-compressed"
303
+ };
304
+
419
305
  // src/helpers/parseBody.ts
420
306
  function getBoundary(header) {
421
307
  if (!header) return null;
@@ -494,15 +380,15 @@ function addField(body, name, value) {
494
380
  if (!Array.isArray(body[name])) body[name] = [body[name]];
495
381
  body[name].push(value);
496
382
  }
497
- function startPart(headerStr, dest) {
383
+ function startPart(headerStr, bucket2, limits) {
498
384
  const name = getMatching(headerStr, /name="(.+?)"/).trim().replace(/\[\]$/, "");
499
385
  if (!name) return { kind: "skip" };
500
386
  const filename = getMatching(headerStr, /filename="(.+?)"/).trim();
501
387
  if (!filename) return { kind: "text", name, chunks: [] };
502
388
  const type2 = getMatching(headerStr, /Content-Type:\s*([^\r\n]+)/i).trim() || "application/octet-stream";
503
- if (!dest) return { kind: "drop" };
504
- if (dest instanceof UploadPipeline) {
505
- return { kind: "pipefile", name, filename, type: type2, pipeline: dest, chunks: [] };
389
+ if (!bucket2) return { kind: "drop" };
390
+ if (limits) {
391
+ return { kind: "validated", name, filename, type: type2, bucket: bucket2, limits, chunks: [] };
506
392
  }
507
393
  const id = `${createId()}${getExt(filename)}`;
508
394
  let controller;
@@ -511,7 +397,7 @@ function startPart(headerStr, dest) {
511
397
  controller = c;
512
398
  }
513
399
  });
514
- const file2 = dest.file(id);
400
+ const file2 = bucket2.file(id);
515
401
  return {
516
402
  kind: "file",
517
403
  name,
@@ -526,7 +412,7 @@ function startPart(headerStr, dest) {
526
412
  }
527
413
  function feedPart(part, data) {
528
414
  if (data.length === 0) return;
529
- if (part.kind === "text" || part.kind === "pipefile") part.chunks.push(data);
415
+ if (part.kind === "text" || part.kind === "validated") part.chunks.push(data);
530
416
  else if (part.kind === "file") {
531
417
  part.controller.enqueue(data);
532
418
  part.size += data.length;
@@ -537,16 +423,16 @@ async function endPart(part, body) {
537
423
  const buf = Buffer.concat(part.chunks);
538
424
  const value = isProbablyText(buf) ? buf.toString("utf-8").trim() : buf;
539
425
  addField(body, part.name, value);
540
- } else if (part.kind === "pipefile") {
426
+ } else if (part.kind === "validated") {
541
427
  const buf = Buffer.concat(part.chunks);
542
- const ref = await part.pipeline.processFile(part.filename, buf, part.type);
428
+ validateFile(part.filename, buf, part.type, part.limits);
429
+ const ref = await saveFileToBucket(part.filename, buf, part.bucket, part.type);
543
430
  addField(body, part.name, ref);
544
431
  } else if (part.kind === "file") {
545
432
  part.controller.close();
546
433
  await part.write;
547
434
  addField(body, part.name, {
548
435
  name: part.filename,
549
- id: part.id,
550
436
  path: part.file.path,
551
437
  type: part.type,
552
438
  size: part.size
@@ -554,7 +440,7 @@ async function endPart(part, body) {
554
440
  }
555
441
  }
556
442
  var BREAK = Buffer.from("\r\n\r\n");
557
- async function parseMultipart(stream, boundary, dest, max = INF) {
443
+ async function parseMultipart(stream, boundary, bucket2, limits, max = INF) {
558
444
  const delim = Buffer.from(`\r
559
445
  --${boundary}`);
560
446
  const body = {};
@@ -591,7 +477,7 @@ async function parseMultipart(stream, boundary, dest, max = INF) {
591
477
  } else if (state === "headers") {
592
478
  const i = buf.indexOf(BREAK);
593
479
  if (i === -1) break;
594
- part = startPart(buf.subarray(0, i).toString("utf-8"), dest);
480
+ part = startPart(buf.subarray(0, i).toString("utf-8"), bucket2, limits);
595
481
  buf = buf.subarray(i + BREAK.length);
596
482
  state = "body";
597
483
  advanced = true;
@@ -637,12 +523,25 @@ async function streamToBucket(stream, type2, bucket2) {
637
523
  controller.close();
638
524
  await write;
639
525
  if (!size) return void 0;
640
- return { name: id, id, path: file2.path, type: type2, size };
526
+ return { name: id, path: file2.path, type: type2, size };
641
527
  }
642
528
  async function parseBody(input, contentType, dest, max = INF) {
643
529
  const type2 = Array.isArray(contentType) ? contentType[0] : contentType;
530
+ let bucket2;
531
+ let limits;
532
+ if (dest && "bucket" in dest) {
533
+ bucket2 = dest.bucket;
534
+ const { maxSize, minSize, fileType } = dest;
535
+ if (maxSize != null || minSize != null || fileType != null) {
536
+ limits = { maxSize, minSize, fileType };
537
+ }
538
+ } else {
539
+ bucket2 = dest;
540
+ }
644
541
  const boundary = type2 && /multipart\/form-data/i.test(type2) ? getBoundary(type2) : null;
645
- if (boundary) return parseMultipart(toStream(input), boundary, dest, max);
542
+ if (boundary) {
543
+ return parseMultipart(toStream(input), boundary, bucket2, limits, max);
544
+ }
646
545
  if (!type2 || /^text\//i.test(type2)) {
647
546
  const buf = await toBuffer(input, max);
648
547
  return buf.length ? buf.toString("utf-8") : void 0;
@@ -655,15 +554,18 @@ async function parseBody(input, contentType, dest, max = INF) {
655
554
  const buf = await toBuffer(input, max);
656
555
  return buf.length ? parseUrlEncoded(buf.toString("utf-8")) : void 0;
657
556
  }
658
- if (!dest) {
557
+ if (!bucket2) {
659
558
  const buf = await toBuffer(input, max);
660
559
  return buf.length ? buf : void 0;
661
560
  }
662
- if (dest instanceof UploadPipeline) {
561
+ if (limits) {
663
562
  const buf = await toBuffer(input);
664
- return buf.length ? dest.processFile(`upload${extFromType(type2)}`, buf, type2) : void 0;
563
+ if (!buf.length) return void 0;
564
+ const name = `upload${extFromType(type2)}`;
565
+ validateFile(name, buf, type2, limits);
566
+ return saveFileToBucket(name, buf, bucket2, type2);
665
567
  }
666
- return streamToBucket(toStream(input), type2, dest);
568
+ return streamToBucket(toStream(input), type2, bucket2);
667
569
  }
668
570
 
669
571
  // src/helpers/body.ts
@@ -760,7 +662,7 @@ function normalizeExpires(expires) {
760
662
  function createCookies(key, val) {
761
663
  if (val.value === null) val.expires = EXPIRED;
762
664
  const { value, path: path2, expires, maxAge, httpOnly, secure, sameSite } = val;
763
- let str = `${key}=${value || ""};Path=${path2 || "/"}`;
665
+ let str = `${key}=${encodeURIComponent(value ?? "")};Path=${path2 || "/"}`;
764
666
  if (typeof expires !== "undefined") str += `;Expires=${normalizeExpires(expires)}`;
765
667
  if (typeof maxAge === "number") str += `;Max-Age=${maxAge}`;
766
668
  if (httpOnly) str += ";HttpOnly";
@@ -823,6 +725,27 @@ function clientIp(headers2, opts = {}) {
823
725
  return normalize(remoteAddress);
824
726
  }
825
727
 
728
+ // src/helpers/disposition.ts
729
+ var encodeExt = (name) => encodeURIComponent(name).replace(
730
+ /['()*]/g,
731
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
732
+ );
733
+ function disposition(name) {
734
+ if (!name) return "attachment";
735
+ const clean = name.replace(/[\r\n]/g, "").split(/[\\/]/).pop() || "";
736
+ if (!clean) return "attachment";
737
+ const ascii = clean.replace(/[^\x20-\x7e]/g, "?");
738
+ const value = `attachment; filename="${ascii.replace(/["\\]/g, "\\$&")}"`;
739
+ if (clean === ascii) return value;
740
+ return `${value}; filename*=UTF-8''${encodeExt(clean)}`;
741
+ }
742
+
743
+ // src/helpers/isHtml.ts
744
+ var TAG = /^\s*<[a-zA-Z!/]/;
745
+ function isHtml(body) {
746
+ return TAG.test(body);
747
+ }
748
+
826
749
  // src/helpers/isReadableStream.ts
827
750
  function isReadableStream(obj) {
828
751
  return obj !== null && typeof obj === "object" && typeof obj.pipe === "function" && typeof obj.read === "function" && typeof obj.on === "function";
@@ -850,8 +773,7 @@ var Reply = class {
850
773
  download(name) {
851
774
  const ext2 = name?.split(".").pop();
852
775
  if (ext2 && !this.res.headers.get("content-type")) this.type(ext2);
853
- const filename = name ? `; filename="${encodeURIComponent(name)}"` : "";
854
- return this.headers("content-disposition", `attachment${filename}`);
776
+ return this.headers("content-disposition", disposition(name));
855
777
  }
856
778
  headers(key, value) {
857
779
  if (typeof key !== "string") {
@@ -859,10 +781,15 @@ var Reply = class {
859
781
  return this;
860
782
  }
861
783
  if (Array.isArray(value)) {
862
- Object.values(value).map((val) => this.headers(key, val));
784
+ this.res.headers.delete(key);
785
+ for (const val of value) this.res.headers.append(key, val);
863
786
  return this;
864
787
  }
865
- this.res.headers.append(key, value);
788
+ if (key.toLowerCase() === "set-cookie") {
789
+ this.res.headers.append(key, value);
790
+ } else {
791
+ this.res.headers.set(key, value);
792
+ }
866
793
  return this;
867
794
  }
868
795
  cache(value) {
@@ -884,25 +811,31 @@ var Reply = class {
884
811
  return this.headers("set-cookie", createCookies(key, value));
885
812
  }
886
813
  json(body) {
887
- return this.headers("content-type", "application/json").send(
888
- JSON.stringify(body)
889
- );
814
+ if (body === void 0) body = null;
815
+ if (!this.res.headers.get("content-type")) {
816
+ this.res.headers.set("content-type", "application/json");
817
+ }
818
+ return this.send(JSON.stringify(body));
890
819
  }
891
820
  redirect(path2) {
892
- return this.headers("location", path2).status(302).send();
821
+ this.headers("location", path2);
822
+ if (this.res.status == null) this.res.status = 302;
823
+ return this.send();
893
824
  }
894
825
  async file(path2) {
895
826
  if (typeof path2 !== "string") {
896
827
  if (!await path2.exists()) return this.status(404).send();
897
828
  return this.type(path2.type).send(path2.stream());
898
829
  }
830
+ if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path2)) return this.status(404).send();
899
831
  try {
900
832
  const fs2 = await import("fs");
901
833
  const ext2 = path2.split(".").pop();
834
+ await fs2.promises.access(path2);
902
835
  const stream = fs2.createReadStream(path2);
903
836
  return this.type(ext2).send(stream);
904
837
  } catch (error) {
905
- if (error.code === "ENOENT") {
838
+ if (error.code === "ENOENT" || error.code === "EISDIR") {
906
839
  return this.status(404).send();
907
840
  }
908
841
  throw error;
@@ -913,10 +846,10 @@ var Reply = class {
913
846
  if (status2 === 101 || status2 === 204 || status2 === 205 || status2 === 304) {
914
847
  return new Response(null, { status: status2, headers: headers2 });
915
848
  }
849
+ if (body === null) body = "";
916
850
  if (typeof body === "string") {
917
851
  if (!headers2.get("content-type")) {
918
- const isHtml = body.trim().startsWith("<");
919
- headers2.set("content-type", isHtml ? "text/html" : "text/plain");
852
+ headers2.set("content-type", isHtml(body) ? mimes_default.html : mimes_default.text);
920
853
  }
921
854
  if (!headers2.has("content-length")) {
922
855
  headers2.set("content-length", String(Buffer.byteLength(body)));
@@ -1516,6 +1449,106 @@ function parseAuthOptions(auth2, all) {
1516
1449
  };
1517
1450
  }
1518
1451
 
1452
+ // src/helpers/bucket.ts
1453
+ import * as fs from "fs";
1454
+ import * as fsp from "fs/promises";
1455
+ import * as path from "path";
1456
+ function localBucket(root, prefix = "") {
1457
+ const base = path.resolve(root);
1458
+ const resolveKey = (name) => {
1459
+ if (!name) throw new Error("File name is required");
1460
+ const full = path.resolve(base, name.replace(/^\/+/, ""));
1461
+ if (full !== base && !full.startsWith(base + path.sep)) {
1462
+ throw new Error(`Path "${name}" escapes the bucket root`);
1463
+ }
1464
+ return full;
1465
+ };
1466
+ const file2 = (name, win) => {
1467
+ const full = resolveKey(name);
1468
+ const key = prefix + name.replace(/^\/+/, "");
1469
+ const type2 = mimes_default[path.extname(name).slice(1).toLowerCase()];
1470
+ const read = () => {
1471
+ let opts;
1472
+ if (win) {
1473
+ opts = { start: win.start };
1474
+ if (Number.isFinite(win.end)) opts.end = Math.max(win.start, win.end - 1);
1475
+ }
1476
+ const nodeStream = fs.createReadStream(full, opts);
1477
+ return new ReadableStream({
1478
+ start(controller) {
1479
+ nodeStream.on("data", (chunk) => controller.enqueue(chunk));
1480
+ nodeStream.on("end", () => controller.close());
1481
+ nodeStream.on("error", (err) => controller.error(err));
1482
+ },
1483
+ cancel() {
1484
+ nodeStream.destroy();
1485
+ }
1486
+ });
1487
+ };
1488
+ return {
1489
+ path: key,
1490
+ name: path.basename(name),
1491
+ type: type2,
1492
+ async exists() {
1493
+ const stats = await fsp.stat(full).catch(() => null);
1494
+ return !!stats?.isFile();
1495
+ },
1496
+ async info() {
1497
+ const stats = await fsp.stat(full).catch(() => null);
1498
+ if (!stats?.isFile()) return null;
1499
+ const size = win ? Math.max(0, Math.min(win.end, stats.size) - win.start) : stats.size;
1500
+ return { size, type: type2 ?? null, modified: stats.mtime };
1501
+ },
1502
+ // Read-only view of [start, end), composed relative to the current window.
1503
+ slice(start, end) {
1504
+ const base2 = win?.start ?? 0;
1505
+ const cap = win?.end ?? Number.POSITIVE_INFINITY;
1506
+ const s = Math.min(cap, base2 + Math.max(0, start));
1507
+ const e = end === void 0 ? cap : Math.min(cap, base2 + end);
1508
+ return file2(name, { start: s, end: e });
1509
+ },
1510
+ async write(content) {
1511
+ await fsp.mkdir(path.dirname(full), { recursive: true });
1512
+ if (content instanceof ReadableStream) {
1513
+ const writable = fs.createWriteStream(full);
1514
+ for await (const chunk of content) {
1515
+ writable.write(chunk);
1516
+ }
1517
+ await new Promise((resolve2, reject) => {
1518
+ writable.on("error", reject);
1519
+ writable.end(() => resolve2());
1520
+ });
1521
+ return;
1522
+ }
1523
+ await fsp.writeFile(full, content);
1524
+ },
1525
+ stream() {
1526
+ return read();
1527
+ },
1528
+ async bytes() {
1529
+ if (win) return new Uint8Array(await new Response(read()).arrayBuffer());
1530
+ return new Uint8Array(await fsp.readFile(full));
1531
+ },
1532
+ async remove() {
1533
+ await fsp.unlink(full).catch(() => {
1534
+ });
1535
+ }
1536
+ };
1537
+ };
1538
+ return {
1539
+ file: file2,
1540
+ folder: (sub) => localBucket(path.join(base, sub), `${prefix}${sub.replace(/^\/+|\/+$/g, "")}/`)
1541
+ };
1542
+ }
1543
+ function bucket(root) {
1544
+ if (!root) return null;
1545
+ if (typeof root === "string") return localBucket(root);
1546
+ if (typeof root.file === "function") return root;
1547
+ throw new Error(
1548
+ "Invalid bucket: pass a directory path or a `bucket` instance (with .file())"
1549
+ );
1550
+ }
1551
+
1519
1552
  // src/helpers/color.ts
1520
1553
  var map = {
1521
1554
  reset: 0,
@@ -1651,10 +1684,23 @@ function resolveSecurity(security) {
1651
1684
  }
1652
1685
  return {
1653
1686
  trustProxy: o.trustProxy ?? true,
1687
+ traversalProtection: off ? false : o.traversalProtection !== false,
1654
1688
  headers: headers2,
1655
1689
  hsts: off ? null : val(o.hsts, "max-age=15552000; includeSubDomains")
1656
1690
  };
1657
1691
  }
1692
+ var CLIMBS = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
1693
+ var ABSOLUTE = /^(?:[\\/]|[a-zA-Z]:)/;
1694
+ function checkTraversal(params, ctx) {
1695
+ if (!ctx.options.security?.traversalProtection) return;
1696
+ for (const param in params) {
1697
+ const value = params[param];
1698
+ if (typeof value !== "string") continue;
1699
+ if (CLIMBS.test(value) || ABSOLUTE.test(value)) {
1700
+ throw errors_default.PATH_TRAVERSAL({ param, value });
1701
+ }
1702
+ }
1703
+ }
1658
1704
  function applySecurity(res, ctx) {
1659
1705
  const security = ctx.options.security;
1660
1706
  if (!security) return;
@@ -1727,10 +1773,11 @@ function config(options = {}) {
1727
1773
  settings.uploads = null;
1728
1774
  } else if (typeof up === "object" && "bucket" in up) {
1729
1775
  const { bucket: bucket2, maxSize, minSize, fileType } = up;
1730
- const hasLimits = maxSize != null || minSize != null || fileType != null;
1731
- settings.uploads = hasLimits ? new UploadPipeline(bucket2).limit({ maxSize, minSize, fileType }) : bucket(bucket2);
1776
+ if (maxSize != null) parseBytes(maxSize);
1777
+ if (minSize != null) parseBytes(minSize);
1778
+ settings.uploads = { bucket: bucket(bucket2), maxSize, minSize, fileType };
1732
1779
  } else {
1733
- settings.uploads = bucket(up);
1780
+ settings.uploads = { bucket: bucket(up) };
1734
1781
  }
1735
1782
  const favicon2 = options.favicon || env2.FAVICON;
1736
1783
  if (favicon2) settings.favicon = favicon2;
@@ -1909,7 +1956,7 @@ async function parseResponse(out, ctx) {
1909
1956
  out = new Response(void 0, { status: out });
1910
1957
  }
1911
1958
  if (typeof out === "string") {
1912
- const type2 = /^\s*</.test(out) ? "text/html" : "text/plain";
1959
+ const type2 = isHtml(out) ? mimes_default.html : mimes_default.text;
1913
1960
  out = new Response(out, {
1914
1961
  headers: {
1915
1962
  "content-type": type2,
@@ -2075,6 +2122,7 @@ async function getResponse(app, ctx) {
2075
2122
  if (Object.keys(route.options).length) {
2076
2123
  ctx.options = { ...app.settings, ...route.options };
2077
2124
  }
2125
+ checkTraversal(params, ctx);
2078
2126
  ctx.body = await resolveBody(ctx, ctx.options.body);
2079
2127
  for (const cb of route.fns) {
2080
2128
  if (typeof cb === "function") {
@@ -2174,7 +2222,12 @@ function parseCookies(cookies2) {
2174
2222
  return Object.fromEntries(
2175
2223
  cookieStr.split(/;\s*/).map((part) => {
2176
2224
  const [key, ...rest] = part.split("=");
2177
- return [key, decodeURIComponent(rest.join("="))];
2225
+ const value = rest.join("=");
2226
+ try {
2227
+ return [key, decodeURIComponent(value)];
2228
+ } catch {
2229
+ return [key, value];
2230
+ }
2178
2231
  })
2179
2232
  );
2180
2233
  }
@@ -2444,17 +2497,18 @@ async function assets(ctx) {
2444
2497
  try {
2445
2498
  const key = ctx.url.pathname.replace(/^\/+/, "");
2446
2499
  const file2 = ctx.options.public.file(key);
2447
- const meta = file2.info ? await file2.info() : null;
2448
- if (meta ? !meta.exists : !await file2.exists()) return;
2500
+ const info = file2.info?.bind(file2);
2501
+ const meta = info ? await info() : null;
2502
+ if (info ? !meta : !await file2.exists()) return;
2449
2503
  const ext2 = ctx.url.pathname.split(".").pop();
2450
2504
  const ctype = meta?.type || ext2;
2451
2505
  const headers2 = { "cache-control": CACHE_CONTROL };
2452
2506
  let tag;
2453
2507
  if (meta) {
2454
- const stamp = meta.date ? meta.date.getTime() : 0;
2508
+ const stamp = meta.modified ? meta.modified.getTime() : 0;
2455
2509
  tag = `W/"${meta.size.toString(16)}-${stamp.toString(16)}"`;
2456
2510
  headers2.etag = tag;
2457
- if (meta.date) headers2["last-modified"] = meta.date.toUTCString();
2511
+ if (meta.modified) headers2["last-modified"] = meta.modified.toUTCString();
2458
2512
  }
2459
2513
  const canRange = !!(meta && file2.slice);
2460
2514
  if (canRange) headers2["accept-ranges"] = "bytes";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "github:franciscop/server-next",
@@ -44,14 +44,31 @@
44
44
  "home": "./docs/index.html",
45
45
  "menu": {
46
46
  "Documentation": "/documentation",
47
+ "Tutorials": "/tutorials",
47
48
  "Github": "https://github.com/franciscop/server-next"
48
- }
49
+ },
50
+ "documentation": {
51
+ "Documentation": [
52
+ "docs/0. Documentation.md",
53
+ "docs/1. Getting Started.md"
54
+ ],
55
+ "Guides": "docs/2. Guides.md",
56
+ "Options": "docs/3. Options.md",
57
+ "Router": "docs/4. Router.md",
58
+ "Context": "docs/5. Context.md",
59
+ "Reply": "docs/6. Reply.md",
60
+ "Authentication": "docs/7. Authentication.md",
61
+ "Testing": "docs/8. Testing.md",
62
+ "Platforms": "docs/9. Platforms.md",
63
+ "FAQ": "docs/A. FAQ.md"
64
+ },
65
+ "tutorials": "docs/tutorials"
49
66
  },
50
67
  "devDependencies": {
51
68
  "@types/bun": "^1.3.0",
52
69
  "@types/jest": "^30.0.0",
53
70
  "@types/node": "^24.10.0",
54
- "bucket": "^0.4.0",
71
+ "bucket": "^0.5.0",
55
72
  "bun": "^1.3.13",
56
73
  "check-dts": "^0.8.2",
57
74
  "jest": "^29.7.0",