@stacksjs/storage 0.70.88 → 0.70.91
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
|
@@ -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,69 @@
|
|
|
1
|
+
import type { SignedUrlOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Mint a signed token for the given storage path.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* const token = createSignedStorageToken('reports/q4.pdf', { expiresIn: 3600 })
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare function createSignedStorageToken(path: string, options: SignedUrlOptions): string;
|
|
11
|
+
/**
|
|
12
|
+
* Revoke a signed storage token so subsequent
|
|
13
|
+
* {@link verifySignedStorageToken} calls return
|
|
14
|
+
* `{ valid: false, reason: 'revoked' }`. Idempotent — calling
|
|
15
|
+
* twice is a no-op.
|
|
16
|
+
*
|
|
17
|
+
* Pass either the full JWS compact-form token or just the signature
|
|
18
|
+
* segment (the part after the second `.`); both work because
|
|
19
|
+
* verification keys off the signature segment.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const url = await Storage.disk('local').signedUrl('reports/q4.pdf', { expiresIn: 3600 })
|
|
24
|
+
* // ... url is shared, then later leaked
|
|
25
|
+
* revokeSignedStorageToken(extractTokenFromUrl(url))
|
|
26
|
+
* // Any further fetch with that URL → 403
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function revokeSignedStorageToken(token: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Check whether a signature has been revoked. Exposed for tests
|
|
32
|
+
* and for distributed-cache replicators that need to peek at the
|
|
33
|
+
* set; production callers should rely on {@link verifySignedStorageToken}
|
|
34
|
+
* to consult this automatically.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isSignedStorageTokenRevoked(sigPart: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Test-only: clear the revocation set. The set is process-local
|
|
39
|
+
* and unbounded across tests would let one test's revoke bleed
|
|
40
|
+
* into another's verification.
|
|
41
|
+
*/
|
|
42
|
+
export declare function clearRevokedSignedStorageTokens(): void;
|
|
43
|
+
/**
|
|
44
|
+
* Verify a signed storage token. The caller MUST pass the requested
|
|
45
|
+
* path so we can ensure the token's `path` claim matches what the
|
|
46
|
+
* client is trying to fetch — otherwise an attacker could substitute
|
|
47
|
+
* any path in the URL and still pass signature verification.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* const v = verifySignedStorageToken(req.query.token, requestedPath)
|
|
52
|
+
* if (!v.valid) return new Response('Forbidden', { status: 403 })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function verifySignedStorageToken(token: string, requestedPath: string): SignedTokenVerification;
|
|
56
|
+
declare interface SignedTokenClaims {
|
|
57
|
+
iss: string
|
|
58
|
+
iat: number
|
|
59
|
+
exp: number
|
|
60
|
+
path: string
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Result of verifying a signed token.
|
|
64
|
+
*/
|
|
65
|
+
export declare interface SignedTokenVerification {
|
|
66
|
+
valid: boolean
|
|
67
|
+
reason?: 'malformed' | 'bad_signature' | 'expired' | 'path_mismatch' | 'revoked'
|
|
68
|
+
claims?: SignedTokenClaims
|
|
69
|
+
}
|
|
@@ -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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve a static file with production-grade cache headers:
|
|
3
|
+
* - Strong ETag (sha256, first 16 hex chars) computed once per
|
|
4
|
+
* `(path, mtime)` and cached in-process.
|
|
5
|
+
* - `Last-Modified` from the file's mtime.
|
|
6
|
+
* - 304 Not Modified when `If-None-Match` matches OR
|
|
7
|
+
* `If-Modified-Since` is at-or-after mtime (and ETag didn't mismatch).
|
|
8
|
+
* - `Cache-Control: public, max-age=31536000, immutable` for paths
|
|
9
|
+
* that look fingerprinted (e.g. `/_assets/foo.abc12345.js`).
|
|
10
|
+
* - `Cache-Control: public, max-age=300, must-revalidate` for
|
|
11
|
+
* everything else.
|
|
12
|
+
*
|
|
13
|
+
* Returns a `404` if the file doesn't exist. Other read errors propagate.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { serveFile } from '@stacksjs/storage'
|
|
18
|
+
* import { resolve } from 'node:path'
|
|
19
|
+
*
|
|
20
|
+
* route.get('/assets/:path', async (req) => {
|
|
21
|
+
* const url = new URL(req.url)
|
|
22
|
+
* // url.pathname is e.g. /assets/app.abc12345.js
|
|
23
|
+
* const filePath = resolve('./public', url.pathname.replace(/^\//, ''))
|
|
24
|
+
* return serveFile(req, filePath)
|
|
25
|
+
* })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function serveFile(req: Request, filePath: string, options?: ServeFileOptions): Promise<Response>;
|
|
29
|
+
/**
|
|
30
|
+
* Optional knobs for {@link serveFile}.
|
|
31
|
+
*/
|
|
32
|
+
export declare interface ServeFileOptions {
|
|
33
|
+
contentType?: string
|
|
34
|
+
cacheControl?: string
|
|
35
|
+
defaultMaxAge?: number
|
|
36
|
+
etag?: boolean
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper to create a local disk config
|
|
3
|
+
*/
|
|
4
|
+
export declare function localDisk(root: string, options?: Partial<Omit<LocalDiskConfig, 'driver' | 'root'>>): LocalDiskConfig;
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create an S3 disk config
|
|
7
|
+
*/
|
|
8
|
+
export declare function s3Disk(bucket: string, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Create filesystem config from environment variables
|
|
11
|
+
*/
|
|
12
|
+
export declare function configFromEnv(base?: Partial<FilesystemConfig>): FilesystemConfig;
|
|
13
|
+
/**
|
|
14
|
+
* Base disk configuration shared by all drivers
|
|
15
|
+
*/
|
|
16
|
+
declare interface BaseDiskConfig {
|
|
17
|
+
name?: string
|
|
18
|
+
visibility?: Visibility
|
|
19
|
+
throw?: boolean
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Local filesystem disk configuration
|
|
23
|
+
*/
|
|
24
|
+
export declare interface LocalDiskConfig extends BaseDiskConfig {
|
|
25
|
+
driver: 'local'
|
|
26
|
+
root: string
|
|
27
|
+
url?: string
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* S3 disk configuration
|
|
31
|
+
*/
|
|
32
|
+
export declare interface S3DiskConfig extends BaseDiskConfig {
|
|
33
|
+
driver: 's3'
|
|
34
|
+
bucket: string
|
|
35
|
+
region?: string
|
|
36
|
+
prefix?: string
|
|
37
|
+
endpoint?: string
|
|
38
|
+
usePathStyleEndpoint?: boolean
|
|
39
|
+
url?: string
|
|
40
|
+
credentials?: {
|
|
41
|
+
key: string
|
|
42
|
+
secret: string
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Userland-augmentable disk-name registry (stacksjs/stacks#1924).
|
|
47
|
+
*
|
|
48
|
+
* Empty by default — the framework can't know an app's configured
|
|
49
|
+
* disks at its own build time. Apps declare their disks once and get
|
|
50
|
+
* autocomplete on `Storage.disk('…')` everywhere:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* // types/storage.d.ts
|
|
54
|
+
* declare module '@stacksjs/storage' {
|
|
55
|
+
* interface KnownDisks {
|
|
56
|
+
* local: true
|
|
57
|
+
* public: true
|
|
58
|
+
* s3: true
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Mirrors the `DatabaseSchema` pattern from stacksjs/stacks#1923.
|
|
64
|
+
*/
|
|
65
|
+
// eslint-disable-next-line ts/no-empty-object-type
|
|
66
|
+
export declare interface KnownDisks {}
|
|
67
|
+
/**
|
|
68
|
+
* Main filesystem configuration
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* const config: FilesystemConfig = {
|
|
73
|
+
* default: 'local',
|
|
74
|
+
* disks: {
|
|
75
|
+
* local: {
|
|
76
|
+
* driver: 'local',
|
|
77
|
+
* root: '/storage/app',
|
|
78
|
+
* },
|
|
79
|
+
* public: {
|
|
80
|
+
* driver: 'local',
|
|
81
|
+
* root: '/public',
|
|
82
|
+
* url: '/storage',
|
|
83
|
+
* visibility: 'public',
|
|
84
|
+
* },
|
|
85
|
+
* s3: {
|
|
86
|
+
* driver: 's3',
|
|
87
|
+
* bucket: 'my-bucket',
|
|
88
|
+
* region: 'us-east-1',
|
|
89
|
+
* },
|
|
90
|
+
* },
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare interface FilesystemConfig {
|
|
95
|
+
default: string
|
|
96
|
+
disks: Record<string, DiskConfig>
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Environment variable mappings for filesystem configuration
|
|
100
|
+
*/
|
|
101
|
+
export declare interface FilesystemEnv {
|
|
102
|
+
FILESYSTEM_DISK?: string
|
|
103
|
+
AWS_ACCESS_KEY_ID?: string
|
|
104
|
+
AWS_SECRET_ACCESS_KEY?: string
|
|
105
|
+
AWS_DEFAULT_REGION?: string
|
|
106
|
+
AWS_BUCKET?: string
|
|
107
|
+
AWS_ENDPOINT?: string
|
|
108
|
+
AWS_URL?: string
|
|
109
|
+
AWS_USE_PATH_STYLE_ENDPOINT?: string
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Filesystem Configuration Types
|
|
113
|
+
*
|
|
114
|
+
* Laravel-style filesystem configuration with clean, typed interfaces.
|
|
115
|
+
* Supports local, public, and S3 disk drivers.
|
|
116
|
+
*/
|
|
117
|
+
export type FilesystemDriver = 'local' | 's3';
|
|
118
|
+
export type Visibility = 'public' | 'private';
|
|
119
|
+
/**
|
|
120
|
+
* Union type for all disk configurations
|
|
121
|
+
*/
|
|
122
|
+
export type DiskConfig = LocalDiskConfig | S3DiskConfig;
|
|
123
|
+
/**
|
|
124
|
+
* A configured disk name (autocompletes to the keys of an augmented
|
|
125
|
+
* {@link KnownDisks}) or any other string. The `(string & {})` branch
|
|
126
|
+
* keeps the union from collapsing back to `string`, so known disks
|
|
127
|
+
* surface in autocomplete while arbitrary names still type-check —
|
|
128
|
+
* apps that haven't augmented `KnownDisks` keep compiling unchanged.
|
|
129
|
+
*/
|
|
130
|
+
// eslint-disable-next-line ts/no-empty-object-type
|
|
131
|
+
export type DiskName = (keyof KnownDisks & string) | (string & {});
|
|
@@ -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
|
+
}
|