@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
@@ -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
+ }
@@ -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,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,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
+ }