@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - S3 Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so `storage.js` (and the main barrel) never pull
|
|
5
|
+
* the AWS SDK onto the import path: `@aws-sdk/client-s3` and
|
|
6
|
+
* `@aws-sdk/s3-request-presigner` are optional peer dependencies and are
|
|
7
|
+
* only loaded when an S3 adapter method is actually called. Apps running
|
|
8
|
+
* `STORAGE_PROVIDER=local` do not need them installed.
|
|
9
|
+
*
|
|
10
|
+
* The dynamic imports are marked `webpackIgnore` so bundlers leave them as
|
|
11
|
+
* runtime imports (resolved from the app's `node_modules`) instead of
|
|
12
|
+
* trying to resolve them at build time. Apps that use S3 must install both
|
|
13
|
+
* packages — the scaffolded web app already lists them as dependencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const S3_CLIENT_PACKAGE = "@aws-sdk/client-s3";
|
|
17
|
+
const S3_PRESIGNER_PACKAGE = "@aws-sdk/s3-request-presigner";
|
|
18
|
+
|
|
19
|
+
function isMissingPackageError(error, packageName) {
|
|
20
|
+
const message = error?.message ?? "";
|
|
21
|
+
return (
|
|
22
|
+
error?.code === "ERR_MODULE_NOT_FOUND" ||
|
|
23
|
+
error?.code === "MODULE_NOT_FOUND" ||
|
|
24
|
+
message.includes(`Cannot find package '${packageName}'`) ||
|
|
25
|
+
message.includes(`Cannot find module '${packageName}'`)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function importS3ClientPackage() {
|
|
30
|
+
try {
|
|
31
|
+
return await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (isMissingPackageError(error, S3_CLIENT_PACKAGE)) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'S3 storage support requires installing "@aws-sdk/client-s3" in the app that uses @techstream/quark-core/storage/s3.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function importS3PresignerPackage() {
|
|
43
|
+
try {
|
|
44
|
+
return await import(
|
|
45
|
+
/* webpackIgnore: true */ "@aws-sdk/s3-request-presigner"
|
|
46
|
+
);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (isMissingPackageError(error, S3_PRESIGNER_PACKAGE)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'Signed upload URLs require installing "@aws-sdk/s3-request-presigner" in the app that uses @techstream/quark-core/storage/s3.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cache the in-flight module promises so repeated adapter calls do not
|
|
58
|
+
// re-run the dynamic import (and a failed import keeps reporting the same
|
|
59
|
+
// friendly error).
|
|
60
|
+
let _s3ClientPackage = null;
|
|
61
|
+
let _s3PresignerPackage = null;
|
|
62
|
+
|
|
63
|
+
function loadS3ClientPackage() {
|
|
64
|
+
_s3ClientPackage ??= importS3ClientPackage();
|
|
65
|
+
return _s3ClientPackage;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadS3PresignerPackage() {
|
|
69
|
+
_s3PresignerPackage ??= importS3PresignerPackage();
|
|
70
|
+
return _s3PresignerPackage;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// S3-Compatible Storage Adapter (AWS S3 / Cloudflare R2 / MinIO)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates an S3-compatible storage adapter.
|
|
79
|
+
* Works with AWS S3, Cloudflare R2, MinIO, and any S3-compatible service.
|
|
80
|
+
*
|
|
81
|
+
* Required env vars (or pass via options):
|
|
82
|
+
* S3_BUCKET - Bucket name
|
|
83
|
+
* S3_REGION - Region (use "auto" for R2)
|
|
84
|
+
* S3_ACCESS_KEY_ID - Access key
|
|
85
|
+
* S3_SECRET_ACCESS_KEY - Secret key
|
|
86
|
+
* S3_ENDPOINT - Custom endpoint (required for R2/MinIO)
|
|
87
|
+
* S3_PUBLIC_URL - Optional public URL prefix for signed/public URLs
|
|
88
|
+
*
|
|
89
|
+
* For Cloudflare R2:
|
|
90
|
+
* S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
|
|
91
|
+
* S3_REGION=auto
|
|
92
|
+
*
|
|
93
|
+
* Requires `@aws-sdk/client-s3` (and `@aws-sdk/s3-request-presigner` for
|
|
94
|
+
* `getSignedUploadUrl`) to be installed in the consuming app. Both are
|
|
95
|
+
* loaded lazily on first use, so creating an adapter and reading its
|
|
96
|
+
* `provider` / `getPublicUrl()` works without them.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} options
|
|
99
|
+
* @returns {StorageAdapter}
|
|
100
|
+
*/
|
|
101
|
+
export function createS3Storage(options = {}) {
|
|
102
|
+
const bucket = options.bucket || process.env.S3_BUCKET;
|
|
103
|
+
const region = options.region || process.env.S3_REGION || "auto";
|
|
104
|
+
const endpoint = options.endpoint || process.env.S3_ENDPOINT;
|
|
105
|
+
const accessKeyId = options.accessKeyId || process.env.S3_ACCESS_KEY_ID;
|
|
106
|
+
const secretAccessKey =
|
|
107
|
+
options.secretAccessKey || process.env.S3_SECRET_ACCESS_KEY;
|
|
108
|
+
const publicUrl = options.publicUrl || process.env.S3_PUBLIC_URL;
|
|
109
|
+
|
|
110
|
+
if (!bucket) throw new Error("S3_BUCKET is required for S3 storage");
|
|
111
|
+
if (!accessKeyId)
|
|
112
|
+
throw new Error("S3_ACCESS_KEY_ID is required for S3 storage");
|
|
113
|
+
if (!secretAccessKey)
|
|
114
|
+
throw new Error("S3_SECRET_ACCESS_KEY is required for S3 storage");
|
|
115
|
+
|
|
116
|
+
/** @type {import("@aws-sdk/client-s3").S3Client | null} */
|
|
117
|
+
let _client = null;
|
|
118
|
+
|
|
119
|
+
async function getClient() {
|
|
120
|
+
if (_client) return _client;
|
|
121
|
+
const { S3Client } = await loadS3ClientPackage();
|
|
122
|
+
_client = new S3Client({
|
|
123
|
+
region,
|
|
124
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
125
|
+
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
|
|
126
|
+
});
|
|
127
|
+
return _client;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
provider: "s3",
|
|
132
|
+
|
|
133
|
+
async put(key, data, meta = {}) {
|
|
134
|
+
let body;
|
|
135
|
+
if (Buffer.isBuffer(data) || typeof data === "string") {
|
|
136
|
+
body = data;
|
|
137
|
+
} else if (data && typeof data.pipe === "function") {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
for await (const chunk of data) {
|
|
140
|
+
chunks.push(chunk);
|
|
141
|
+
}
|
|
142
|
+
body = Buffer.concat(chunks);
|
|
143
|
+
} else {
|
|
144
|
+
throw new Error("put() expects a Buffer, string, or Readable stream");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { PutObjectCommand } = await loadS3ClientPackage();
|
|
148
|
+
const client = await getClient();
|
|
149
|
+
await client.send(
|
|
150
|
+
new PutObjectCommand({
|
|
151
|
+
Bucket: bucket,
|
|
152
|
+
Key: key,
|
|
153
|
+
Body: body,
|
|
154
|
+
ContentType: meta.contentType || "application/octet-stream",
|
|
155
|
+
...(meta.cacheControl ? { CacheControl: meta.cacheControl } : {}),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return { key, provider: "s3" };
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async get(key) {
|
|
163
|
+
const { GetObjectCommand } = await loadS3ClientPackage();
|
|
164
|
+
const client = await getClient();
|
|
165
|
+
const response = await client.send(
|
|
166
|
+
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const chunks = [];
|
|
170
|
+
for await (const chunk of response.Body) {
|
|
171
|
+
chunks.push(chunk);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
body: Buffer.concat(chunks),
|
|
176
|
+
contentType: response.ContentType || null,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async delete(key) {
|
|
181
|
+
const { DeleteObjectCommand } = await loadS3ClientPackage();
|
|
182
|
+
const client = await getClient();
|
|
183
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async exists(key) {
|
|
187
|
+
const { HeadObjectCommand } = await loadS3ClientPackage();
|
|
188
|
+
try {
|
|
189
|
+
const client = await getClient();
|
|
190
|
+
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
191
|
+
return true;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
getPublicUrl(key) {
|
|
201
|
+
if (publicUrl) {
|
|
202
|
+
return `${publicUrl.replace(/\/$/, "")}/${key}`;
|
|
203
|
+
}
|
|
204
|
+
return `/api/files/${encodeURIComponent(key)}`;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate a pre-signed URL that allows a client (e.g. the browser) to
|
|
209
|
+
* upload directly to S3/R2 without routing the binary through the server.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} key - Storage key for the object to be uploaded
|
|
212
|
+
* @param {Object} [options]
|
|
213
|
+
* @param {number} [options.expiresIn=300] - URL validity in seconds (default: 5 min)
|
|
214
|
+
* @param {string} [options.contentType] - Expected Content-Type; enforced by S3
|
|
215
|
+
* @returns {Promise<{ url: string, key: string, expiresAt: string }>}
|
|
216
|
+
*/
|
|
217
|
+
async getSignedUploadUrl(key, options = {}) {
|
|
218
|
+
const expiresIn = options.expiresIn ?? 300;
|
|
219
|
+
|
|
220
|
+
const { PutObjectCommand } = await loadS3ClientPackage();
|
|
221
|
+
const { getSignedUrl } = await loadS3PresignerPackage();
|
|
222
|
+
|
|
223
|
+
const command = new PutObjectCommand({
|
|
224
|
+
Bucket: bucket,
|
|
225
|
+
Key: key,
|
|
226
|
+
...(options.contentType ? { ContentType: options.contentType } : {}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const client = await getClient();
|
|
230
|
+
const url = await getSignedUrl(client, command, { expiresIn });
|
|
231
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
232
|
+
|
|
233
|
+
return { url, key, expiresAt };
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - File Storage
|
|
3
|
+
*
|
|
4
|
+
* Adapter-based storage abstraction supporting local filesystem and
|
|
5
|
+
* S3-compatible providers (AWS S3, Cloudflare R2, MinIO, etc.).
|
|
6
|
+
*
|
|
7
|
+
* The S3 adapter lives in its own module (storage-s3.js) and loads the
|
|
8
|
+
* AWS SDK lazily, so importing this file — or the main barrel — never
|
|
9
|
+
* requires `@aws-sdk/client-s3` / `@aws-sdk/s3-request-presigner` to be
|
|
10
|
+
* installed. Apps using `STORAGE_PROVIDER=s3` must install them (the
|
|
11
|
+
* scaffolded web app declares both as dependencies).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const storage = createStorage(); // reads STORAGE_PROVIDER env
|
|
15
|
+
* await storage.put(key, buffer, { contentType: "image/png" });
|
|
16
|
+
* const { body, contentType } = await storage.get(key);
|
|
17
|
+
* await storage.delete(key);
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createWriteStream } from "node:fs";
|
|
21
|
+
import { mkdir, readFile, stat, unlink } from "node:fs/promises";
|
|
22
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
23
|
+
import { pipeline } from "node:stream/promises";
|
|
24
|
+
|
|
25
|
+
// Import + re-export the S3 adapter so existing deep-imports continue
|
|
26
|
+
// to work (`import { createS3Storage } from "@techstream/quark-core/storage"`).
|
|
27
|
+
// storage-s3.js has no top-level AWS SDK imports, so this stays safe for
|
|
28
|
+
// local-only deployments that never installed the optional peer deps.
|
|
29
|
+
import { createS3Storage } from "./storage-s3.js";
|
|
30
|
+
|
|
31
|
+
export { createS3Storage };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolves a storage key against the base directory and guards against
|
|
35
|
+
* path-traversal attacks. Throws if the resolved path escapes baseDir.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} baseDir - Absolute base directory
|
|
38
|
+
* @param {string} key - Caller-supplied storage key
|
|
39
|
+
* @returns {string} Absolute, validated file path
|
|
40
|
+
*/
|
|
41
|
+
function safePath(baseDir, key) {
|
|
42
|
+
const resolved = resolve(baseDir, key);
|
|
43
|
+
const relativePath = relative(baseDir, resolved);
|
|
44
|
+
if (
|
|
45
|
+
relativePath === ".." ||
|
|
46
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
47
|
+
isAbsolute(relativePath)
|
|
48
|
+
) {
|
|
49
|
+
throw new Error("Path traversal detected - key escapes storage directory");
|
|
50
|
+
}
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Local Storage Adapter
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a local-filesystem storage adapter.
|
|
60
|
+
* @param {Object} options
|
|
61
|
+
* @param {string} [options.directory="./uploads"] - Base directory for files
|
|
62
|
+
* @returns {StorageAdapter}
|
|
63
|
+
*/
|
|
64
|
+
export function createLocalStorage(options = {}) {
|
|
65
|
+
const baseDir = resolve(
|
|
66
|
+
options.directory || process.env.STORAGE_LOCAL_DIR || "./uploads",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
provider: "local",
|
|
71
|
+
|
|
72
|
+
async put(key, data, _meta = {}) {
|
|
73
|
+
const filePath = safePath(baseDir, key);
|
|
74
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
75
|
+
|
|
76
|
+
if (Buffer.isBuffer(data) || typeof data === "string") {
|
|
77
|
+
const ws = createWriteStream(filePath);
|
|
78
|
+
ws.end(data);
|
|
79
|
+
await new Promise((res, rej) => {
|
|
80
|
+
ws.on("finish", res);
|
|
81
|
+
ws.on("error", rej);
|
|
82
|
+
});
|
|
83
|
+
} else if (data && typeof data.pipe === "function") {
|
|
84
|
+
await pipeline(data, createWriteStream(filePath));
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error("put() expects a Buffer, string, or Readable stream");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { key, provider: "local" };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async get(key) {
|
|
93
|
+
const filePath = safePath(baseDir, key);
|
|
94
|
+
const buffer = await readFile(filePath);
|
|
95
|
+
return { body: buffer, contentType: null };
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async delete(key) {
|
|
99
|
+
const filePath = safePath(baseDir, key);
|
|
100
|
+
try {
|
|
101
|
+
await unlink(filePath);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code !== "ENOENT") throw err;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async exists(key) {
|
|
108
|
+
const filePath = safePath(baseDir, key);
|
|
109
|
+
try {
|
|
110
|
+
await stat(filePath);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
getPublicUrl(key) {
|
|
118
|
+
// Local files are served via the API route, not a public URL
|
|
119
|
+
return `/api/files/${encodeURIComponent(key)}`;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async getSignedUploadUrl(_key, _options = {}) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"getSignedUploadUrl() requires STORAGE_PROVIDER=s3. " +
|
|
125
|
+
"For local development, upload files via POST /api/files instead.",
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Factory
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates a storage adapter based on STORAGE_PROVIDER env var.
|
|
137
|
+
* "local" (default) → local filesystem
|
|
138
|
+
* "s3" → S3-compatible (AWS, R2, MinIO)
|
|
139
|
+
*
|
|
140
|
+
* The S3 adapter is imported from storage-s3.js, which defers the AWS SDK
|
|
141
|
+
* import to the first adapter call. Local-only apps therefore never need
|
|
142
|
+
* the optional `@aws-sdk/*` peer dependencies installed.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} [options] - Override options passed to the adapter
|
|
145
|
+
* @returns {StorageAdapter}
|
|
146
|
+
*/
|
|
147
|
+
export function createStorage(options = {}) {
|
|
148
|
+
const provider = options.provider || process.env.STORAGE_PROVIDER || "local";
|
|
149
|
+
|
|
150
|
+
switch (provider) {
|
|
151
|
+
case "s3":
|
|
152
|
+
return createS3Storage(options);
|
|
153
|
+
case "local":
|
|
154
|
+
return createLocalStorage(options);
|
|
155
|
+
default:
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Unknown STORAGE_PROVIDER: "${provider}". Supported: "local", "s3"`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generate a unique storage key for a file upload.
|
|
164
|
+
* Format: <prefix>/<year>/<month>/<randomId>-<sanitizedFilename>
|
|
165
|
+
*
|
|
166
|
+
* @param {string} originalFilename
|
|
167
|
+
* @param {Object} [options]
|
|
168
|
+
* @param {string} [options.prefix="uploads"] - Key prefix / folder
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
export function generateStorageKey(originalFilename, options = {}) {
|
|
172
|
+
const prefix = options.prefix || "uploads";
|
|
173
|
+
const now = new Date();
|
|
174
|
+
const year = now.getFullYear();
|
|
175
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
176
|
+
const id = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
177
|
+
const safeName = originalFilename
|
|
178
|
+
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
179
|
+
.replace(/_{2,}/g, "_")
|
|
180
|
+
.toLowerCase();
|
|
181
|
+
|
|
182
|
+
return `${prefix}/${year}/${month}/${id}-${safeName}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Returns the public URL for a stored asset.
|
|
187
|
+
*
|
|
188
|
+
* Checks `ASSET_CDN_URL` first - if set, prepends it to the key.
|
|
189
|
+
* This is provider-agnostic: works with any CDN (CloudFront, Cloudflare,
|
|
190
|
+
* Bunny, Fastly, etc.) as long as the CDN is pointed at the same bucket.
|
|
191
|
+
*
|
|
192
|
+
* Falls back to the local API route (`/api/files/<key>`) when no CDN is
|
|
193
|
+
* configured - covers local development and any environment where
|
|
194
|
+
* `STORAGE_PROVIDER=local` is used.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} key - Storage key (e.g. "uploads/2026/02/abc-photo.jpg")
|
|
197
|
+
* @returns {string} Full CDN URL or local API route
|
|
198
|
+
*/
|
|
199
|
+
export function getAssetUrl(key) {
|
|
200
|
+
const cdnBase = process.env.ASSET_CDN_URL;
|
|
201
|
+
if (cdnBase) return `${cdnBase.replace(/\/$/, "")}/${key}`;
|
|
202
|
+
return `/api/files/${encodeURIComponent(key)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @typedef {Object} StorageAdapter
|
|
207
|
+
* @property {"local" | "s3"} provider
|
|
208
|
+
* @property {(key: string, data: Buffer | Readable | string, meta?: { contentType?: string, cacheControl?: string }) => Promise<{ key: string, provider: string }>} put
|
|
209
|
+
* @property {(key: string) => Promise<{ body: Buffer, contentType: string | null }>} get
|
|
210
|
+
* @property {(key: string) => Promise<void>} delete
|
|
211
|
+
* @property {(key: string) => Promise<boolean>} exists
|
|
212
|
+
* @property {(key: string) => string} getPublicUrl
|
|
213
|
+
* @property {(key: string, options?: { expiresIn?: number, contentType?: string }) => Promise<{ url: string, key: string, expiresAt: string }>} getSignedUploadUrl
|
|
214
|
+
*/
|