@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.
- package/dist/adapters/bun.d.ts +31 -0
- package/dist/adapters/bun.js +226 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +5 -0
- package/dist/adapters/local.d.ts +38 -0
- package/dist/adapters/local.js +225 -0
- package/dist/adapters/memory.d.ts +38 -0
- package/dist/adapters/memory.js +318 -0
- package/dist/adapters/s3.d.ts +53 -0
- package/dist/adapters/s3.js +471 -0
- package/dist/adapters/scoped.d.ts +68 -0
- package/dist/adapters/scoped.js +142 -0
- package/dist/copy.d.ts +3 -0
- package/dist/copy.js +30 -0
- package/dist/delete.d.ts +8 -0
- package/dist/delete.js +103 -0
- package/dist/drivers/aws.d.ts +4 -0
- package/dist/drivers/aws.js +94 -0
- package/dist/drivers/bun.d.ts +4 -0
- package/dist/drivers/bun.js +88 -0
- package/dist/drivers/index.d.ts +4 -0
- package/dist/drivers/index.js +4 -0
- package/dist/drivers/local.d.ts +4 -0
- package/dist/drivers/local.js +88 -0
- package/dist/drivers/memory.d.ts +4 -0
- package/dist/drivers/memory.js +67 -0
- package/dist/facade.d.ts +53 -0
- package/dist/facade.js +226 -0
- package/dist/files.d.ts +52 -0
- package/dist/files.js +126 -0
- package/dist/folders.d.ts +18 -0
- package/dist/folders.js +36 -0
- package/dist/fs.d.ts +4 -0
- package/dist/fs.js +7 -0
- package/dist/glob.d.ts +13 -0
- package/dist/glob.js +40 -0
- package/dist/hash.d.ts +5 -0
- package/dist/hash.js +33 -0
- package/dist/helpers.d.ts +7 -0
- package/dist/helpers.js +28 -0
- package/dist/image.d.ts +55 -0
- package/dist/image.js +29 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +27 -0
- package/dist/mime-verify.d.ts +65 -0
- package/dist/mime-verify.js +47 -0
- package/dist/move.d.ts +6 -0
- package/dist/move.js +55 -0
- package/dist/path-sanitize.d.ts +92 -0
- package/dist/path-sanitize.js +84 -0
- package/dist/put-file.d.ts +53 -0
- package/dist/put-file.js +85 -0
- package/dist/s3-presigned-post.d.ts +52 -0
- package/dist/s3-presigned-post.js +68 -0
- package/dist/signed-url.d.ts +69 -0
- package/dist/signed-url.js +86 -0
- package/dist/static-serve.d.ts +37 -0
- package/dist/static-serve.js +110 -0
- package/dist/storage.d.ts +9 -0
- package/dist/storage.js +9 -0
- package/dist/types/filesystem.d.ts +131 -0
- package/dist/types/filesystem.js +40 -0
- package/dist/types.d.ts +229 -0
- package/dist/types.js +25 -0
- package/dist/uploaded-file.d.ts +38 -0
- package/dist/uploaded-file.js +114 -0
- package/dist/visibility.d.ts +3 -0
- package/dist/visibility.js +3 -0
- package/dist/zip.d.ts +16 -0
- package/dist/zip.js +41 -0
- 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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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);
|
package/dist/put-file.js
ADDED
|
@@ -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
|
+
}
|