@stacksjs/storage 0.70.87 → 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.
@@ -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.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,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,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,68 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { Buffer } from "node:buffer";
3
+ const ALGORITHM = "AWS4-HMAC-SHA256", MIN_EXPIRY = 60, MAX_EXPIRY = 604800;
4
+ function hmac(key, data) {
5
+ return createHmac("sha256", key).update(data, "utf8").digest();
6
+ }
7
+ function deriveSigningKey(secretAccessKey, dateStamp, region) {
8
+ const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp), kRegion = hmac(kDate, region), kService = hmac(kRegion, "s3");
9
+ return hmac(kService, "aws4_request");
10
+ }
11
+ function isoDate(now) {
12
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""), dateStamp = amzDate.slice(0, 8);
13
+ return { amzDate, dateStamp };
14
+ }
15
+ export function signS3PresignedPost(input) {
16
+ const expiresIn = Math.floor(input.expiresIn);
17
+ if (!Number.isFinite(expiresIn) || expiresIn < MIN_EXPIRY || expiresIn > MAX_EXPIRY)
18
+ throw RangeError(`[storage/s3-post] expiresIn must be between ${MIN_EXPIRY}s and ${MAX_EXPIRY}s (got ${expiresIn}s)`);
19
+ if (!input.bucket)
20
+ throw Error("[storage/s3-post] bucket is required");
21
+ if (!input.credentials?.accessKeyId || !input.credentials?.secretAccessKey)
22
+ throw Error("[storage/s3-post] credentials.accessKeyId and credentials.secretAccessKey are required");
23
+ const now = new Date, { amzDate, dateStamp } = isoDate(now), credentialScope = `${dateStamp}/${input.region}/s3/aws4_request`, credentialField = `${input.credentials.accessKeyId}/${credentialScope}`, expirationDate = new Date(now.getTime() + expiresIn * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"), conditions = [];
24
+ conditions.push({ bucket: input.bucket });
25
+ if (typeof input.key === "string")
26
+ conditions.push({ key: input.key });
27
+ else
28
+ conditions.push(["starts-with", "$key", input.key.startsWith]);
29
+ const acl = input.acl ?? "private";
30
+ conditions.push({ acl });
31
+ if (typeof input.contentType === "string")
32
+ conditions.push({ "Content-Type": input.contentType });
33
+ else
34
+ conditions.push(["starts-with", "$Content-Type", input.contentType.startsWith]);
35
+ if (input.contentLengthRange) {
36
+ if (!Number.isFinite(input.contentLengthRange.min) || input.contentLengthRange.min < 0 || !Number.isFinite(input.contentLengthRange.max) || input.contentLengthRange.max < input.contentLengthRange.min)
37
+ throw RangeError("[storage/s3-post] contentLengthRange must satisfy 0 <= min <= max");
38
+ conditions.push(["content-length-range", input.contentLengthRange.min, input.contentLengthRange.max]);
39
+ }
40
+ if (input.fields)
41
+ for (const [k, v] of Object.entries(input.fields))
42
+ conditions.push({ [k]: v });
43
+ conditions.push({ "x-amz-credential": credentialField });
44
+ conditions.push({ "x-amz-algorithm": ALGORITHM });
45
+ conditions.push({ "x-amz-date": amzDate });
46
+ if (input.credentials.sessionToken)
47
+ conditions.push({ "x-amz-security-token": input.credentials.sessionToken });
48
+ const policy = {
49
+ expiration: expirationDate,
50
+ conditions
51
+ }, policyBase64 = Buffer.from(JSON.stringify(policy), "utf8").toString("base64"), signingKey = deriveSigningKey(input.credentials.secretAccessKey, dateStamp, input.region), signature = createHmac("sha256", signingKey).update(policyBase64, "utf8").digest("hex"), fields = {
52
+ key: typeof input.key === "string" ? input.key : `${input.key.startsWith}\${filename}`,
53
+ acl,
54
+ "Content-Type": typeof input.contentType === "string" ? input.contentType : input.contentType.startsWith,
55
+ "x-amz-credential": credentialField,
56
+ "x-amz-algorithm": ALGORITHM,
57
+ "x-amz-date": amzDate,
58
+ policy: policyBase64,
59
+ "x-amz-signature": signature,
60
+ ...input.credentials.sessionToken ? { "x-amz-security-token": input.credentials.sessionToken } : {},
61
+ ...input.fields ?? {}
62
+ };
63
+ return {
64
+ url: `https://${input.bucket}.s3.${input.region}.amazonaws.com/`,
65
+ fields,
66
+ key: typeof input.key === "string" ? input.key : input.key.startsWith
67
+ };
68
+ }
@@ -0,0 +1,86 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import process from "node:process";
4
+ const ALG = "HS256";
5
+ function getAppKey() {
6
+ const k = process.env.APP_KEY;
7
+ if (!k || k.length < 16) {
8
+ if (process.env.APP_ENV === "production" || process.env.NODE_ENV === "production")
9
+ throw Error("[storage/signed-url] APP_KEY is missing or too short (need \u226516 chars). Cannot sign URL.");
10
+ }
11
+ return k || "stacks-default-key-dev-only-do-not-use-prod";
12
+ }
13
+ function base64UrlEncode(buf) {
14
+ return buf.toString("base64url");
15
+ }
16
+ function base64UrlDecode(str) {
17
+ return Buffer.from(str, "base64url");
18
+ }
19
+ function normalizeExpiry(expiresIn) {
20
+ if (expiresIn instanceof Date)
21
+ return Math.floor(expiresIn.getTime() / 1000);
22
+ return Math.floor(Date.now() / 1000) + Math.floor(expiresIn);
23
+ }
24
+ export function createSignedStorageToken(path, options) {
25
+ const exp = normalizeExpiry(options.expiresIn), iat = Math.floor(Date.now() / 1000), header = { alg: ALG, typ: "JWT" }, payload = {
26
+ iss: options.issuer || "stacks",
27
+ iat,
28
+ exp,
29
+ path
30
+ }, headerPart = base64UrlEncode(Buffer.from(JSON.stringify(header))), payloadPart = base64UrlEncode(Buffer.from(JSON.stringify(payload))), signingInput = `${headerPart}.${payloadPart}`, sig = base64UrlEncode(createHmac("sha256", getAppKey()).update(signingInput).digest());
31
+ return `${signingInput}.${sig}`;
32
+ }
33
+ const REVOCATION_LIMIT = 1e5, revokedSignatures = new Set;
34
+ function rememberRevoked(sigPart) {
35
+ if (revokedSignatures.has(sigPart))
36
+ return;
37
+ if (revokedSignatures.size >= REVOCATION_LIMIT) {
38
+ const oldest = revokedSignatures.values().next().value;
39
+ if (oldest !== void 0)
40
+ revokedSignatures.delete(oldest);
41
+ }
42
+ revokedSignatures.add(sigPart);
43
+ }
44
+ export function revokeSignedStorageToken(token) {
45
+ if (typeof token !== "string" || token.length === 0)
46
+ return;
47
+ const parts = token.split("."), sig = parts.length === 3 ? parts[2] : token;
48
+ if (sig)
49
+ rememberRevoked(sig);
50
+ }
51
+ export function isSignedStorageTokenRevoked(sigPart) {
52
+ return revokedSignatures.has(sigPart);
53
+ }
54
+ export function clearRevokedSignedStorageTokens() {
55
+ revokedSignatures.clear();
56
+ }
57
+ export function verifySignedStorageToken(token, requestedPath) {
58
+ if (typeof token !== "string")
59
+ return { valid: !1, reason: "malformed" };
60
+ const parts = token.split(".");
61
+ if (parts.length !== 3)
62
+ return { valid: !1, reason: "malformed" };
63
+ const headerPart = parts[0], payloadPart = parts[1], sigPart = parts[2], signingInput = `${headerPart}.${payloadPart}`, expectedSig = createHmac("sha256", getAppKey()).update(signingInput).digest();
64
+ let providedSig;
65
+ try {
66
+ providedSig = base64UrlDecode(sigPart);
67
+ } catch {
68
+ return { valid: !1, reason: "malformed" };
69
+ }
70
+ if (providedSig.length !== expectedSig.length || !timingSafeEqual(providedSig, expectedSig))
71
+ return { valid: !1, reason: "bad_signature" };
72
+ if (revokedSignatures.has(sigPart))
73
+ return { valid: !1, reason: "revoked" };
74
+ let claims;
75
+ try {
76
+ claims = JSON.parse(base64UrlDecode(payloadPart).toString("utf8"));
77
+ } catch {
78
+ return { valid: !1, reason: "malformed" };
79
+ }
80
+ const now = Math.floor(Date.now() / 1000);
81
+ if (typeof claims.exp !== "number" || now >= claims.exp)
82
+ return { valid: !1, reason: "expired" };
83
+ if (claims.path !== requestedPath)
84
+ return { valid: !1, reason: "path_mismatch" };
85
+ return { valid: !0, claims };
86
+ }
@@ -0,0 +1,110 @@
1
+ import { createHash } from "node:crypto";
2
+ import { extname } from "node:path";
3
+ const etagCache = new Map, ETAG_CACHE_MAX = 5000, FINGERPRINTED = /\.[a-f0-9]{8,}\./;
4
+ export async function serveFile(req, filePath, options = {}) {
5
+ const file = Bun.file(filePath);
6
+ if (!await file.exists())
7
+ return new Response("Not Found", { status: 404 });
8
+ const stat = await file.stat(), mtimeMs = Math.floor(typeof stat.mtimeMs === "number" ? stat.mtimeMs : stat.mtime?.getTime() ?? Date.now()), lastModified = new Date(mtimeMs).toUTCString(), contentType = options.contentType || file.type || guessMime(filePath);
9
+ let etag;
10
+ if (options.etag !== !1)
11
+ etag = await computeEtag(filePath, mtimeMs, file);
12
+ const ifNoneMatch = req.headers.get("if-none-match");
13
+ if (etag && ifNoneMatch && stripWeak(ifNoneMatch) === etag)
14
+ return new Response(null, {
15
+ status: 304,
16
+ headers: buildHeaders({ etag, lastModified, contentType, filePath, options, omitContentType: !0 })
17
+ });
18
+ const ifModifiedSince = req.headers.get("if-modified-since");
19
+ if (ifModifiedSince) {
20
+ const sinceMs = Date.parse(ifModifiedSince);
21
+ if (!Number.isNaN(sinceMs) && mtimeMs <= sinceMs)
22
+ return new Response(null, {
23
+ status: 304,
24
+ headers: buildHeaders({ etag, lastModified, contentType, filePath, options, omitContentType: !0 })
25
+ });
26
+ }
27
+ return new Response(file, {
28
+ status: 200,
29
+ headers: buildHeaders({ etag, lastModified, contentType, filePath, options })
30
+ });
31
+ }
32
+ function buildHeaders(input) {
33
+ const h = new Headers;
34
+ if (!input.omitContentType)
35
+ h.set("Content-Type", input.contentType);
36
+ h.set("Last-Modified", input.lastModified);
37
+ if (input.etag)
38
+ h.set("ETag", `"${input.etag}"`);
39
+ h.set("Cache-Control", input.options.cacheControl || pickCacheControl(input.filePath, input.options));
40
+ h.set("Vary", "Accept-Encoding");
41
+ return h;
42
+ }
43
+ function pickCacheControl(filePath, options) {
44
+ if (FINGERPRINTED.test(filePath))
45
+ return "public, max-age=31536000, immutable";
46
+ return `public, max-age=${options.defaultMaxAge ?? 300}, must-revalidate`;
47
+ }
48
+ async function computeEtag(filePath, mtimeMs, file) {
49
+ const cached = etagCache.get(filePath);
50
+ if (cached && cached.mtimeMs === mtimeMs)
51
+ return cached.etag;
52
+ const buf = await file.arrayBuffer(), etag = createHash("sha256").update(new Uint8Array(buf)).digest("hex").slice(0, 16);
53
+ if (etagCache.size >= ETAG_CACHE_MAX) {
54
+ const firstKey = etagCache.keys().next().value;
55
+ if (firstKey !== void 0)
56
+ etagCache.delete(firstKey);
57
+ }
58
+ etagCache.set(filePath, { mtimeMs, etag });
59
+ return etag;
60
+ }
61
+ function stripWeak(v) {
62
+ let s = v.trim();
63
+ if (s.startsWith("W/"))
64
+ s = s.slice(2);
65
+ if (s.startsWith('"') && s.endsWith('"'))
66
+ s = s.slice(1, -1);
67
+ return s;
68
+ }
69
+ function guessMime(filePath) {
70
+ switch (extname(filePath).toLowerCase()) {
71
+ case ".js":
72
+ case ".mjs":
73
+ case ".cjs":
74
+ return "application/javascript; charset=utf-8";
75
+ case ".css":
76
+ return "text/css; charset=utf-8";
77
+ case ".html":
78
+ case ".htm":
79
+ return "text/html; charset=utf-8";
80
+ case ".json":
81
+ return "application/json; charset=utf-8";
82
+ case ".svg":
83
+ return "image/svg+xml";
84
+ case ".webp":
85
+ return "image/webp";
86
+ case ".png":
87
+ return "image/png";
88
+ case ".jpg":
89
+ case ".jpeg":
90
+ return "image/jpeg";
91
+ case ".gif":
92
+ return "image/gif";
93
+ case ".ico":
94
+ return "image/x-icon";
95
+ case ".woff":
96
+ return "font/woff";
97
+ case ".woff2":
98
+ return "font/woff2";
99
+ case ".ttf":
100
+ return "font/ttf";
101
+ case ".txt":
102
+ return "text/plain; charset=utf-8";
103
+ case ".wasm":
104
+ return "application/wasm";
105
+ case ".map":
106
+ return "application/json; charset=utf-8";
107
+ default:
108
+ return "application/octet-stream";
109
+ }
110
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./copy";
2
+ export * from "./delete";
3
+ export * from "./files";
4
+ export * from "./folders";
5
+ export * from "./fs";
6
+ export * from "./helpers";
7
+ export * from "./move";
8
+ export * from "./visibility";
9
+ export * from "./zip";
@@ -0,0 +1,40 @@
1
+ export function localDisk(root, options) {
2
+ return {
3
+ driver: "local",
4
+ root,
5
+ visibility: "private",
6
+ ...options
7
+ };
8
+ }
9
+ export function s3Disk(bucket, options) {
10
+ return {
11
+ driver: "s3",
12
+ bucket,
13
+ region: process.env.AWS_DEFAULT_REGION || "us-east-1",
14
+ visibility: "private",
15
+ ...options
16
+ };
17
+ }
18
+ export function configFromEnv(base = {}) {
19
+ const env = process.env;
20
+ return {
21
+ default: env.FILESYSTEM_DISK || base.default || "local",
22
+ disks: {
23
+ ...base.disks,
24
+ ...env.AWS_ACCESS_KEY_ID && env.AWS_BUCKET ? {
25
+ s3: {
26
+ driver: "s3",
27
+ bucket: env.AWS_BUCKET,
28
+ region: env.AWS_DEFAULT_REGION || "us-east-1",
29
+ endpoint: env.AWS_ENDPOINT,
30
+ url: env.AWS_URL,
31
+ usePathStyleEndpoint: env.AWS_USE_PATH_STYLE_ENDPOINT === "true",
32
+ credentials: {
33
+ key: env.AWS_ACCESS_KEY_ID,
34
+ secret: env.AWS_SECRET_ACCESS_KEY || ""
35
+ }
36
+ }
37
+ } : {}
38
+ }
39
+ };
40
+ }
package/dist/types.js ADDED
@@ -0,0 +1,25 @@
1
+ export var Visibility;
2
+ ((Visibility) => {
3
+ Visibility.PUBLIC = "public";
4
+ Visibility.PRIVATE = "private";
5
+ })(Visibility ||= {});
6
+ export async function* createDirectoryListing(entries) {
7
+ for (const entry of entries)
8
+ yield entry;
9
+ }
10
+ export function normalizeExpiryToMilliseconds(expiry) {
11
+ if (expiry instanceof Date)
12
+ return expiry.getTime() - Date.now();
13
+ return expiry * 1000;
14
+ }
15
+ export function normalizeExpiryToDate(expiry) {
16
+ if (expiry instanceof Date)
17
+ return expiry;
18
+ return new Date(Date.now() + expiry * 1000);
19
+ }
20
+ export function isFile(entry) {
21
+ return entry.type === "file";
22
+ }
23
+ export function isDirectory(entry) {
24
+ return entry.type === "directory";
25
+ }