@stacksjs/storage 0.70.88 → 0.70.90

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 (71) hide show
  1. package/dist/adapters/bun.d.ts +31 -0
  2. package/dist/adapters/bun.js +226 -0
  3. package/dist/adapters/index.d.ts +6 -0
  4. package/dist/adapters/index.js +5 -0
  5. package/dist/adapters/local.d.ts +38 -0
  6. package/dist/adapters/local.js +225 -0
  7. package/dist/adapters/memory.d.ts +38 -0
  8. package/dist/adapters/memory.js +318 -0
  9. package/dist/adapters/s3.d.ts +53 -0
  10. package/dist/adapters/s3.js +471 -0
  11. package/dist/adapters/scoped.d.ts +68 -0
  12. package/dist/adapters/scoped.js +142 -0
  13. package/dist/copy.d.ts +3 -0
  14. package/dist/copy.js +30 -0
  15. package/dist/delete.d.ts +8 -0
  16. package/dist/delete.js +103 -0
  17. package/dist/drivers/aws.d.ts +4 -0
  18. package/dist/drivers/aws.js +94 -0
  19. package/dist/drivers/bun.d.ts +4 -0
  20. package/dist/drivers/bun.js +88 -0
  21. package/dist/drivers/index.d.ts +4 -0
  22. package/dist/drivers/index.js +4 -0
  23. package/dist/drivers/local.d.ts +4 -0
  24. package/dist/drivers/local.js +88 -0
  25. package/dist/drivers/memory.d.ts +4 -0
  26. package/dist/drivers/memory.js +67 -0
  27. package/dist/facade.d.ts +53 -0
  28. package/dist/facade.js +226 -0
  29. package/dist/files.d.ts +52 -0
  30. package/dist/files.js +126 -0
  31. package/dist/folders.d.ts +18 -0
  32. package/dist/folders.js +36 -0
  33. package/dist/fs.d.ts +4 -0
  34. package/dist/fs.js +7 -0
  35. package/dist/glob.d.ts +13 -0
  36. package/dist/glob.js +40 -0
  37. package/dist/hash.d.ts +5 -0
  38. package/dist/hash.js +33 -0
  39. package/dist/helpers.d.ts +7 -0
  40. package/dist/helpers.js +28 -0
  41. package/dist/image.d.ts +55 -0
  42. package/dist/image.js +29 -0
  43. package/dist/index.d.ts +60 -0
  44. package/dist/index.js +27 -0
  45. package/dist/mime-verify.d.ts +65 -0
  46. package/dist/mime-verify.js +47 -0
  47. package/dist/move.d.ts +6 -0
  48. package/dist/move.js +55 -0
  49. package/dist/path-sanitize.d.ts +92 -0
  50. package/dist/path-sanitize.js +84 -0
  51. package/dist/put-file.d.ts +53 -0
  52. package/dist/put-file.js +85 -0
  53. package/dist/s3-presigned-post.d.ts +52 -0
  54. package/dist/s3-presigned-post.js +68 -0
  55. package/dist/signed-url.d.ts +69 -0
  56. package/dist/signed-url.js +86 -0
  57. package/dist/static-serve.d.ts +37 -0
  58. package/dist/static-serve.js +110 -0
  59. package/dist/storage.d.ts +9 -0
  60. package/dist/storage.js +9 -0
  61. package/dist/types/filesystem.d.ts +131 -0
  62. package/dist/types/filesystem.js +40 -0
  63. package/dist/types.d.ts +229 -0
  64. package/dist/types.js +25 -0
  65. package/dist/uploaded-file.d.ts +38 -0
  66. package/dist/uploaded-file.js +114 -0
  67. package/dist/visibility.d.ts +3 -0
  68. package/dist/visibility.js +3 -0
  69. package/dist/zip.d.ts +16 -0
  70. package/dist/zip.js +41 -0
  71. package/package.json +6 -6
package/dist/image.js ADDED
@@ -0,0 +1,29 @@
1
+ let cachedSharp = null;
2
+ async function loadSharp() {
3
+ if (cachedSharp)
4
+ return cachedSharp;
5
+ try {
6
+ const mod = await import("sharp");
7
+ cachedSharp = mod.default ?? mod;
8
+ return cachedSharp;
9
+ } catch (err) {
10
+ throw Error(`[storage/image] \`sharp\` is not installed. Image transforms require it as a peer dependency.
11
+ Install with: \`bun add sharp\`
12
+ Original load error: ${err instanceof Error ? err.message : String(err)}`);
13
+ }
14
+ }
15
+ export function transform(pipeline) {
16
+ return async (input) => {
17
+ const sharp = await loadSharp(), bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : input, buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes), img = sharp(buf);
18
+ return (await pipeline(img)).toBuffer();
19
+ };
20
+ }
21
+ export function avatar(size = 512, quality = 85) {
22
+ return transform((img) => img.resize(size, size, { fit: "cover" }).webp({ quality }));
23
+ }
24
+ export function resize(width, height, fit = "inside") {
25
+ return transform((img) => img.resize(width, height, { fit }));
26
+ }
27
+ export function stripMetadata() {
28
+ return transform((img) => img);
29
+ }
@@ -0,0 +1,60 @@
1
+ export type { ServeFileOptions } from './static-serve';
2
+ export type { DiskConfig, FilesystemConfig, LocalDiskConfig, S3DiskConfig } from './facade';
3
+ export type { FilenameStrategy, PutFileOptions, UploadedFileLike } from './put-file';
4
+ // Disk-name autocomplete: userland augments `KnownDisks` to get
5
+ // completion on `Storage.disk('…')` (stacksjs/stacks#1924).
6
+ export type { DiskName, KnownDisks } from './types/filesystem';
7
+ export type { SignedTokenVerification } from './signed-url';
8
+ export type { SignedUrlOptions } from './types';
9
+ export type { ParsedDiskPath } from './path-sanitize';
10
+ export type { MimeVerifyResult } from './mime-verify';
11
+ export type { S3PresignedPostInput, S3PresignedPostResult } from './s3-presigned-post';
12
+ export type {
13
+ GetStreamOptions,
14
+ PresignedUploadPolicy,
15
+ PresignedUploadPolicyOptions,
16
+ PutResult,
17
+ PutStreamOptions,
18
+ } from './types';
19
+ export * from './copy';
20
+ export * from './delete';
21
+ export * from './files';
22
+ export * from './folders';
23
+ export * from './fs';
24
+ export * from './glob';
25
+ export * from './hash';
26
+ export * from './helpers';
27
+ export * as storage from './storage';
28
+ export * from './zip';
29
+ // Storage adapters and types
30
+ export * from './adapters/index';
31
+ export * from './types';
32
+ export * from './drivers/index';
33
+ // Static asset serving with ETag/Last-Modified/Cache-Control
34
+ export { serveFile } from './static-serve';
35
+ // Laravel-style Storage facade and UploadedFile
36
+ export { Storage, StorageManager } from './facade';
37
+ export { UploadedFile, uploadedFile, uploadedFiles } from './uploaded-file';
38
+ // Filesystem type helpers
39
+ export { configFromEnv, localDisk, s3Disk } from './types/filesystem';
40
+ // Signed-URL helpers (used by Storage.disk('local').signedUrl())
41
+ export {
42
+ clearRevokedSignedStorageTokens,
43
+ createSignedStorageToken,
44
+ isSignedStorageTokenRevoked,
45
+ revokeSignedStorageToken,
46
+ verifySignedStorageToken,
47
+ } from './signed-url';
48
+ // Path-sanitization helpers (used internally by S3 presignedUploadUrl;
49
+ // also re-exported so user code can pre-validate input from request
50
+ // bodies and surface 400s with a clean `PathSanitizeError`.)
51
+ export { parseDiskPath, PathSanitizeError, sanitizePresignedDir, sanitizePresignedFilename } from './path-sanitize';
52
+ // MIME re-verification — server-side check that uploaded bytes match
53
+ // the claimed content type. Run this after a presigned upload
54
+ // completes since `Content-Type` on a presigned PUT is caller-attested.
55
+ export { detectMimeFromMagicBytes, verifyUploadedMime } from './mime-verify';
56
+ // S3 presigned-POST policy signer (stacksjs/stacks#1888 Phase B).
57
+ // Exposed as a standalone for callers that have their own S3 client
58
+ // and just need the policy-signing logic; the Storage facade wraps
59
+ // it as `presignedUploadPolicy()`.
60
+ export { signS3PresignedPost } from './s3-presigned-post';
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ export * from "./copy";
2
+ export * from "./delete";
3
+ export * from "./files";
4
+ export * from "./folders";
5
+ export * from "./fs";
6
+ export * from "./glob";
7
+ export * from "./hash";
8
+ export * from "./helpers";
9
+ export * as storage from "./storage";
10
+ export * from "./zip";
11
+ export * from "./adapters";
12
+ export * from "./types";
13
+ export * from "./drivers";
14
+ export { serveFile } from "./static-serve";
15
+ export { Storage, StorageManager } from "./facade";
16
+ export { UploadedFile, uploadedFile, uploadedFiles } from "./uploaded-file";
17
+ export { configFromEnv, localDisk, s3Disk } from "./types/filesystem";
18
+ export {
19
+ clearRevokedSignedStorageTokens,
20
+ createSignedStorageToken,
21
+ isSignedStorageTokenRevoked,
22
+ revokeSignedStorageToken,
23
+ verifySignedStorageToken
24
+ } from "./signed-url";
25
+ export { parseDiskPath, PathSanitizeError, sanitizePresignedDir, sanitizePresignedFilename } from "./path-sanitize";
26
+ export { detectMimeFromMagicBytes, verifyUploadedMime } from "./mime-verify";
27
+ export { signS3PresignedPost } from "./s3-presigned-post";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Detect a MIME type from the first chunk of a file. Returns the
3
+ * detected MIME (e.g. `'image/png'`) or `null` if the bytes don't
4
+ * match any known signature.
5
+ *
6
+ * Intentionally narrow — only the well-known binary formats that
7
+ * appear in `presignedUploadUrl`'s extension map are detected. Text
8
+ * formats are not covered because their signatures are ambiguous.
9
+ */
10
+ export declare function detectMimeFromMagicBytes(bytes: Uint8Array | ArrayBuffer): string | null;
11
+ /**
12
+ * Verify that a file's actual contents match the claimed content type.
13
+ * Returns `{ ok, expected, detected }` so callers can branch on the
14
+ * result and produce useful error messages.
15
+ *
16
+ * Reads up to 32 bytes from the file (enough for every signature we
17
+ * check), then matches against `detectMimeFromMagicBytes`.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * // After a presigned upload completes:
22
+ * const result = await verifyUploadedMime('uploads/avatar.jpg', 'image/jpeg')
23
+ * if (!result.ok) {
24
+ * await Storage.disk().deleteFile('uploads/avatar.jpg')
25
+ * return Response.json({ error: 'content type mismatch', ...result }, { status: 400 })
26
+ * }
27
+ * ```
28
+ */
29
+ export declare function verifyUploadedMime(path: string, expectedContentType: string, options?: { disk?: string }): Promise<MimeVerifyResult>;
30
+ /**
31
+ * MIME re-verification helpers (stacksjs/stacks#1873 S-3).
32
+ *
33
+ * Background: `presignedUploadUrl({ contentType })` lets the caller
34
+ * declare what they're going to upload, and AWS signs the URL against
35
+ * that exact `Content-Type` header. Nothing checks that the **bytes**
36
+ * actually match the claim. An attacker who can call your presigned
37
+ * endpoint can request `image/jpeg` (which derives a `.jpg`
38
+ * extension), then PUT a JavaScript file. The server only sees
39
+ * "object exists, contentType was image/jpeg" — but the bytes are
40
+ * executable.
41
+ *
42
+ * These helpers exist so server code can re-detect the MIME from
43
+ * magic bytes after the upload finishes, and either delete the
44
+ * mismatched object or surface it as a 400.
45
+ *
46
+ * **Limitations** — magic-byte sniffing only works for binary formats
47
+ * with a well-defined signature. Text-based types (JSON, CSV, plain
48
+ * text, SVG, HTML) can't be unambiguously detected from the first few
49
+ * bytes; for those, validate by parsing the content (e.g. try
50
+ * `JSON.parse` for `application/json`).
51
+ */
52
+ /**
53
+ * Result of a magic-byte detection attempt.
54
+ *
55
+ * `ok: true` means the bytes match a known signature for the
56
+ * expected content type. `ok: false` with `detected: null` means the
57
+ * bytes didn't match any signature this helper knows; `ok: false`
58
+ * with `detected: string` means the bytes match a *different*
59
+ * signature than expected (e.g. PNG bytes uploaded as image/jpeg).
60
+ */
61
+ export declare interface MimeVerifyResult {
62
+ ok: boolean
63
+ expected: string
64
+ detected: string | null
65
+ }
@@ -0,0 +1,47 @@
1
+ export function detectMimeFromMagicBytes(bytes) {
2
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
3
+ if (view.length < 4)
4
+ return null;
5
+ if (view[0] === 137 && view[1] === 80 && view[2] === 78 && view[3] === 71 && view[4] === 13 && view[5] === 10 && view[6] === 26 && view[7] === 10)
6
+ return "image/png";
7
+ if (view[0] === 255 && view[1] === 216 && view[2] === 255)
8
+ return "image/jpeg";
9
+ if (view[0] === 71 && view[1] === 73 && view[2] === 70 && view[3] === 56)
10
+ return "image/gif";
11
+ if (view[0] === 82 && view[1] === 73 && view[2] === 70 && view[3] === 70 && view.length >= 12) {
12
+ if (view[8] === 87 && view[9] === 69 && view[10] === 66 && view[11] === 80)
13
+ return "image/webp";
14
+ if (view[8] === 87 && view[9] === 65 && view[10] === 86 && view[11] === 69)
15
+ return "audio/wav";
16
+ }
17
+ if (view[0] === 37 && view[1] === 80 && view[2] === 68 && view[3] === 70)
18
+ return "application/pdf";
19
+ if (view[0] === 80 && view[1] === 75 && (view[2] === 3 || view[2] === 5) && (view[3] === 4 || view[3] === 6))
20
+ return "application/zip";
21
+ if (view.length >= 12 && view[4] === 102 && view[5] === 116 && view[6] === 121 && view[7] === 112) {
22
+ const brand = String.fromCharCode(view[8] ?? 0, view[9] ?? 0, view[10] ?? 0, view[11] ?? 0);
23
+ if (brand === "avif" || brand === "avis")
24
+ return "image/avif";
25
+ if (brand === "heic" || brand === "heix" || brand === "mif1")
26
+ return "image/heic";
27
+ return "video/mp4";
28
+ }
29
+ if (view[0] === 26 && view[1] === 69 && view[2] === 223 && view[3] === 163)
30
+ return "video/webm";
31
+ if (view[0] === 73 && view[1] === 68 && view[2] === 51)
32
+ return "audio/mpeg";
33
+ if (view[0] === 255 && (view[1] ?? 0) >= 224)
34
+ return "audio/mpeg";
35
+ return null;
36
+ }
37
+ function normalizeContentType(contentType) {
38
+ return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
39
+ }
40
+ export async function verifyUploadedMime(path, expectedContentType, options = {}) {
41
+ const { Storage } = await import("./facade"), bytes = await Storage.disk(options.disk).readToUint8Array(path), expected = normalizeContentType(expectedContentType), detected = detectMimeFromMagicBytes(bytes.slice(0, 32));
42
+ return {
43
+ ok: detected === expected || detected === "image/jpeg" && (expected === "image/jpg" || expected === "image/pjpeg"),
44
+ expected,
45
+ detected
46
+ };
47
+ }
package/dist/move.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Result } from '@stacksjs/error-handling';
2
+ export declare function move(src: string | string[], dest: string, options?: MoveOptions): Promise<Result<{ message: string }, Error>>;
3
+ export declare function rename(from: string, to: string, options?: MoveOptions): Promise<Result<{ message: string }, Error>>;
4
+ declare interface MoveOptions {
5
+ overwrite?: boolean
6
+ }
package/dist/move.js ADDED
@@ -0,0 +1,55 @@
1
+ import { err, handleError, ok } from "@stacksjs/error-handling";
2
+ import { log } from "@stacksjs/logging";
3
+ import { path } from "@stacksjs/path";
4
+ import { fs } from "./fs";
5
+ export async function move(src, dest, options) {
6
+ try {
7
+ if (Array.isArray(src)) {
8
+ const errors = [], operations = src.map(async (file) => {
9
+ const from = file, to = path.resolve(dest, path.basename(file)), result = await rename(from, to, options);
10
+ if (result.isErr) {
11
+ log.error(result.error);
12
+ errors.push(result.error);
13
+ }
14
+ });
15
+ await Promise.all(operations);
16
+ if (errors.length > 0)
17
+ return err(handleError(errors[0]));
18
+ return ok({ message: "Files moved successfully" });
19
+ }
20
+ const result = await rename(src, dest, options);
21
+ if (result.isErr) {
22
+ log.error(result.error);
23
+ return err(handleError(result.error));
24
+ }
25
+ return ok({ message: "File moved successfully" });
26
+ } catch (error) {
27
+ return err(handleError(error));
28
+ }
29
+ }
30
+ export async function rename(from, to, options) {
31
+ return new Promise((resolve, reject) => {
32
+ try {
33
+ const dir = path.dirname(to);
34
+ if (!fs.existsSync(dir))
35
+ fs.mkdirSync(dir, { recursive: !0 });
36
+ if (!fs.existsSync(from))
37
+ return reject(err(Error(`File or directory does not exist: ${from}`)));
38
+ if (fs.existsSync(to)) {
39
+ if (!options?.overwrite)
40
+ return reject(err(Error(`File or directory already exists: ${to}`)));
41
+ fs.rmSync(to, { recursive: !0, force: !0 });
42
+ }
43
+ fs.renameSync(from, to);
44
+ return resolve(ok({ message: "File moved successfully" }));
45
+ } catch (error) {
46
+ if (error.code === "ENOENT")
47
+ log.error(`File or directory does not exist
48
+
49
+ `, error);
50
+ else
51
+ log.error(error);
52
+ return reject(err(Error(error)));
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Sanitize a `dir` parameter for `presignedUploadUrl`. Returns the
3
+ * cleaned dir on success (trailing slashes stripped, empty string
4
+ * preserved as-is) or throws `PathSanitizeError` on:
5
+ *
6
+ * - non-string input
7
+ * - absolute paths (`/foo`)
8
+ * - traversal segments (`..`, `foo/../bar`)
9
+ * - null bytes (`foo\0bar`) — these get stripped by some S3 SDKs
10
+ * silently
11
+ * - control characters (`\r`, `\n`, etc.) — log-injection risk
12
+ * - segments outside `[A-Za-z0-9._-]`
13
+ *
14
+ * Empty / undefined dir is allowed (it means "write at the root of
15
+ * the configured prefix").
16
+ */
17
+ export declare function sanitizePresignedDir(dir: string | undefined): string;
18
+ /**
19
+ * Sanitize a `filename` parameter for `presignedUploadUrl`. Returns
20
+ * the cleaned filename on success or throws `PathSanitizeError`.
21
+ *
22
+ * Rejects path separators, traversal tokens, null bytes, control
23
+ * characters, and disallowed characters. Validates the extension
24
+ * against a strict alphanumeric pattern (no `.exe.jpg` smuggling —
25
+ * the caller is responsible for matching extension to expected
26
+ * content type via the contentType the URL was signed for).
27
+ */
28
+ export declare function sanitizePresignedFilename(filename: string): string;
29
+ /**
30
+ * Parse a `disk:path` reference used by `Storage.copyAcross()` /
31
+ * `moveAcross()` (stacksjs/stacks#1888 S-7).
32
+ *
33
+ * Format: `<disk>:<path>` where:
34
+ * - `<disk>` is an alphanumeric + dash/underscore disk name
35
+ * - `<path>` is a storage-relative path (path-traversal /
36
+ * null-byte / control-char checks applied)
37
+ *
38
+ * Throws {@link PathSanitizeError} on a malformed input — the
39
+ * cross-disk helpers turn that into a clear "bad source" / "bad
40
+ * dest" error rather than crashing inside the adapter.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * parseDiskPath('s3:user-uploads/foo.jpg')
45
+ * // → { disk: 's3', path: 'user-uploads/foo.jpg' }
46
+ * ```
47
+ */
48
+ export declare function parseDiskPath(input: string): ParsedDiskPath;
49
+ /**
50
+ * Parsed `disk:path` reference returned by {@link parseDiskPath}.
51
+ */
52
+ export declare interface ParsedDiskPath {
53
+ disk: string
54
+ path: string
55
+ }
56
+ /**
57
+ * Path-sanitization helpers for storage adapters (stacksjs/stacks#1873).
58
+ *
59
+ * Callers of `presignedUploadUrl({ dir, filename })` pass in
60
+ * caller-controlled strings that get interpolated straight into the
61
+ * stored key. Without sanitization, `dir: '../../sensitive'` escapes
62
+ * the intended prefix and `filename: 'foo/bar.exe'` injects a
63
+ * directory separator — both let a hostile caller (or a confused
64
+ * authenticated caller) write to objects outside their intended
65
+ * scope. These helpers reject the dangerous shapes loudly before the
66
+ * adapter ever signs anything.
67
+ *
68
+ * Note: the local/bun adapters already have a defense-in-depth check
69
+ * via `path.relative()` in `resolvePath()`. The S3 adapter doesn't,
70
+ * because S3 keys are opaque strings — there's no filesystem `..`
71
+ * resolution to lean on. That's exactly why we need this layer.
72
+ */
73
+ /**
74
+ * Thrown when `sanitizePresignedDir` or `sanitizePresignedFilename`
75
+ * detects a value that would escape the intended scope. The `reason`
76
+ * discriminant lets callers distinguish "you passed an absolute path"
77
+ * from "you passed a null byte" if they want to surface that in error
78
+ * messages — most callers can just `catch (e: PathSanitizeError)` and
79
+ * return a 400.
80
+ */
81
+ export declare class PathSanitizeError extends Error {
82
+ readonly reason: | 'empty'
83
+ | 'not-string'
84
+ | 'absolute-path'
85
+ | 'traversal'
86
+ | 'null-byte'
87
+ | 'control-char'
88
+ | 'too-long'
89
+ | 'invalid-char'
90
+ | 'invalid-extension';
91
+ constructor(message: string, reason: PathSanitizeError['reason']);
92
+ }
@@ -0,0 +1,84 @@
1
+ export class PathSanitizeError extends Error {
2
+ reason;
3
+ constructor(message, reason) {
4
+ super(message);
5
+ this.name = "PathSanitizeError";
6
+ this.reason = reason;
7
+ }
8
+ }
9
+ const MAX_COMPONENT_LENGTH = 255, ALLOWED_DIR_CHAR = /^[A-Za-z0-9._-]+$/, ALLOWED_FILENAME_CHAR = /^[A-Za-z0-9._-]+$/, ALLOWED_EXTENSION = /^[a-z0-9]+$/;
10
+ export function sanitizePresignedDir(dir) {
11
+ if (dir === void 0 || dir === "")
12
+ return "";
13
+ if (typeof dir !== "string")
14
+ throw new PathSanitizeError(`dir must be a string, got ${typeof dir}`, "not-string");
15
+ const trimmed = dir.replace(/^\/+/, "").replace(/\/+$/, "");
16
+ if (dir.startsWith("/"))
17
+ throw new PathSanitizeError(`dir must not be absolute: '${dir}'`, "absolute-path");
18
+ if (trimmed === "")
19
+ return "";
20
+ if (trimmed.includes("\x00"))
21
+ throw new PathSanitizeError("dir contains null byte", "null-byte");
22
+ if (/[\x00-\x1F\x7F]/.test(trimmed))
23
+ throw new PathSanitizeError("dir contains control character", "control-char");
24
+ const segments = trimmed.split("/");
25
+ for (const segment of segments) {
26
+ if (segment === "" || segment === "." || segment === "..")
27
+ throw new PathSanitizeError(`dir contains traversal or empty segment: '${dir}'`, "traversal");
28
+ if (segment.length > MAX_COMPONENT_LENGTH)
29
+ throw new PathSanitizeError(`dir segment exceeds ${MAX_COMPONENT_LENGTH} chars`, "too-long");
30
+ if (!ALLOWED_DIR_CHAR.test(segment))
31
+ throw new PathSanitizeError(`dir segment contains disallowed character: '${segment}'`, "invalid-char");
32
+ }
33
+ return segments.join("/");
34
+ }
35
+ export function sanitizePresignedFilename(filename) {
36
+ if (typeof filename !== "string")
37
+ throw new PathSanitizeError(`filename must be a string, got ${typeof filename}`, "not-string");
38
+ if (filename === "")
39
+ throw new PathSanitizeError("filename must not be empty", "empty");
40
+ if (filename.length > MAX_COMPONENT_LENGTH)
41
+ throw new PathSanitizeError(`filename exceeds ${MAX_COMPONENT_LENGTH} chars`, "too-long");
42
+ if (filename.includes("\x00"))
43
+ throw new PathSanitizeError("filename contains null byte", "null-byte");
44
+ if (/[\x00-\x1F\x7F]/.test(filename))
45
+ throw new PathSanitizeError("filename contains control character", "control-char");
46
+ if (filename.includes("/") || filename.includes("\\"))
47
+ throw new PathSanitizeError(`filename must not contain path separators: '${filename}'`, "traversal");
48
+ if (filename === "." || filename === ".." || filename.startsWith("../") || filename.includes("/.."))
49
+ throw new PathSanitizeError(`filename contains traversal token: '${filename}'`, "traversal");
50
+ if (!ALLOWED_FILENAME_CHAR.test(filename))
51
+ throw new PathSanitizeError(`filename contains disallowed character: '${filename}'`, "invalid-char");
52
+ const dotIdx = filename.lastIndexOf(".");
53
+ if (dotIdx > 0 && dotIdx < filename.length - 1) {
54
+ const ext = filename.slice(dotIdx + 1).toLowerCase();
55
+ if (!ALLOWED_EXTENSION.test(ext))
56
+ throw new PathSanitizeError(`filename has invalid extension: '.${ext}'`, "invalid-extension");
57
+ }
58
+ return filename;
59
+ }
60
+ const DISK_NAME_RE = /^[a-z0-9_-]+$/i;
61
+ export function parseDiskPath(input) {
62
+ if (typeof input !== "string" || input.length === 0)
63
+ throw new PathSanitizeError("disk-path reference is empty", "empty");
64
+ if (input.includes("\x00"))
65
+ throw new PathSanitizeError("disk-path reference contains a null byte", "null-byte");
66
+ const colonIdx = input.indexOf(":");
67
+ if (colonIdx <= 0 || colonIdx === input.length - 1)
68
+ throw new PathSanitizeError(`disk-path reference must use '<disk>:<path>' format, got '${input}'`, "invalid-char");
69
+ const disk = input.slice(0, colonIdx), path = input.slice(colonIdx + 1);
70
+ if (!DISK_NAME_RE.test(disk))
71
+ throw new PathSanitizeError(`disk name '${disk}' is invalid (alphanumeric + '-' / '_' only)`, "invalid-char");
72
+ if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path))
73
+ throw new PathSanitizeError(`disk-path '${path}' is absolute`, "absolute-path");
74
+ if (path.includes("\x00"))
75
+ throw new PathSanitizeError("disk-path contains a null byte", "null-byte");
76
+ for (let i = 0;i < path.length; i++) {
77
+ const code = path.charCodeAt(i);
78
+ if (code < 32 || code === 127)
79
+ throw new PathSanitizeError(`disk-path contains a control character at index ${i}`, "control-char");
80
+ }
81
+ if (path.split(/[/\\]/).some((seg) => seg === ".."))
82
+ throw new PathSanitizeError(`disk-path '${path}' contains a '..' segment`, "traversal");
83
+ return { disk, path };
84
+ }
@@ -0,0 +1,53 @@
1
+ import type { DiskName } from './types/filesystem';
2
+ import type { PutResult } from './types';
3
+ import type { StorageManager } from './facade';
4
+ export declare function putUploadedFile(manager: StorageManager, file: UploadedFileLike, opts: PutFileOptions): Promise<PutResult & { url: string }>;
5
+ /**
6
+ * Optional metadata fields shared by every uploaded-file shape we
7
+ * accept. The `name` / `mimeType` aliases are present so the router's
8
+ * `UploadedFile` class (which uses the class-style names) flows
9
+ * through alongside the direct-parse shape (which uses the
10
+ * snake-style `originalName` / `mimetype`).
11
+ */
12
+ declare interface UploadedFileMetadata {
13
+ originalName?: string
14
+ name?: string
15
+ mimetype?: string
16
+ mimeType?: string
17
+ }
18
+ export declare interface PutFileOptions {
19
+ disk?: DiskName
20
+ dir?: string
21
+ filename?: FilenameStrategy
22
+ preserveExtension?: boolean
23
+ transform?: (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Uint8Array | Buffer>
24
+ }
25
+ /**
26
+ * Minimal structural shape for an uploaded file accepted by
27
+ * `Storage.put(file, opts)`. Modeled as a discriminated union so the
28
+ * type-checker rejects `Storage.put({})` and similar empty-object
29
+ * mistakes (stacksjs/stacks#1873 S-13). At least one of `buffer`,
30
+ * `bytes()`, or `arrayBuffer()` must be present — that's the runtime
31
+ * contract `readBytes()` enforces with a throw, and now the
32
+ * structural contract the type system enforces at compile time.
33
+ *
34
+ * Two callsites land here in practice (stacksjs/stacks#1856):
35
+ *
36
+ * 1. **Direct multipart parse** (the original router shape, before
37
+ * bun-router wrapped each entry in an `UploadedFile` class).
38
+ * `{ originalName, mimetype, buffer }` — synchronous.
39
+ * 2. **Router's `UploadedFile` class** (current shape from
40
+ * `req.file(key)` / `req.files`). Exposes `name`, `mimeType`, and
41
+ * an async `bytes()` / `arrayBuffer()` accessor instead of a
42
+ * `buffer` property — Bun's `File` is lazy by design.
43
+ */
44
+ export type UploadedFileLike = UploadedFileMetadata & (
45
+ | { buffer: ArrayBuffer | Uint8Array | Buffer, bytes?: () => Promise<Uint8Array>, arrayBuffer?: () => Promise<ArrayBuffer> }
46
+ | { bytes: () => Promise<Uint8Array>, buffer?: ArrayBuffer | Uint8Array | Buffer, arrayBuffer?: () => Promise<ArrayBuffer> }
47
+ | { arrayBuffer: () => Promise<ArrayBuffer>, buffer?: ArrayBuffer | Uint8Array | Buffer, bytes?: () => Promise<Uint8Array> }
48
+ );
49
+ /** Built-in filename strategies for `Storage.put(file, { filename })`. */
50
+ export type FilenameStrategy = | 'uuid'
51
+ | 'hash'
52
+ | 'original'
53
+ | ((file: UploadedFileLike) => string);
@@ -0,0 +1,85 @@
1
+ const MIME_TO_EXT = {
2
+ "image/jpeg": "jpg",
3
+ "image/jpg": "jpg",
4
+ "image/png": "png",
5
+ "image/webp": "webp",
6
+ "image/gif": "gif",
7
+ "image/svg+xml": "svg",
8
+ "image/avif": "avif",
9
+ "application/pdf": "pdf",
10
+ "application/json": "json",
11
+ "application/zip": "zip",
12
+ "application/octet-stream": "bin",
13
+ "text/plain": "txt",
14
+ "text/csv": "csv",
15
+ "text/html": "html",
16
+ "video/mp4": "mp4",
17
+ "video/webm": "webm",
18
+ "audio/mpeg": "mp3",
19
+ "audio/wav": "wav"
20
+ };
21
+ function extFromOriginalName(name) {
22
+ if (!name)
23
+ return null;
24
+ const idx = name.lastIndexOf(".");
25
+ if (idx <= 0 || idx === name.length - 1)
26
+ return null;
27
+ const ext = name.slice(idx + 1).toLowerCase();
28
+ if (!/^[a-z0-9]+$/.test(ext))
29
+ return null;
30
+ return ext;
31
+ }
32
+ function originalNameOf(file) {
33
+ return file.originalName ?? file.name;
34
+ }
35
+ function mimetypeOf(file) {
36
+ return file.mimetype ?? file.mimeType;
37
+ }
38
+ function deriveExtension(file) {
39
+ const mime = mimetypeOf(file);
40
+ return extFromOriginalName(originalNameOf(file)) ?? (mime && MIME_TO_EXT[mime.toLowerCase()]) ?? null;
41
+ }
42
+ async function readBytes(file) {
43
+ if (file.buffer !== void 0)
44
+ return file.buffer;
45
+ if (typeof file.bytes === "function")
46
+ return await file.bytes();
47
+ if (typeof file.arrayBuffer === "function")
48
+ return await file.arrayBuffer();
49
+ throw Error("UploadedFile is missing both `buffer` and `bytes()`/`arrayBuffer()` accessors \u2014 cannot read file contents.");
50
+ }
51
+ function bufferLikeToHash(buffer) {
52
+ const view = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer, hasher = new Bun.CryptoHasher("sha256");
53
+ hasher.update(view);
54
+ return hasher.digest("hex").slice(0, 32);
55
+ }
56
+ function sanitizeOriginalName(name) {
57
+ return name.replace(/[/\\]/g, "_").replace(/\.{2,}/g, "_").replace(/[^A-Za-z0-9._-]/g, "_").replace(/_+/g, "_");
58
+ }
59
+ async function resolveFilename(file, strategy) {
60
+ if (typeof strategy === "function")
61
+ return strategy(file);
62
+ switch (strategy) {
63
+ case "uuid":
64
+ return crypto.randomUUID().replace(/-/g, "");
65
+ case "hash": {
66
+ const bytes = await readBytes(file);
67
+ return bufferLikeToHash(bytes);
68
+ }
69
+ case "original": {
70
+ const name = originalNameOf(file);
71
+ return name ? sanitizeOriginalName(name) : crypto.randomUUID().replace(/-/g, "");
72
+ }
73
+ }
74
+ }
75
+ function joinPath(...parts) {
76
+ return parts.filter(Boolean).map((p, i) => i === 0 ? p.replace(/\/+$/, "") : p.replace(/^\/+/, "").replace(/\/+$/, "")).filter(Boolean).join("/");
77
+ }
78
+ export async function putUploadedFile(manager, file, opts) {
79
+ const disk = manager.disk(opts.disk), baseName = await resolveFilename(file, opts.filename ?? "uuid"), wantExt = opts.preserveExtension !== !1, baseHasExt = /\.[A-Za-z0-9]+$/.test(baseName), ext = wantExt && !baseHasExt ? deriveExtension(file) : null, finalName = ext ? `${baseName}.${ext}` : baseName, fullPath = joinPath(opts.dir ?? "", finalName), raw = await readBytes(file);
80
+ let contents = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw;
81
+ if (opts.transform)
82
+ contents = await opts.transform(contents);
83
+ const written = await disk.write(fullPath, contents), url = await disk.publicUrl(fullPath);
84
+ return { ...written, path: fullPath, url };
85
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Build + sign an S3 presigned-POST policy.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * const post = signS3PresignedPost({
7
+ * bucket: 'app-uploads',
8
+ * region: 'us-east-1',
9
+ * credentials: { accessKeyId, secretAccessKey },
10
+ * key: { startsWith: 'avatars/' },
11
+ * contentType: { startsWith: 'image/' },
12
+ * contentLengthRange: { min: 0, max: 5 * 1024 * 1024 },
13
+ * expiresIn: 3600,
14
+ * })
15
+ *
16
+ * // Browser side:
17
+ * // const fd = new FormData()
18
+ * // Object.entries(post.fields).forEach(([k, v]) => fd.append(k, v))
19
+ * // fd.append('file', file) // MUST be last
20
+ * // await fetch(post.url, { method: 'POST', body: fd })
21
+ * ```
22
+ */
23
+ export declare function signS3PresignedPost(input: S3PresignedPostInput): S3PresignedPostResult;
24
+ /**
25
+ * Inputs to {@link signS3PresignedPost}. Mirrors the
26
+ * `presignedUploadPolicy()` adapter call once it's wired up.
27
+ */
28
+ export declare interface S3PresignedPostInput {
29
+ bucket: string
30
+ region: string
31
+ credentials: {
32
+ accessKeyId: string
33
+ secretAccessKey: string
34
+ sessionToken?: string
35
+ }
36
+ key: string | { startsWith: string }
37
+ contentType: string | { startsWith: string }
38
+ contentLengthRange?: { min: number, max: number }
39
+ acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read' | 'bucket-owner-read' | 'bucket-owner-full-control'
40
+ expiresIn: number
41
+ fields?: Record<string, string>
42
+ }
43
+ /**
44
+ * What the browser submits. The form is `multipart/form-data` POSTed
45
+ * to `url`; every key in `fields` becomes a form field with the same
46
+ * name. The actual file MUST be the LAST field, named `'file'`.
47
+ */
48
+ export declare interface S3PresignedPostResult {
49
+ url: string
50
+ fields: Record<string, string>
51
+ key: string
52
+ }