@vryzel/file-next 0.5.2 → 0.6.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.
@@ -160,13 +160,44 @@ var FileSystemError = class extends Error {
160
160
  var asS3Key = (s) => s;
161
161
  var asTenantId = (s) => s;
162
162
 
163
+ // src/storage/content-disposition.ts
164
+ var NEVER_INLINE = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml", "image/svg+xml"]);
165
+ var DENIED_UPLOAD = /* @__PURE__ */ new Set([
166
+ "text/html",
167
+ "application/xhtml+xml",
168
+ "image/svg+xml",
169
+ "text/xml",
170
+ "application/xml"
171
+ ]);
172
+ function mimeBase(contentType) {
173
+ return (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
174
+ }
175
+ function isDeniedUploadType(contentType) {
176
+ return DENIED_UPLOAD.has(mimeBase(contentType));
177
+ }
178
+ function matchesAllowedContentType(pattern, actual) {
179
+ const mime = mimeBase(actual);
180
+ if (!mime || isDeniedUploadType(mime)) return false;
181
+ if (pattern === mime) return true;
182
+ if (pattern.endsWith("/*")) return mime.startsWith(pattern.slice(0, -1));
183
+ return false;
184
+ }
185
+ function signedContentDisposition(requested, contentType) {
186
+ const mime = (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
187
+ if (!mime || NEVER_INLINE.has(mime)) return "attachment";
188
+ if (mime === "application/pdf" || mime === "text/plain" || mime === "text/csv" || mime === "text/markdown" || mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {
189
+ return "inline";
190
+ }
191
+ return "attachment";
192
+ }
193
+
163
194
  // src/server/actions.ts
164
195
  var NodeIdSchema = zod.z.string().min(1, "id is required");
165
196
  var PathSchema = zod.z.string().nullable();
166
197
  var ListFilesInputSchema = zod.z.object({
167
198
  parentId: PathSchema,
168
- limit: zod.z.number().int().positive().optional(),
169
- cursor: zod.z.string().optional()
199
+ limit: zod.z.number().int().positive().max(500).optional(),
200
+ cursor: zod.z.string().max(256).optional()
170
201
  });
171
202
  var DeleteFileInputSchema = zod.z.object({
172
203
  id: NodeIdSchema,
@@ -184,7 +215,7 @@ var CopyFileInputSchema = zod.z.object({
184
215
  });
185
216
  var SetMetadataInputSchema = zod.z.object({
186
217
  id: NodeIdSchema,
187
- metadata: zod.z.record(zod.z.string()),
218
+ metadata: zod.z.record(zod.z.string(), zod.z.string()),
188
219
  replace: zod.z.boolean().optional()
189
220
  });
190
221
  var CreateFolderInputSchema = zod.z.object({
@@ -205,9 +236,13 @@ var ConfirmUploadInputSchema = zod.z.object({
205
236
  size: zod.z.number().int().nonnegative().optional()
206
237
  });
207
238
  var SearchFilesInputSchema = zod.z.object({
208
- query: zod.z.string(),
209
- parentId: zod.z.string().optional(),
210
- limit: zod.z.number().int().positive().optional()
239
+ query: zod.z.string().max(256),
240
+ parentId: zod.z.string().max(256).optional(),
241
+ limit: zod.z.number().int().positive().max(500).optional()
242
+ });
243
+ var ListTrashInputSchema = zod.z.object({
244
+ limit: zod.z.number().int().positive().max(500).optional(),
245
+ cursor: zod.z.string().max(256).optional()
211
246
  });
212
247
  var RestoreNodeInputSchema = zod.z.object({
213
248
  id: NodeIdSchema
@@ -236,7 +271,7 @@ var createServerActions = (deps) => {
236
271
  if (e instanceof FileSystemError) return e;
237
272
  return new FileSystemError({
238
273
  code,
239
- message: `${message}: ${e instanceof Error ? e.message : String(e)}`,
274
+ message,
240
275
  retryable: true
241
276
  });
242
277
  };
@@ -337,12 +372,34 @@ var createServerActions = (deps) => {
337
372
  const prepareUpload = async (input) => {
338
373
  const parsed = PrepareUploadInputSchema.safeParse(input);
339
374
  if (!parsed.success) return zodErr("Invalid prepareUpload input", parsed);
375
+ const maxBytes = deps.maxBytes ?? fs.maxBytes;
376
+ if (maxBytes != null && parsed.data.contentLength > maxBytes) {
377
+ return err(
378
+ new FileSystemError({
379
+ code: "PayloadTooLarge",
380
+ message: `contentLength exceeds maxBytes ${maxBytes}`,
381
+ retryable: false
382
+ })
383
+ );
384
+ }
385
+ if (isDeniedUploadType(parsed.data.contentType) || deps.allowedContentTypes != null && !deps.allowedContentTypes.some(
386
+ (pattern) => matchesAllowedContentType(pattern, parsed.data.contentType)
387
+ )) {
388
+ return err(
389
+ new FileSystemError({
390
+ code: "UnsupportedMediaType",
391
+ message: "Content type is not allowed",
392
+ retryable: false
393
+ })
394
+ );
395
+ }
340
396
  try {
341
397
  const auth = await authTenant();
342
398
  const id = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `upl-${Date.now()}`;
343
399
  const signed = await fs.forTenant(auth.tenantId).adapter.createPresignedUploadUrl({
344
400
  key: asS3Key(id),
345
401
  contentType: parsed.data.contentType,
402
+ contentLength: parsed.data.contentLength,
346
403
  expiresIn: 900
347
404
  });
348
405
  if (!signed.ok) return signed;
@@ -392,12 +449,14 @@ var createServerActions = (deps) => {
392
449
  }
393
450
  };
394
451
  const listTrash = async (input = {}) => {
452
+ const parsed = ListTrashInputSchema.safeParse(input);
453
+ if (!parsed.success) return zodErr("Invalid listTrash input", parsed);
395
454
  try {
396
455
  const auth = await authTenant();
397
456
  return store.listTrash({
398
457
  tenantId: asTenantId(auth.tenantId),
399
- limit: input.limit,
400
- cursor: input.cursor
458
+ limit: parsed.data.limit,
459
+ cursor: parsed.data.cursor
401
460
  });
402
461
  } catch (e) {
403
462
  return err(wrap(e, "InternalError", "listTrash failed"));
@@ -499,18 +558,12 @@ var createServerActions = (deps) => {
499
558
  revokeShare
500
559
  };
501
560
  };
502
-
503
- // src/server/route-handlers/upload.ts
504
561
  var MAX_EXPIRES_IN_SECONDS = 7 * 24 * 60 * 60;
505
562
  var DEFAULT_EXPIRES_IN_SECONDS = 900;
506
- var matchesContentType = (pattern, actual) => {
507
- if (pattern === actual) return true;
508
- if (pattern.endsWith("/*")) {
509
- const prefix = pattern.slice(0, -1);
510
- return actual.startsWith(prefix);
511
- }
512
- return false;
513
- };
563
+ var UploadBody = zod.z.object({
564
+ contentType: zod.z.string().min(1).max(255),
565
+ contentLength: zod.z.number().int().nonnegative()
566
+ });
514
567
  var assertExpiresIn = (expiresIn) => {
515
568
  if (!Number.isFinite(expiresIn) || expiresIn < 1 || expiresIn > MAX_EXPIRES_IN_SECONDS) {
516
569
  throw new FileSystemError({
@@ -522,65 +575,76 @@ var assertExpiresIn = (expiresIn) => {
522
575
  }
523
576
  };
524
577
  var createUploadRouteHandler = (opts) => {
578
+ if (!opts.getAuth && opts.unsafeNoAuth !== true) {
579
+ throw new Error("file-next: getAuth is required");
580
+ }
525
581
  const expiresIn = opts.expiresIn ?? DEFAULT_EXPIRES_IN_SECONDS;
526
582
  assertExpiresIn(expiresIn);
527
583
  return async (req) => {
528
- let body;
584
+ let fs = opts.fs;
585
+ if (opts.getAuth) {
586
+ const auth = await opts.getAuth(req);
587
+ if (!auth) {
588
+ return Response.json(
589
+ { ok: false, error: { code: "Unauthorized", message: "Not authenticated" } },
590
+ { status: 401 }
591
+ );
592
+ }
593
+ fs = opts.fs.forTenant(auth.tenantId);
594
+ }
595
+ let raw;
529
596
  try {
530
- body = await req.json();
597
+ raw = await req.json();
531
598
  } catch {
532
599
  return Response.json(
533
600
  { ok: false, error: { code: "InternalError", message: "Request body must be valid JSON" } },
534
601
  { status: 400 }
535
602
  );
536
603
  }
604
+ const parsed = UploadBody.safeParse(raw);
605
+ if (!parsed.success) {
606
+ return Response.json(
607
+ { ok: false, error: { code: "InternalError", message: "Invalid upload request" } },
608
+ { status: 400 }
609
+ );
610
+ }
611
+ const body = parsed.data;
537
612
  if (typeof opts.maxBytes === "number" && body.contentLength > opts.maxBytes) {
538
613
  return Response.json(
539
614
  {
540
615
  ok: false,
541
616
  error: {
542
617
  code: "PayloadTooLarge",
543
- message: `contentLength ${body.contentLength} exceeds maxBytes ${opts.maxBytes}`
618
+ message: `contentLength exceeds maxBytes ${opts.maxBytes}`
544
619
  }
545
620
  },
546
621
  { status: 413 }
547
622
  );
548
623
  }
549
- if (opts.allowedContentTypes && opts.allowedContentTypes.length > 0) {
550
- const allowed = opts.allowedContentTypes.some((p) => matchesContentType(p, body.contentType));
551
- if (!allowed) {
552
- return Response.json(
553
- {
554
- ok: false,
555
- error: {
556
- code: "UnsupportedMediaType",
557
- message: `contentType "${body.contentType}" is not in the allowed list`
558
- }
559
- },
560
- { status: 415 }
561
- );
562
- }
563
- }
564
- let fs = opts.fs;
565
- if (opts.getAuth) {
566
- const auth = await opts.getAuth(req);
567
- if (!auth) {
568
- return Response.json(
569
- { ok: false, error: { code: "Unauthorized", message: "Not authenticated" } },
570
- { status: 401 }
571
- );
572
- }
573
- fs = opts.fs.forTenant(auth.tenantId);
624
+ if (isDeniedUploadType(body.contentType) || opts.allowedContentTypes && opts.allowedContentTypes.length > 0 && !opts.allowedContentTypes.some(
625
+ (pattern) => matchesAllowedContentType(pattern, body.contentType)
626
+ )) {
627
+ return Response.json(
628
+ {
629
+ ok: false,
630
+ error: {
631
+ code: "UnsupportedMediaType",
632
+ message: "Content type is not allowed"
633
+ }
634
+ },
635
+ { status: 415 }
636
+ );
574
637
  }
575
638
  const id = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `upl-${Date.now()}`;
576
639
  const r = await fs.adapter.createPresignedUploadUrl({
577
640
  key: asS3Key(id),
578
641
  contentType: body.contentType,
642
+ contentLength: body.contentLength,
579
643
  expiresIn
580
644
  });
581
645
  if (!r.ok) {
582
646
  return Response.json(
583
- { ok: false, error: { code: r.error.code, message: r.error.message } },
647
+ { ok: false, error: { code: r.error.code, message: "Upload failed" } },
584
648
  { status: 500 }
585
649
  );
586
650
  }
@@ -613,9 +677,28 @@ var assertExpiresIn2 = (expiresIn) => {
613
677
  }
614
678
  };
615
679
  var createDownloadRouteHandler = (opts) => {
680
+ if (!opts.getAuth && opts.unsafeNoAuth !== true) {
681
+ throw new Error("file-next: getAuth is required");
682
+ }
683
+ if (opts.getAuth && !opts.store) {
684
+ throw new Error("file-next: store is required when getAuth is set");
685
+ }
616
686
  const expiresIn = opts.expiresIn ?? DEFAULT_EXPIRES_IN_SECONDS2;
617
687
  assertExpiresIn2(expiresIn);
618
688
  return async (req) => {
689
+ let fs = opts.fs;
690
+ let tenantId;
691
+ if (opts.getAuth) {
692
+ const auth = await opts.getAuth(req);
693
+ if (!auth) {
694
+ return Response.json(
695
+ { ok: false, error: { code: "Unauthorized", message: "Not authenticated" } },
696
+ { status: 401 }
697
+ );
698
+ }
699
+ tenantId = auth.tenantId;
700
+ fs = opts.fs.forTenant(auth.tenantId);
701
+ }
619
702
  const url = new URL(req.url);
620
703
  const key = url.searchParams.get("key");
621
704
  if (!key) {
@@ -624,16 +707,17 @@ var createDownloadRouteHandler = (opts) => {
624
707
  { status: 400 }
625
708
  );
626
709
  }
627
- let fs = opts.fs;
628
- if (opts.getAuth) {
629
- const auth = await opts.getAuth(req);
630
- if (!auth) {
710
+ if (opts.store && tenantId) {
711
+ const node = await opts.store.findByS3Key({
712
+ tenantId: asTenantId(tenantId),
713
+ s3Key: key
714
+ });
715
+ if (!node.ok || !node.value) {
631
716
  return Response.json(
632
- { ok: false, error: { code: "Unauthorized", message: "Not authenticated" } },
633
- { status: 401 }
717
+ { ok: false, error: { code: "NotFound", message: "Not found" } },
718
+ { status: 404 }
634
719
  );
635
720
  }
636
- fs = opts.fs.forTenant(auth.tenantId);
637
721
  }
638
722
  const r = await fs.adapter.createPresignedDownloadUrl({
639
723
  key: asS3Key(key),
@@ -641,7 +725,7 @@ var createDownloadRouteHandler = (opts) => {
641
725
  });
642
726
  if (!r.ok) {
643
727
  return Response.json(
644
- { ok: false, error: { code: r.error.code, message: r.error.message } },
728
+ { ok: false, error: { code: r.error.code, message: "Download failed" } },
645
729
  { status: 500 }
646
730
  );
647
731
  }
@@ -653,18 +737,17 @@ var createDownloadRouteHandler = (opts) => {
653
737
  };
654
738
  };
655
739
 
656
- // src/storage/content-disposition.ts
657
- var NEVER_INLINE = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml", "image/svg+xml"]);
658
- function signedContentDisposition(requested, contentType) {
659
- const mime = (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
660
- if (!mime || NEVER_INLINE.has(mime)) return "attachment";
661
- if (mime === "application/pdf" || mime === "text/plain" || mime === "text/csv" || mime === "text/markdown" || mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {
662
- return "inline";
663
- }
664
- return "attachment";
665
- }
666
-
667
740
  // src/server/route-handlers/share.ts
741
+ var DEFAULT_MAX_SHARE_BYTES = 32 * 1024 * 1024;
742
+ var shareHeaders = () => {
743
+ const headers = new Headers();
744
+ headers.set("X-Content-Type-Options", "nosniff");
745
+ headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
746
+ headers.set("Cross-Origin-Resource-Policy", "same-origin");
747
+ headers.set("Cache-Control", "private, no-store");
748
+ headers.set("Referrer-Policy", "no-referrer");
749
+ return headers;
750
+ };
668
751
  var tokenFromRequest = (req) => {
669
752
  const url = new URL(req.url);
670
753
  const query = url.searchParams.get("token");
@@ -686,7 +769,7 @@ var createShareRouteHandler = (opts) => {
686
769
  const resolved = await opts.store.resolveShare({ token });
687
770
  if (!resolved.ok) {
688
771
  return Response.json(
689
- { ok: false, error: { code: resolved.error.code, message: resolved.error.message } },
772
+ { ok: false, error: { code: "InternalError", message: "Share lookup failed" } },
690
773
  { status: 500 }
691
774
  );
692
775
  }
@@ -698,29 +781,54 @@ var createShareRouteHandler = (opts) => {
698
781
  );
699
782
  }
700
783
  const adapter = opts.fs.forTenant(node.tenantId).adapter;
701
- const rangeHeader = req.headers.get("range") ?? void 0;
784
+ const download = new URL(req.url).searchParams.get("download") === "1";
785
+ const key = asS3Key(node.s3Key);
786
+ const st = await adapter.stat({ key });
787
+ if (!st.ok) {
788
+ return Response.json(
789
+ { ok: false, error: { code: "NotFound", message: "Share not found" } },
790
+ { status: 404 }
791
+ );
792
+ }
793
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_SHARE_BYTES;
794
+ if (st.value.size > maxBytes) {
795
+ const signed = await adapter.createPresignedDownloadUrl({
796
+ key,
797
+ expiresIn: 60,
798
+ disposition: download ? "attachment" : "inline",
799
+ contentType: st.value.contentType || node.mimeType
800
+ });
801
+ if (!signed.ok) {
802
+ return Response.json(
803
+ { ok: false, error: { code: "InternalError", message: "Share failed" } },
804
+ { status: 500 }
805
+ );
806
+ }
807
+ const headers2 = shareHeaders();
808
+ headers2.set("Location", signed.value.url);
809
+ return new Response(null, { status: 302, headers: headers2 });
810
+ }
702
811
  const read = await adapter.read({
703
- key: asS3Key(node.s3Key),
704
- range: rangeHeader ?? void 0
812
+ key,
813
+ range: req.headers.get("range") ?? void 0
705
814
  });
706
815
  if (!read.ok) {
707
816
  return Response.json(
708
- { ok: false, error: { code: read.error.code, message: read.error.message } },
817
+ { ok: false, error: { code: "NotFound", message: "Share not found" } },
709
818
  { status: 404 }
710
819
  );
711
820
  }
712
- const download = new URL(req.url).searchParams.get("download") === "1";
713
- const headers = new Headers();
714
- const mime = node.mimeType || read.value.contentType || "application/octet-stream";
821
+ const headers = shareHeaders();
822
+ const mime = st.value.contentType || node.mimeType || read.value.contentType || "application/octet-stream";
715
823
  headers.set("Content-Type", mime);
716
824
  const disposition = download ? "attachment" : signedContentDisposition("inline", mime);
717
825
  headers.set(
718
826
  "Content-Disposition",
719
827
  `${disposition}; filename*=UTF-8''${encodeURIComponent(node.name)}`
720
828
  );
721
- headers.set("Cache-Control", "private, no-store");
722
- headers.set("Content-Length", String(read.value.body.byteLength));
723
- return new Response(Buffer.from(read.value.body), { status: 200, headers });
829
+ const bytes = Uint8Array.from(read.value.body);
830
+ headers.set("Content-Length", String(bytes.byteLength));
831
+ return new Response(bytes.buffer, { status: 200, headers });
724
832
  };
725
833
  };
726
834