@nextlyhq/storage-uploadthing 0.0.2-alpha.60 → 0.0.2-alpha.64

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,1327 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+
4
+ // ../nextly/dist/chunk-HJ5IQPOY.mjs
5
+ function isDbError(err) {
6
+ if (!err || typeof err !== "object") return false;
7
+ const obj = err;
8
+ return obj.name === "DbError" && typeof obj.kind === "string";
9
+ }
10
+ var NEXTLY_ERROR_STATUS = {
11
+ VALIDATION_ERROR: 400,
12
+ INVALID_INPUT: 400,
13
+ AUTH_REQUIRED: 401,
14
+ AUTH_INVALID_CREDENTIALS: 401,
15
+ TOKEN_EXPIRED: 401,
16
+ FORBIDDEN: 403,
17
+ // The schema builder is off in this environment (production by default).
18
+ // Separate from FORBIDDEN: the caller's permissions are not the problem.
19
+ BUILDER_DISABLED: 403,
20
+ NOT_FOUND: 404,
21
+ CONFLICT: 409,
22
+ DUPLICATE: 409,
23
+ RATE_LIMITED: 429,
24
+ PAYLOAD_TOO_LARGE: 413,
25
+ UNSUPPORTED_MEDIA_TYPE: 415,
26
+ // 422: understood, well-formed, and refused on a rule the caller can act on.
27
+ // Deliberately NOT added to CANONICAL_CODE_FOR_STATUS -- that list drives the
28
+ // status -> code direction, where 422 stays mapped to INVALID_INPUT.
29
+ BUSINESS_RULE_VIOLATION: 422,
30
+ INTERNAL_ERROR: 500,
31
+ DATABASE_ERROR: 500,
32
+ EXTERNAL_SERVICE_ERROR: 502,
33
+ SERVICE_UNAVAILABLE: 503,
34
+ // Outbound-fetch safety (utils/validate-external-url): a URL refused for SSRF
35
+ // safety, and a fetch that timed out / exceeded the size cap / failed to decode.
36
+ EXTERNAL_URL_BLOCKED: 400,
37
+ EXTERNAL_REQUEST_FAILED: 502,
38
+ FILENAME_INVALID: 400,
39
+ EXTENSION_BLOCKED: 400,
40
+ MIME_BLOCKED: 415,
41
+ MIME_NOT_ALLOWED: 415,
42
+ SIZE_EXCEEDED: 413,
43
+ // A stored object exceeded the cap a READ was given, which is a different
44
+ // question from SIZE_EXCEEDED above: that one refuses an upload the caller
45
+ // is sending, this one refuses to buffer an object already stored. Kept
46
+ // apart so a caller discriminating on the code cannot match both.
47
+ STORAGE_READ_TOO_LARGE: 413,
48
+ // A stored object did not answer within the deadline the read was given. 504
49
+ // rather than 500 because the failure is the BACKEND not answering, and a
50
+ // caller can act on that difference: a gateway timeout is worth retrying, an
51
+ // internal error is not.
52
+ STORAGE_READ_TIMEOUT: 504,
53
+ // The store answered, and answered badly. 502 rather than 500 because the
54
+ // fault is UPSTREAM of this process: a caller can retry it, and an operator
55
+ // reading the log needs to look at the bucket rather than at this service.
56
+ // Distinct from the timeout above, which never got an answer at all.
57
+ STORAGE_READ_UNREACHABLE: 502,
58
+ MAGIC_BYTE_MISMATCH: 400,
59
+ SVG_SANITIZATION_FAILED: 400,
60
+ UNSUPPORTED_FOR_BACKEND: 415,
61
+ // Plan B — schema bookkeeping consolidation.
62
+ NEXTLY_LEGACY_BOOKKEEPING_DETECTED: 409,
63
+ NEXTLY_UPGRADE_TABLE_NAME_COLLISION: 409,
64
+ NEXTLY_UPGRADE_IN_PROGRESS: 409,
65
+ // Plan C2 — nextly migrate phases.
66
+ NEXTLY_MIGRATE_LOCK_BUSY: 409,
67
+ NEXTLY_BASELINE_LOCK_NOT_HELD: 409,
68
+ NEXTLY_RESOLVE_LOCK_NOT_HELD: 409,
69
+ // Boot refused to serve: the migrate lock stayed held past the wait deadline,
70
+ // so this process never established whether the schema matches the code. 503
71
+ // rather than 409 — a load balancer should take the instance out of rotation
72
+ // and retry it, which is exactly the recovery this refusal wants.
73
+ NEXTLY_BOOT_MIGRATIONS_NOT_RUN: 503,
74
+ // Still running rather than refused. 503 for the same reason: retry shortly.
75
+ NEXTLY_BOOT_MIGRATIONS_PENDING: 503,
76
+ NEXTLY_CORE_DESTRUCTIVE_REFUSED: 409,
77
+ NEXTLY_MIGRATION_DRIFT: 409,
78
+ NEXTLY_MIGRATION_APPLY_FAILED: 500,
79
+ // Plan C3 — migrate:resolve recovery command.
80
+ NEXTLY_MIGRATION_FILE_MISSING: 404,
81
+ NEXTLY_MIGRATION_SNAPSHOT_MISSING: 404,
82
+ NEXTLY_MIGRATION_RESOLVE_DRIFT: 409,
83
+ NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409,
84
+ // Plan D — UI schema support.
85
+ NEXTLY_UI_SCHEMA_INVALID: 400,
86
+ NEXTLY_SCHEMA_SLUG_COLLISION: 409,
87
+ NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400,
88
+ // Plugin platform (P2b) — schema extend (contributes.extend) + relations (D15).
89
+ NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN: 400,
90
+ NEXTLY_SCHEMA_EXTEND_FIELD_DUPLICATE: 409,
91
+ NEXTLY_SCHEMA_CROSS_PLUGIN_RELATION: 409,
92
+ // Plugin platform (P2c) — framework remap (.rename()).
93
+ NEXTLY_SCHEMA_RENAME_UNKNOWN_TARGET: 400,
94
+ // Plugin platform — a declared admin.clientConfig that cannot be delivered
95
+ // to the browser, refused at boot rather than serialized mangled.
96
+ NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
97
+ // Plugin platform — a contributed admin widget that cannot be delivered to
98
+ // the browser. Refused at boot because it is serialized into the ONE
99
+ // `/api/admin-meta/workspace` payload: a value `JSON.stringify` throws on
100
+ // fails that request for every admin, not just the widget's own card.
101
+ NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
102
+ // Plugin platform (P0) — boot-time plugin dependency/version resolution.
103
+ PLUGIN_RESOLUTION_ERROR: 500,
104
+ // Plugin platform (P4) — contributes.routes collection (D25).
105
+ NEXTLY_ROUTE_COLLISION: 409,
106
+ NEXTLY_ROUTE_INVALID_PATH: 400,
107
+ // An email transport whose library is an optional peer dependency the host
108
+ // has not installed. 503 rather than 500: the request is not malformed and
109
+ // nothing is broken, the install simply cannot carry it out yet, and the
110
+ // remedy is one command on the server rather than a change by the caller.
111
+ NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
112
+ // The tooling that compiles `nextly.config.ts` is an optional peer the host
113
+ // has not installed. 503 rather than 500 for the same reason as the mail
114
+ // transport above: nothing is broken and the request is not malformed, the
115
+ // install simply cannot carry it out until one command is run.
116
+ NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503
117
+ };
118
+ var CANONICAL_CODE_FOR_STATUS = [
119
+ "VALIDATION_ERROR",
120
+ "AUTH_REQUIRED",
121
+ "FORBIDDEN",
122
+ "NOT_FOUND",
123
+ "CONFLICT",
124
+ "PAYLOAD_TOO_LARGE",
125
+ "UNSUPPORTED_MEDIA_TYPE",
126
+ "RATE_LIMITED",
127
+ "EXTERNAL_SERVICE_ERROR",
128
+ "SERVICE_UNAVAILABLE"
129
+ ];
130
+ ({
131
+ ...Object.fromEntries(
132
+ CANONICAL_CODE_FOR_STATUS.map((code) => [NEXTLY_ERROR_STATUS[code], code])
133
+ )});
134
+ var DB_ERROR_MAPPING = {
135
+ "unique-violation": {
136
+ code: "DUPLICATE",
137
+ statusCode: 409,
138
+ publicMessage: "Resource already exists."
139
+ },
140
+ "fk-violation": {
141
+ code: "VALIDATION_ERROR",
142
+ statusCode: 400,
143
+ publicMessage: "Referenced record does not exist."
144
+ },
145
+ "not-null-violation": {
146
+ code: "VALIDATION_ERROR",
147
+ statusCode: 400,
148
+ publicMessage: "A required field is missing."
149
+ },
150
+ constraint: {
151
+ code: "VALIDATION_ERROR",
152
+ statusCode: 400,
153
+ publicMessage: "The provided data violates a constraint."
154
+ },
155
+ deadlock: {
156
+ code: "CONFLICT",
157
+ statusCode: 409,
158
+ publicMessage: "The operation could not be completed. Please retry."
159
+ },
160
+ "serialization-failure": {
161
+ code: "CONFLICT",
162
+ statusCode: 409,
163
+ publicMessage: "The operation could not be completed. Please retry."
164
+ },
165
+ timeout: {
166
+ code: "DATABASE_ERROR",
167
+ statusCode: 500,
168
+ publicMessage: "The operation timed out. Please try again."
169
+ },
170
+ "connection-lost": {
171
+ code: "DATABASE_ERROR",
172
+ statusCode: 500,
173
+ publicMessage: "A temporary database error occurred. Please try again."
174
+ },
175
+ syntax: {
176
+ code: "INTERNAL_ERROR",
177
+ statusCode: 500,
178
+ publicMessage: "An unexpected error occurred."
179
+ },
180
+ internal: {
181
+ code: "INTERNAL_ERROR",
182
+ statusCode: 500,
183
+ publicMessage: "An unexpected error occurred."
184
+ }
185
+ };
186
+ var NEXTLY_ERROR_BRAND = Symbol.for("nextly/NextlyError");
187
+ function hasBrand(value) {
188
+ if (value === null || typeof value !== "object" && typeof value !== "function") {
189
+ return false;
190
+ }
191
+ return value[NEXTLY_ERROR_BRAND] === true;
192
+ }
193
+ var NextlyError = class _NextlyError extends Error {
194
+ code;
195
+ statusCode;
196
+ publicMessage;
197
+ publicData;
198
+ messageKey;
199
+ logMessage;
200
+ logContext;
201
+ cause;
202
+ timestamp;
203
+ constructor(opts) {
204
+ super(opts.publicMessage);
205
+ this.name = "NextlyError";
206
+ this.code = opts.code;
207
+ this.publicMessage = opts.publicMessage;
208
+ this.publicData = opts.publicData;
209
+ this.messageKey = opts.messageKey;
210
+ this.logMessage = opts.logMessage;
211
+ this.logContext = opts.logContext;
212
+ this.cause = opts.cause;
213
+ this.timestamp = /* @__PURE__ */ new Date();
214
+ this.statusCode = _NextlyError.resolveStatusCode(opts);
215
+ Error.captureStackTrace?.(this, _NextlyError);
216
+ }
217
+ static {
218
+ _NextlyError.prototype[NEXTLY_ERROR_BRAND] = true;
219
+ }
220
+ static resolveStatusCode(opts) {
221
+ if (typeof opts.statusCode === "number") return opts.statusCode;
222
+ if (opts.code in NEXTLY_ERROR_STATUS) {
223
+ return NEXTLY_ERROR_STATUS[opts.code];
224
+ }
225
+ return 500;
226
+ }
227
+ /** HTTP-safe JSON. Strips logMessage / logContext / cause / stack. */
228
+ toResponseJSON(requestId) {
229
+ const json = {
230
+ code: String(this.code),
231
+ message: this.publicMessage,
232
+ requestId
233
+ };
234
+ if (this.messageKey) json.messageKey = this.messageKey;
235
+ if (this.publicData !== void 0) json.data = this.publicData;
236
+ return json;
237
+ }
238
+ /** Operator-facing JSON for log lines. Includes everything. */
239
+ toLogJSON(requestId) {
240
+ return {
241
+ code: this.code,
242
+ statusCode: this.statusCode,
243
+ publicMessage: this.publicMessage,
244
+ messageKey: this.messageKey,
245
+ logMessage: this.logMessage,
246
+ logContext: this.logContext,
247
+ cause: this.cause ? {
248
+ name: this.cause.name,
249
+ message: this.cause.message,
250
+ stack: this.cause.stack
251
+ } : void 0,
252
+ timestamp: this.timestamp.toISOString(),
253
+ requestId
254
+ };
255
+ }
256
+ // ────────────────────────────────────────────────────────────────────
257
+ // Static factories — the recommended throw site for every common case.
258
+ // Public messages here follow the §13.8 rubric: complete sentence,
259
+ // generic, no identifiers, no value echoing, no policy hints.
260
+ // ────────────────────────────────────────────────────────────────────
261
+ static invalidCredentials(opts) {
262
+ return new _NextlyError({
263
+ code: "AUTH_INVALID_CREDENTIALS",
264
+ publicMessage: "Invalid email or password.",
265
+ logMessage: "Login failed",
266
+ logContext: opts?.logContext
267
+ });
268
+ }
269
+ static authRequired(opts) {
270
+ return new _NextlyError({
271
+ code: "AUTH_REQUIRED",
272
+ publicMessage: "Authentication required.",
273
+ logContext: opts?.logContext
274
+ });
275
+ }
276
+ /**
277
+ * Distinct from `authRequired`: the caller was authenticated but the
278
+ * session token expired. Clients key on the TOKEN_EXPIRED *code* to
279
+ * silently refresh and retry rather than redirecting to login; the public
280
+ * message stays the generic spec §13.6 string ("Authentication required.")
281
+ * so the wire never reveals the session state — same as `authRequired`.
282
+ */
283
+ static tokenExpired(opts) {
284
+ return new _NextlyError({
285
+ code: "TOKEN_EXPIRED",
286
+ publicMessage: "Authentication required.",
287
+ logContext: opts?.logContext
288
+ });
289
+ }
290
+ static notFound(opts) {
291
+ return new _NextlyError({
292
+ code: "NOT_FOUND",
293
+ publicMessage: opts?.message ?? "Not found.",
294
+ cause: opts?.cause,
295
+ logContext: opts?.logContext
296
+ });
297
+ }
298
+ static forbidden(opts) {
299
+ return new _NextlyError({
300
+ code: "FORBIDDEN",
301
+ publicMessage: "You don't have permission to perform this action.",
302
+ cause: opts?.cause,
303
+ logContext: opts?.logContext
304
+ });
305
+ }
306
+ /**
307
+ * A call the caller got wrong, where naming the mistake IS the value of the
308
+ * error.
309
+ *
310
+ * The only factory that takes its public message from the caller. The
311
+ * generic messages elsewhere exist so an HTTP response cannot leak internal
312
+ * detail; this one is for arguments and configuration a developer controls
313
+ * and must be told about — a missing option, an unusable combination — where
314
+ * `internal()` would reduce the one useful sentence to "An unexpected error
315
+ * occurred." Do not pass user-supplied data through it.
316
+ */
317
+ static invalidInput(opts) {
318
+ return new _NextlyError({
319
+ code: "INVALID_INPUT",
320
+ publicMessage: opts.message,
321
+ logContext: opts.logContext
322
+ });
323
+ }
324
+ static validation(opts) {
325
+ return new _NextlyError({
326
+ code: "VALIDATION_ERROR",
327
+ publicMessage: "Validation failed.",
328
+ publicData: { errors: opts.errors },
329
+ cause: opts.cause,
330
+ logContext: opts.logContext
331
+ });
332
+ }
333
+ static conflict(opts) {
334
+ return new _NextlyError({
335
+ code: "CONFLICT",
336
+ publicMessage: opts?.message ?? "The resource has changed since you last loaded it. Please refresh and try again.",
337
+ cause: opts?.cause,
338
+ logContext: { reason: opts?.reason, ...opts?.logContext }
339
+ });
340
+ }
341
+ static duplicate(opts) {
342
+ return new _NextlyError({
343
+ code: "DUPLICATE",
344
+ publicMessage: "Resource already exists.",
345
+ logContext: opts?.logContext
346
+ });
347
+ }
348
+ static rateLimited(opts) {
349
+ return new _NextlyError({
350
+ code: "RATE_LIMITED",
351
+ publicMessage: "Too many requests. Please try again later.",
352
+ publicData: opts?.retryAfterSeconds !== void 0 ? { retryAfterSeconds: opts.retryAfterSeconds } : void 0,
353
+ logContext: opts?.logContext
354
+ });
355
+ }
356
+ static internal(opts) {
357
+ return new _NextlyError({
358
+ code: "INTERNAL_ERROR",
359
+ publicMessage: "An unexpected error occurred.",
360
+ cause: opts?.cause,
361
+ logContext: opts?.logContext
362
+ });
363
+ }
364
+ // Accepts an optional `logMessage` override so callers (e.g. the health
365
+ // route) can record a specific operator narrative ("Health check failed")
366
+ // while the public message stays canonical per spec §13.8.5.
367
+ static serviceUnavailable(opts) {
368
+ return new _NextlyError({
369
+ code: "SERVICE_UNAVAILABLE",
370
+ publicMessage: opts?.publicMessage ?? "Service unavailable. Please try again later.",
371
+ logMessage: opts?.logMessage,
372
+ cause: opts?.cause,
373
+ logContext: opts?.logContext
374
+ });
375
+ }
376
+ /**
377
+ * Convert a DbError (or arbitrary unknown thrown by the DB layer) to a
378
+ * NextlyError with a generic public message and rich logContext. Used by
379
+ * `withDbErrors` for auto-conversion (Pattern A) and by services that
380
+ * catch DB errors at boundaries (Pattern B). Spec §8.2 mapping table.
381
+ *
382
+ * Never leaks DB driver text, constraint names, or table names into
383
+ * `publicMessage`. All DB context goes into `logContext`. The original
384
+ * DbError is preserved as `cause`.
385
+ */
386
+ static fromDatabaseError(error) {
387
+ if (isDbError(error)) {
388
+ const mapping = DB_ERROR_MAPPING[error.kind];
389
+ const logContext = {
390
+ dbKind: error.kind,
391
+ dialect: error.dialect
392
+ };
393
+ if (error.code !== void 0) logContext.dbCode = error.code;
394
+ if (error.meta !== void 0) logContext.meta = error.meta;
395
+ return new _NextlyError({
396
+ code: mapping.code,
397
+ statusCode: mapping.statusCode,
398
+ publicMessage: mapping.publicMessage,
399
+ logMessage: "Database error",
400
+ logContext,
401
+ cause: error
402
+ });
403
+ }
404
+ return new _NextlyError({
405
+ code: "INTERNAL_ERROR",
406
+ statusCode: 500,
407
+ publicMessage: "An unexpected error occurred.",
408
+ logMessage: "Non-DbError passed to fromDatabaseError",
409
+ cause: error instanceof Error ? error : void 0,
410
+ logContext: error instanceof Error ? void 0 : { value: String(error) }
411
+ });
412
+ }
413
+ // ────────────────────────────────────────────────────────────────────
414
+ // Type guards. Structural rather than `instanceof` so they survive
415
+ // package-boundary mismatches (one consumer's NextlyError is a
416
+ // different module instance from another's).
417
+ // ────────────────────────────────────────────────────────────────────
418
+ static is(err) {
419
+ return hasBrand(err);
420
+ }
421
+ static isCode(err, code) {
422
+ return hasBrand(err) && err.code === code;
423
+ }
424
+ static isNotFound(err) {
425
+ return _NextlyError.isCode(err, "NOT_FOUND");
426
+ }
427
+ static isValidation(err) {
428
+ return _NextlyError.isCode(err, "VALIDATION_ERROR");
429
+ }
430
+ static isAuthRequired(err) {
431
+ return _NextlyError.isCode(err, "AUTH_REQUIRED");
432
+ }
433
+ static isForbidden(err) {
434
+ return _NextlyError.isCode(err, "FORBIDDEN");
435
+ }
436
+ static isConflict(err) {
437
+ return _NextlyError.isCode(err, "CONFLICT");
438
+ }
439
+ static isRateLimited(err) {
440
+ return _NextlyError.isCode(err, "RATE_LIMITED");
441
+ }
442
+ };
443
+
444
+ // ../nextly/dist/chunk-W77AQUUQ.mjs
445
+ var StorageReadTooLargeError = class extends NextlyError {
446
+ constructor(path2, maxBytes, size) {
447
+ super({
448
+ code: "STORAGE_READ_TOO_LARGE",
449
+ publicMessage: "The stored file is larger than the limit for this read.",
450
+ logContext: {
451
+ path: path2,
452
+ maxBytes,
453
+ ...size === void 0 ? {} : { size }
454
+ }
455
+ });
456
+ this.path = path2;
457
+ this.maxBytes = maxBytes;
458
+ this.size = size;
459
+ }
460
+ };
461
+ var StorageReadTimeoutError = class extends NextlyError {
462
+ constructor(path2, timeoutMs) {
463
+ super({
464
+ code: "STORAGE_READ_TIMEOUT",
465
+ publicMessage: "The stored file could not be read in time.",
466
+ logContext: { path: path2, timeoutMs }
467
+ });
468
+ this.path = path2;
469
+ this.timeoutMs = timeoutMs;
470
+ }
471
+ };
472
+
473
+ // ../nextly/dist/chunk-C2VHLQKK.mjs
474
+ var IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
475
+ var PRIVATE_IPV4_CIDRS = [
476
+ "0.0.0.0/8",
477
+ // RFC1122 "this network" / wildcard
478
+ "10.0.0.0/8",
479
+ // RFC1918
480
+ "100.64.0.0/10",
481
+ // RFC6598 CGNAT
482
+ "127.0.0.0/8",
483
+ // RFC1122 loopback
484
+ "169.254.0.0/16",
485
+ // RFC3927 link-local + AWS/GCP metadata
486
+ "172.16.0.0/12",
487
+ // RFC1918
488
+ "192.0.0.0/24",
489
+ // RFC6890 IETF protocol assignments
490
+ "192.0.2.0/24",
491
+ // RFC5737 documentation
492
+ "192.168.0.0/16",
493
+ // RFC1918
494
+ "198.18.0.0/15",
495
+ // RFC2544 benchmarking
496
+ "198.51.100.0/24",
497
+ // RFC5737 documentation
498
+ "203.0.113.0/24",
499
+ // RFC5737 documentation
500
+ "224.0.0.0/4",
501
+ // RFC5771 multicast
502
+ "240.0.0.0/4"
503
+ // RFC1112 reserved
504
+ ];
505
+ var CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
506
+ "metadata.google.internal",
507
+ "metadata.googleapis.com",
508
+ "metadata"
509
+ // GCP short form
510
+ ]);
511
+ var ExternalUrlError = class extends NextlyError {
512
+ constructor(message, url) {
513
+ super({
514
+ code: "EXTERNAL_URL_BLOCKED",
515
+ publicMessage: message,
516
+ logContext: { url }
517
+ });
518
+ this.url = url;
519
+ this.name = "ExternalUrlError";
520
+ }
521
+ };
522
+ async function validateExternalUrl(rawUrl, options = {}) {
523
+ let parsed;
524
+ try {
525
+ parsed = new URL(rawUrl);
526
+ } catch {
527
+ throw new ExternalUrlError("Invalid URL", rawUrl);
528
+ }
529
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
530
+ const isLocalhostHostname = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
531
+ const baseProtocols = options.allowedProtocols ?? ["https:"];
532
+ const protocols = options.allowLocalhost && isLocalhostHostname ? [...baseProtocols, "http:"] : baseProtocols;
533
+ if (!protocols.includes(parsed.protocol)) {
534
+ throw new ExternalUrlError(
535
+ `Protocol ${parsed.protocol} not allowed (allowed: ${protocols.join(", ")})`,
536
+ rawUrl
537
+ );
538
+ }
539
+ if (CLOUD_METADATA_HOSTS.has(hostname)) {
540
+ throw new ExternalUrlError(
541
+ `Cloud-metadata hostname rejected: ${parsed.hostname}`,
542
+ rawUrl
543
+ );
544
+ }
545
+ if (IPV4_RE.test(hostname)) {
546
+ if (!isPublicIpv4(
547
+ hostname,
548
+ options.allowLocalhost === true && hostname === "127.0.0.1"
549
+ )) {
550
+ throw new ExternalUrlError(
551
+ `Resolved to non-public IP: ${hostname}`,
552
+ rawUrl
553
+ );
554
+ }
555
+ return { url: parsed, pinnedIp: hostname, family: 4 };
556
+ }
557
+ if (hostname.includes(":")) {
558
+ if (!isPublicIpv6(
559
+ hostname,
560
+ options.allowLocalhost === true && hostname === "::1"
561
+ )) {
562
+ throw new ExternalUrlError(
563
+ `Resolved to non-public IP: ${hostname}`,
564
+ rawUrl
565
+ );
566
+ }
567
+ return { url: parsed, pinnedIp: hostname, family: 6 };
568
+ }
569
+ const { lookup } = await import('dns/promises');
570
+ let addresses;
571
+ try {
572
+ addresses = await lookup(hostname, { all: true });
573
+ } catch (err) {
574
+ throw new ExternalUrlError(
575
+ `DNS lookup failed: ${err.message}`,
576
+ rawUrl
577
+ );
578
+ }
579
+ if (addresses.length === 0) {
580
+ throw new ExternalUrlError("No IPs returned for host", rawUrl);
581
+ }
582
+ for (const { address, family } of addresses) {
583
+ const allow = options.allowLocalhost === true && isLocalhostHostname && family === 4;
584
+ const ok = family === 4 ? isPublicIpv4(address, allow) : isPublicIpv6(address, allow);
585
+ if (!ok) {
586
+ throw new ExternalUrlError(
587
+ `Resolved to non-public IP: ${address}`,
588
+ rawUrl
589
+ );
590
+ }
591
+ }
592
+ const first = addresses[0];
593
+ return {
594
+ url: parsed,
595
+ pinnedIp: first.address,
596
+ family: first.family === 6 ? 6 : 4
597
+ };
598
+ }
599
+ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
600
+ var DEFAULT_TIMEOUT_MS = 3e4;
601
+ var SafeFetchError = class extends NextlyError {
602
+ constructor(message, url, reason, status) {
603
+ super({
604
+ code: "EXTERNAL_REQUEST_FAILED",
605
+ publicMessage: message,
606
+ logContext: { url, reason }
607
+ });
608
+ this.url = url;
609
+ this.reason = reason;
610
+ this.status = status;
611
+ this.name = "SafeFetchError";
612
+ }
613
+ };
614
+ function createPinnedLookup(ip, family) {
615
+ return (_hostname, options, callback) => {
616
+ if (options && options.all) {
617
+ callback(null, [{ address: ip, family }]);
618
+ } else {
619
+ callback(null, ip, family);
620
+ }
621
+ };
622
+ }
623
+ async function safeFetch(rawUrl, options = {}) {
624
+ const {
625
+ allowLocalhost,
626
+ allowedProtocols,
627
+ method,
628
+ headers,
629
+ body,
630
+ signal,
631
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
632
+ timeoutMs = DEFAULT_TIMEOUT_MS
633
+ } = options;
634
+ const controller = new AbortController();
635
+ const forwardAbort = () => controller.abort(signal?.reason);
636
+ if (signal) {
637
+ if (signal.aborted) controller.abort(signal.reason);
638
+ else signal.addEventListener("abort", forwardAbort, { once: true });
639
+ }
640
+ const timer = setTimeout(
641
+ () => controller.abort(
642
+ new SafeFetchError(`Request exceeded ${timeoutMs}ms`, rawUrl, "timeout")
643
+ ),
644
+ timeoutMs
645
+ );
646
+ try {
647
+ const validated = await abortable(
648
+ validateExternalUrl(rawUrl, { allowLocalhost, allowedProtocols }),
649
+ controller.signal
650
+ );
651
+ return await pinnedFetch(validated, {
652
+ method,
653
+ headers,
654
+ body,
655
+ signal: controller.signal,
656
+ maxResponseBytes
657
+ });
658
+ } finally {
659
+ clearTimeout(timer);
660
+ if (signal) signal.removeEventListener("abort", forwardAbort);
661
+ }
662
+ }
663
+ function abortError(signal) {
664
+ const reason = signal.reason;
665
+ if (reason instanceof Error) return reason;
666
+ return new DOMException("The operation was aborted", "AbortError");
667
+ }
668
+ function abortable(promise, signal) {
669
+ if (signal.aborted) return Promise.reject(abortError(signal));
670
+ let onAbort;
671
+ const aborted = new Promise((_resolve, reject) => {
672
+ onAbort = () => reject(abortError(signal));
673
+ signal.addEventListener("abort", onAbort, { once: true });
674
+ });
675
+ return Promise.race([promise, aborted]).finally(
676
+ () => signal.removeEventListener("abort", onAbort)
677
+ );
678
+ }
679
+ async function pinnedFetch(validated, init) {
680
+ const { url, pinnedIp, family } = validated;
681
+ const httpMod = url.protocol === "https:" ? await import('https') : await import('http');
682
+ const zlib = await import('zlib');
683
+ const lookup = createPinnedLookup(pinnedIp, family);
684
+ const outHeaders = toOutgoingHeaders(init.headers);
685
+ if (init.body != null) {
686
+ delete outHeaders["transfer-encoding"];
687
+ outHeaders["content-length"] = String(
688
+ typeof init.body === "string" ? Buffer.byteLength(init.body) : init.body.byteLength
689
+ );
690
+ }
691
+ return new Promise((resolve2, reject) => {
692
+ let settled = false;
693
+ const settle = (fn) => {
694
+ if (settled) return;
695
+ settled = true;
696
+ fn();
697
+ };
698
+ const failure = (err) => init.signal.aborted ? abortError(init.signal) : err;
699
+ const req = httpMod.request(
700
+ url,
701
+ {
702
+ method: init.method ?? "GET",
703
+ headers: outHeaders,
704
+ lookup,
705
+ // Fresh socket per request so a pooled connection can't reuse a
706
+ // differently-resolved address, and so the pinned lookup always runs.
707
+ agent: false,
708
+ // The combined deadline/caller signal aborts the request in flight.
709
+ signal: init.signal
710
+ },
711
+ (res) => {
712
+ const chunks = [];
713
+ let received = 0;
714
+ res.on("data", (chunk) => {
715
+ received += chunk.length;
716
+ if (received > init.maxResponseBytes) {
717
+ res.destroy();
718
+ req.destroy();
719
+ settle(
720
+ () => reject(
721
+ new SafeFetchError(
722
+ `Response body exceeded ${init.maxResponseBytes} bytes`,
723
+ url.href,
724
+ "response-too-large",
725
+ res.statusCode
726
+ )
727
+ )
728
+ );
729
+ return;
730
+ }
731
+ chunks.push(chunk);
732
+ });
733
+ res.on("end", () => {
734
+ const decoded = decodeBody(
735
+ Buffer.concat(chunks),
736
+ res.headers["content-encoding"],
737
+ zlib,
738
+ init.maxResponseBytes,
739
+ url.href,
740
+ res.statusCode
741
+ );
742
+ if (decoded instanceof SafeFetchError) {
743
+ settle(() => reject(decoded));
744
+ return;
745
+ }
746
+ settle(
747
+ () => resolve2(toWhatwgResponse(res, decoded.body, decoded.decoded))
748
+ );
749
+ });
750
+ res.on("error", (err) => settle(() => reject(failure(err))));
751
+ }
752
+ );
753
+ req.on("error", (err) => settle(() => reject(failure(err))));
754
+ if (!req.destroyed) {
755
+ if (init.body != null) req.write(init.body);
756
+ req.end();
757
+ }
758
+ });
759
+ }
760
+ function toOutgoingHeaders(headers) {
761
+ const out = {};
762
+ if (!headers) return out;
763
+ const add = (rawKey, value) => {
764
+ const key = rawKey.toLowerCase();
765
+ if (key === "host") return;
766
+ const existing = out[key];
767
+ out[key] = existing === void 0 ? value : [].concat(existing, value);
768
+ };
769
+ if (headers instanceof Headers) {
770
+ headers.forEach((value, key) => add(key, value));
771
+ } else if (Array.isArray(headers)) {
772
+ for (const [key, value] of headers) add(key, value);
773
+ } else {
774
+ for (const [key, value] of Object.entries(headers)) add(key, value);
775
+ }
776
+ return out;
777
+ }
778
+ function toWhatwgResponse(res, body, stripContentHeaders) {
779
+ const headers = new Headers();
780
+ for (const [key, value] of Object.entries(res.headers)) {
781
+ if (value == null) continue;
782
+ const lower = key.toLowerCase();
783
+ if (stripContentHeaders && (lower === "content-encoding" || lower === "content-length")) {
784
+ continue;
785
+ }
786
+ const values = Array.isArray(value) ? value : [value];
787
+ for (const v of values) {
788
+ try {
789
+ headers.append(key, v);
790
+ } catch {
791
+ }
792
+ }
793
+ }
794
+ const raw = res.statusCode ?? 502;
795
+ const status = raw >= 200 && raw <= 599 ? raw : 502;
796
+ const nullBody = status === 204 || status === 205 || status === 304;
797
+ const bytes = new Uint8Array(body.byteLength);
798
+ bytes.set(body);
799
+ return new Response(nullBody ? null : bytes, {
800
+ status,
801
+ statusText: res.statusMessage ?? "",
802
+ headers
803
+ });
804
+ }
805
+ function decodeBody(buf, encoding, zlib, cap, url, status) {
806
+ if (!encoding) return { body: buf, decoded: false };
807
+ if (buf.length === 0) return { body: buf, decoded: true };
808
+ const layers = encoding.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
809
+ const opts = { maxOutputLength: cap };
810
+ let current = buf;
811
+ let decoded = false;
812
+ try {
813
+ for (let i = layers.length - 1; i >= 0; i--) {
814
+ const enc = layers[i];
815
+ if (enc === "identity") continue;
816
+ if (enc === "gzip" || enc === "x-gzip") {
817
+ current = zlib.gunzipSync(current, opts);
818
+ } else if (enc === "br") {
819
+ current = zlib.brotliDecompressSync(current, opts);
820
+ } else if (enc === "deflate") {
821
+ try {
822
+ current = zlib.inflateSync(current, opts);
823
+ } catch (inflateErr) {
824
+ if (errorCode(inflateErr) === "ERR_BUFFER_TOO_LARGE")
825
+ throw inflateErr;
826
+ current = zlib.inflateRawSync(current, opts);
827
+ }
828
+ } else {
829
+ if (decoded) {
830
+ return new SafeFetchError(
831
+ `Cannot fully decode content-encoding "${encoding}"`,
832
+ url,
833
+ "decode-failed",
834
+ status
835
+ );
836
+ }
837
+ return { body: buf, decoded: false };
838
+ }
839
+ decoded = true;
840
+ }
841
+ return { body: current, decoded };
842
+ } catch (err) {
843
+ const tooLarge = errorCode(err) === "ERR_BUFFER_TOO_LARGE";
844
+ return new SafeFetchError(
845
+ tooLarge ? `Decoded ${encoding} response body exceeded the size cap` : `Failed to decode ${encoding} response body`,
846
+ url,
847
+ tooLarge ? "response-too-large" : "decode-failed",
848
+ status
849
+ );
850
+ }
851
+ }
852
+ function errorCode(err) {
853
+ if (err != null && typeof err === "object" && "code" in err) {
854
+ const code = err.code;
855
+ return typeof code === "string" ? code : void 0;
856
+ }
857
+ return void 0;
858
+ }
859
+ function ipv4ToInt(ip) {
860
+ const parts = ip.split(".");
861
+ if (parts.length !== 4) return null;
862
+ let acc = 0;
863
+ for (const p of parts) {
864
+ const n = Number(p);
865
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
866
+ acc = acc << 8 | n;
867
+ }
868
+ return acc >>> 0;
869
+ }
870
+ function isIpv4InCidr(intIp, cidr) {
871
+ const [addr, prefixRaw] = cidr.split("/");
872
+ const intCidr = ipv4ToInt(addr);
873
+ if (intCidr === null) return false;
874
+ const prefix = Number(prefixRaw);
875
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
876
+ const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
877
+ return (intIp & mask) >>> 0 === (intCidr & mask) >>> 0;
878
+ }
879
+ function isPublicIpv4(addr, allowLoopback) {
880
+ const intIp = ipv4ToInt(addr);
881
+ if (intIp === null) return false;
882
+ if (allowLoopback && addr === "127.0.0.1") return true;
883
+ for (const cidr of PRIVATE_IPV4_CIDRS) {
884
+ if (isIpv4InCidr(intIp, cidr)) return false;
885
+ }
886
+ return true;
887
+ }
888
+ function mappedIpv4(lower) {
889
+ const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
890
+ if (dotted) return dotted[1];
891
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
892
+ if (hex) {
893
+ const hi = parseInt(hex[1], 16);
894
+ const lo = parseInt(hex[2], 16);
895
+ return [hi >> 8, hi & 255, lo >> 8, lo & 255].join(".");
896
+ }
897
+ return null;
898
+ }
899
+ function isPublicIpv6(addr, allowLoopback) {
900
+ const lower = addr.toLowerCase();
901
+ if (allowLoopback && lower === "::1") return true;
902
+ const mapped = mappedIpv4(lower);
903
+ if (mapped) {
904
+ return isPublicIpv4(mapped, allowLoopback);
905
+ }
906
+ if (lower === "::" || lower === "::1") return false;
907
+ const firstHextet = lower.split(":")[0] || "";
908
+ if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd"))
909
+ return false;
910
+ if (firstHextet.startsWith("fe8") || firstHextet.startsWith("fe9"))
911
+ return false;
912
+ if (firstHextet.startsWith("fea") || firstHextet.startsWith("feb"))
913
+ return false;
914
+ if (firstHextet.startsWith("ff")) return false;
915
+ return true;
916
+ }
917
+ async function fetchStoredBytes(url, context, label, options, signal) {
918
+ let response;
919
+ try {
920
+ response = await safeFetch(url, {
921
+ ...options?.maxBytes === void 0 ? {} : { maxResponseBytes: options.maxBytes },
922
+ /*
923
+ * A caller's signal REPLACES the deadline rather than joining it.
924
+ *
925
+ * These adapters look the object's address up before fetching it, and
926
+ * that lookup can stall too. Starting a fresh timer here would give the
927
+ * fetch its own full budget on top of however long the lookup took, so a
928
+ * read could outlive the deadline the caller was told applied — by
929
+ * roughly double. One signal begun before the lookup governs both phases.
930
+ */
931
+ ...signal !== void 0 ? { signal } : options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
932
+ });
933
+ } catch (error) {
934
+ const verdict = classifyFetchFailure(error);
935
+ if (verdict === "absent") return null;
936
+ if (verdict === "oversized") {
937
+ throw new StorageReadTooLargeError(
938
+ context,
939
+ resolveReadBounds(options).maxBytes
940
+ );
941
+ }
942
+ throw error;
943
+ }
944
+ if (response.status === 404) return null;
945
+ if (!response.ok) {
946
+ throw NextlyError.internal({
947
+ logContext: {
948
+ service: label,
949
+ path: context,
950
+ status: response.status,
951
+ statusText: response.statusText
952
+ }
953
+ });
954
+ }
955
+ return Buffer.from(await response.arrayBuffer());
956
+ }
957
+ function classifyFetchFailure(error) {
958
+ if (!(error instanceof SafeFetchError)) return "unknown";
959
+ if (error.status === 404) return "absent";
960
+ const successful = error.status !== void 0 && error.status >= 200 && error.status < 300;
961
+ return error.reason === "response-too-large" && successful ? "oversized" : "unknown";
962
+ }
963
+ var DEFAULT_READ_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
964
+ var DEFAULT_READ_MAX_BYTES = DEFAULT_MAX_RESPONSE_BYTES;
965
+ function resolveReadBounds(options) {
966
+ return {
967
+ maxBytes: options?.maxBytes ?? DEFAULT_READ_MAX_BYTES,
968
+ timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
969
+ };
970
+ }
971
+ function deadlineSignal(timeoutMs, context) {
972
+ const controller = new AbortController();
973
+ const timer = setTimeout(() => {
974
+ controller.abort(new StorageReadTimeoutError(context, timeoutMs));
975
+ }, timeoutMs);
976
+ timer.unref?.();
977
+ return {
978
+ signal: controller.signal,
979
+ /*
980
+ * Called once the read has settled, whichever way it went. `unref` only
981
+ * stops a pending timer holding the process open; it still sits on the
982
+ * timer heap retaining the controller and the path, so a server doing a
983
+ * thousand reads a second keeps thirty seconds of them alive and then runs
984
+ * a thousand aborts that answer nobody.
985
+ */
986
+ cancel: () => {
987
+ clearTimeout(timer);
988
+ }
989
+ };
990
+ }
991
+ async function withDeadline(work, signal) {
992
+ if (signal === void 0) return await work;
993
+ return await Promise.race([
994
+ work,
995
+ new Promise((_, reject) => {
996
+ if (signal.aborted) {
997
+ reject(signal.reason);
998
+ return;
999
+ }
1000
+ signal.addEventListener("abort", () => reject(signal.reason), {
1001
+ once: true
1002
+ });
1003
+ })
1004
+ ]);
1005
+ }
1006
+ var BaseStorageAdapter = class {
1007
+ /**
1008
+ * Get adapter info including capabilities.
1009
+ *
1010
+ * Default implementation that auto-detects capabilities by checking
1011
+ * if getSignedUrl and getPresignedUploadUrl methods are implemented.
1012
+ * Override in subclasses for more accurate capability reporting.
1013
+ *
1014
+ * @returns Adapter info with type, name, and capability flags
1015
+ */
1016
+ getInfo() {
1017
+ const hasSignedUrls = "getSignedUrl" in this && typeof this.getSignedUrl === "function";
1018
+ const hasClientUploads = "getPresignedUploadUrl" in this && typeof this.getPresignedUploadUrl === "function";
1019
+ return {
1020
+ type: this.getType(),
1021
+ name: this.constructor.name,
1022
+ supportsSignedUrls: hasSignedUrls,
1023
+ supportsClientUploads: hasClientUploads
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Sanitize filename to prevent directory traversal and storage issues.
1028
+ *
1029
+ * Security measures:
1030
+ * - Remove path separators (/, \)
1031
+ * - Keep only basename (no directories)
1032
+ * - Replace problematic characters with hyphens
1033
+ * - Preserve alphanumeric, dots, underscores, hyphens
1034
+ *
1035
+ * @param filename - Original filename to sanitize
1036
+ * @returns Sanitized filename safe for storage
1037
+ *
1038
+ * @example
1039
+ * ```typescript
1040
+ * this.sanitizeFilename('../../../etc/passwd') // 'passwd'
1041
+ * this.sanitizeFilename('my file (1).jpg') // 'my-file--1-.jpg'
1042
+ * this.sanitizeFilename('photo.jpg') // 'photo.jpg'
1043
+ * ```
1044
+ */
1045
+ sanitizeFilename(filename) {
1046
+ const basename = filename.split(/[/\\]/).pop() || filename;
1047
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
1048
+ }
1049
+ /**
1050
+ * Generate a unique storage key with date-based prefix.
1051
+ *
1052
+ * Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}
1053
+ * This provides:
1054
+ * - Unique keys via UUID to prevent collisions
1055
+ * - Date-based organization for easier management
1056
+ * - Readable filenames for debugging
1057
+ *
1058
+ * @param filename - Original filename (will be sanitized)
1059
+ * @param folder - Optional folder/prefix for organizing uploads
1060
+ * @returns Generated storage key
1061
+ *
1062
+ * @example
1063
+ * ```typescript
1064
+ * this.generateKey('photo.jpg')
1065
+ * // '2026/01/abc-123-...-photo.jpg'
1066
+ *
1067
+ * this.generateKey('doc.pdf', 'documents')
1068
+ * // 'documents/2026/01/abc-123-...-doc.pdf'
1069
+ * ```
1070
+ */
1071
+ generateKey(filename, folder) {
1072
+ const sanitized = this.sanitizeFilename(filename);
1073
+ const uuid = crypto.randomUUID();
1074
+ const date = /* @__PURE__ */ new Date();
1075
+ const year = date.getFullYear();
1076
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1077
+ const prefix = folder ? `${folder}/${year}/${month}` : `${year}/${month}`;
1078
+ return `${prefix}/${uuid}-${sanitized}`;
1079
+ }
1080
+ };
1081
+ var gitignoreUpdated = false;
1082
+ var READ_CHUNK_BYTES = 64 * 1024;
1083
+ async function readCounted(handle, filePath, maxBytes, signal) {
1084
+ const chunks = [];
1085
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
1086
+ let received = 0;
1087
+ for (; ; ) {
1088
+ if (signal.aborted) throw signal.reason;
1089
+ const { bytesRead } = await handle.read(chunk, 0, READ_CHUNK_BYTES, null);
1090
+ if (bytesRead === 0) break;
1091
+ received += bytesRead;
1092
+ if (received > maxBytes) {
1093
+ throw new StorageReadTooLargeError(filePath, maxBytes, received);
1094
+ }
1095
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
1096
+ }
1097
+ return Buffer.concat(chunks);
1098
+ }
1099
+ var LocalStorageAdapter = class extends BaseStorageAdapter {
1100
+ basePath;
1101
+ baseUrl;
1102
+ constructor(config) {
1103
+ super();
1104
+ this.basePath = path.resolve(config.basePath);
1105
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
1106
+ }
1107
+ /**
1108
+ * Upload file to local disk.
1109
+ * Creates directories as needed and writes the file buffer.
1110
+ */
1111
+ async upload(buffer, options) {
1112
+ const key = this.generateKey(options.filename, options.folder);
1113
+ const fullPath = this.resolveAndValidate(key);
1114
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
1115
+ await fs.writeFile(fullPath, buffer);
1116
+ await this.ensureGitignore();
1117
+ return {
1118
+ url: this.getPublicUrl(key),
1119
+ path: key
1120
+ };
1121
+ }
1122
+ /**
1123
+ * Delete file from local disk.
1124
+ * Silently succeeds if the file doesn't exist.
1125
+ */
1126
+ async delete(filePath) {
1127
+ let fullPath;
1128
+ try {
1129
+ fullPath = this.resolveAndValidate(filePath);
1130
+ } catch {
1131
+ return;
1132
+ }
1133
+ try {
1134
+ await fs.unlink(fullPath);
1135
+ } catch (err) {
1136
+ if (err.code !== "ENOENT") {
1137
+ throw err;
1138
+ }
1139
+ }
1140
+ }
1141
+ /**
1142
+ * Bulk delete files from local disk.
1143
+ * Uses parallel unlinks with Promise.allSettled for best performance.
1144
+ */
1145
+ async bulkDelete(filePaths) {
1146
+ const results = await Promise.allSettled(
1147
+ filePaths.map(async (filePath) => {
1148
+ await this.delete(filePath);
1149
+ return filePath;
1150
+ })
1151
+ );
1152
+ const successful = [];
1153
+ const failed = [];
1154
+ results.forEach((result, index) => {
1155
+ if (result.status === "fulfilled") {
1156
+ successful.push(filePaths[index]);
1157
+ } else {
1158
+ failed.push({
1159
+ filePath: filePaths[index],
1160
+ error: result.reason?.message || "Unknown error"
1161
+ });
1162
+ }
1163
+ });
1164
+ return { successful, failed };
1165
+ }
1166
+ /**
1167
+ * Check if file exists on local disk.
1168
+ */
1169
+ async exists(filePath) {
1170
+ try {
1171
+ const fullPath = this.resolveAndValidate(filePath);
1172
+ await fs.access(fullPath);
1173
+ return true;
1174
+ } catch {
1175
+ return false;
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Get public URL for a file.
1180
+ * Returns baseUrl + relative path for Next.js static file serving.
1181
+ */
1182
+ getPublicUrl(filePath) {
1183
+ const cleanPath = filePath.replace(/^\/+/, "");
1184
+ return `${this.baseUrl}/${cleanPath}`;
1185
+ }
1186
+ /**
1187
+ * Get storage type identifier.
1188
+ */
1189
+ getType() {
1190
+ return "local";
1191
+ }
1192
+ /**
1193
+ * Read file contents from local disk.
1194
+ *
1195
+ * Returns the file buffer, or `null` if the file is not found.
1196
+ *
1197
+ * Honours the caller's bounds, which matters MORE here than anywhere else:
1198
+ * this is the default backend, so a bound the cloud adapters keep and this
1199
+ * one ignores is a bound that does nothing in the commonest deployment.
1200
+ *
1201
+ * Both bounds are enforced against ONE open descriptor. Resolving the name,
1202
+ * asking for its size and then reading it again by name resolves the same
1203
+ * name three times: a file replaced in between is read under a cap measured
1204
+ * on the file it displaced, and one appended to in between is buffered whole
1205
+ * however small it was when asked. The descriptor settles the first, and
1206
+ * counting the bytes as they arrive settles the second.
1207
+ */
1208
+ async read(filePath, options) {
1209
+ let fullPath;
1210
+ try {
1211
+ fullPath = this.resolveAndValidate(filePath);
1212
+ } catch {
1213
+ return null;
1214
+ }
1215
+ const bounds = resolveReadBounds(options);
1216
+ const deadline = deadlineSignal(bounds.timeoutMs, filePath);
1217
+ const work = this.readWithinCap(
1218
+ fullPath,
1219
+ filePath,
1220
+ bounds.maxBytes,
1221
+ deadline.signal
1222
+ );
1223
+ void work.catch(() => void 0);
1224
+ try {
1225
+ return await withDeadline(work, deadline.signal);
1226
+ } finally {
1227
+ deadline.cancel();
1228
+ }
1229
+ }
1230
+ // ============================================================
1231
+ // Private Helpers
1232
+ // ============================================================
1233
+ /**
1234
+ * Read one opened file, refusing it the moment it exceeds the cap.
1235
+ *
1236
+ * Split out so the whole descriptor lifetime — open, size, read, close —
1237
+ * sits inside a single promise the caller can race, and so the close in its
1238
+ * `finally` still runs when that race has already answered the caller.
1239
+ *
1240
+ * @param fullPath - Absolute path, already validated against `basePath`
1241
+ * @param filePath - The caller's path, carried only in the refusal
1242
+ * @param maxBytes - The cap this read runs under
1243
+ */
1244
+ async readWithinCap(fullPath, filePath, maxBytes, signal) {
1245
+ const handle = await fs.open(fullPath, "r").catch(() => null);
1246
+ if (handle === null) return null;
1247
+ try {
1248
+ if (signal.aborted) throw signal.reason;
1249
+ const { size } = await handle.stat();
1250
+ if (size > maxBytes) {
1251
+ throw new StorageReadTooLargeError(filePath, maxBytes, size);
1252
+ }
1253
+ return await readCounted(handle, filePath, maxBytes, signal);
1254
+ } catch (error) {
1255
+ if (error instanceof StorageReadTooLargeError) throw error;
1256
+ return null;
1257
+ } finally {
1258
+ await handle.close().catch(() => void 0);
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Resolve a relative file path to an absolute path within basePath.
1263
+ * Throws if the resolved path would escape basePath (path traversal attack).
1264
+ */
1265
+ resolveAndValidate(filePath) {
1266
+ const sanitized = filePath.replace(/^[/\\]+/, "").replace(/\.\.[/\\]/g, "");
1267
+ const fullPath = path.resolve(this.basePath, sanitized);
1268
+ if (!fullPath.startsWith(this.basePath)) {
1269
+ throw new Error(
1270
+ `Path traversal detected: ${filePath} resolves outside of storage directory`
1271
+ );
1272
+ }
1273
+ return fullPath;
1274
+ }
1275
+ /**
1276
+ * Auto-add the uploads directory to .gitignore on first upload.
1277
+ * Prevents accidentally committing uploaded files to git.
1278
+ */
1279
+ async ensureGitignore() {
1280
+ if (gitignoreUpdated) return;
1281
+ gitignoreUpdated = true;
1282
+ try {
1283
+ const projectRoot = path.resolve(this.basePath, "..", "..");
1284
+ const gitignorePath = path.join(projectRoot, ".gitignore");
1285
+ let content = "";
1286
+ try {
1287
+ content = await fs.readFile(gitignorePath, "utf-8");
1288
+ } catch {
1289
+ }
1290
+ const uploadsDirRelative = path.relative(projectRoot, this.basePath);
1291
+ const ignorePattern = uploadsDirRelative + "/";
1292
+ if (!content.includes(ignorePattern)) {
1293
+ const newEntry = `
1294
+ # Nextly local uploads (auto-added)
1295
+ ${ignorePattern}
1296
+ `;
1297
+ await fs.writeFile(gitignorePath, content + newEntry, "utf-8");
1298
+ }
1299
+ } catch {
1300
+ }
1301
+ }
1302
+ };
1303
+ function localStorage(config) {
1304
+ if (config.enabled === false) {
1305
+ return {
1306
+ name: "local-storage",
1307
+ type: "local",
1308
+ collections: {},
1309
+ adapter: null
1310
+ };
1311
+ }
1312
+ const adapter = new LocalStorageAdapter({
1313
+ basePath: config.basePath ?? "./public/uploads",
1314
+ baseUrl: config.baseUrl ?? "/uploads"
1315
+ });
1316
+ return {
1317
+ name: "local-storage",
1318
+ type: "local",
1319
+ collections: config.collections,
1320
+ adapter
1321
+ // Local storage doesn't support presigned URLs or signed downloads
1322
+ };
1323
+ }
1324
+
1325
+ export { BaseStorageAdapter, DEFAULT_READ_TIMEOUT_MS, fetchStoredBytes, localStorage, withDeadline };
1326
+ //# sourceMappingURL=chunk-W3CIFTIH.mjs.map
1327
+ //# sourceMappingURL=chunk-W3CIFTIH.mjs.map