@rezwanul7/storage 0.0.0-stage → 0.1.0

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,535 @@
1
+ import { fileTypeFromBuffer } from 'file-type';
2
+ import fs from 'fs/promises';
3
+ import { createReadStream } from 'fs';
4
+ import path from 'path';
5
+ import { Readable } from 'stream';
6
+ import crypto from 'crypto';
7
+
8
+ // src/server/storage.errors.ts
9
+ var StorageError = class extends Error {
10
+ /** Always `"StorageError"`. */
11
+ name = "StorageError";
12
+ /** Stable code naming what went wrong. */
13
+ code;
14
+ /** Context for logs, such as the Provider's name. */
15
+ meta;
16
+ /**
17
+ * @param code - The stable code.
18
+ * @param opts - The message, the underlying `cause` and context for logs.
19
+ */
20
+ constructor(code, opts) {
21
+ super(opts.message, { cause: opts.cause });
22
+ this.code = code;
23
+ this.meta = opts.meta;
24
+ }
25
+ };
26
+
27
+ // src/shared/mimeTypes.ts
28
+ var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
29
+ "sh",
30
+ "bash",
31
+ "zsh",
32
+ "exe",
33
+ "dll",
34
+ "com",
35
+ "scr",
36
+ "bat",
37
+ "cmd",
38
+ "ps1",
39
+ "jar",
40
+ "class"
41
+ ]);
42
+ var DANGEROUS_MIME = /* @__PURE__ */ new Set([
43
+ "application/x-sh",
44
+ "application/x-shellscript",
45
+ "text/x-shellscript",
46
+ "text/x-sh",
47
+ "application/x-dosexec",
48
+ "application/java-archive"
49
+ ]);
50
+ var MIME_BY_EXT = {
51
+ // Image
52
+ png: "image/png",
53
+ jpg: "image/jpeg",
54
+ jpeg: "image/jpeg",
55
+ gif: "image/gif",
56
+ bmp: "image/bmp",
57
+ webp: "image/webp",
58
+ tiff: "image/tiff",
59
+ tif: "image/tiff",
60
+ svg: "image/svg+xml",
61
+ ico: "image/vnd.microsoft.icon",
62
+ avif: "image/avif",
63
+ heic: "image/heic",
64
+ // Audio
65
+ mp3: "audio/mpeg",
66
+ wav: "audio/wav",
67
+ ogg: "audio/ogg",
68
+ flac: "audio/x-flac",
69
+ m4a: "audio/mp4",
70
+ // Video
71
+ mp4: "video/mp4",
72
+ mkv: "video/x-matroska",
73
+ mov: "video/quicktime",
74
+ avi: "video/x-msvideo",
75
+ webm: "video/webm",
76
+ // Documents
77
+ pdf: "application/pdf",
78
+ xls: "application/vnd.ms-excel",
79
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
80
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
81
+ ppt: "application/vnd.ms-powerpoint",
82
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
83
+ odp: "application/vnd.oasis.opendocument.presentation",
84
+ doc: "application/msword",
85
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
86
+ odt: "application/vnd.oasis.opendocument.text",
87
+ rtf: "application/rtf",
88
+ // Datasets (JSON Lines has no registered MIME, so it is served as JSON)
89
+ csv: "text/csv",
90
+ tsv: "text/tab-separated-values",
91
+ json: "application/json",
92
+ jsonl: "application/json",
93
+ ndjson: "application/json",
94
+ // Archives
95
+ zip: "application/zip",
96
+ rar: "application/x-rar-compressed",
97
+ "7z": "application/x-7z-compressed",
98
+ tar: "application/x-tar",
99
+ gz: "application/gzip",
100
+ // Code
101
+ js: "text/javascript",
102
+ jsx: "text/jsx",
103
+ ts: "text/typescript",
104
+ tsx: "text/tsx",
105
+ py: "text/x-python",
106
+ rb: "text/x-ruby",
107
+ php: "application/x-httpd-php",
108
+ java: "text/x-java-source",
109
+ kt: "text/x-kotlin",
110
+ c: "text/x-c",
111
+ cpp: "text/x-c",
112
+ h: "text/x-c",
113
+ hpp: "text/x-c",
114
+ cs: "text/x-csharp",
115
+ go: "text/x-go",
116
+ rs: "text/x-rust",
117
+ html: "text/html",
118
+ htm: "text/html",
119
+ css: "text/css",
120
+ scss: "text/x-scss",
121
+ yaml: "text/yaml",
122
+ yml: "text/yaml",
123
+ sql: "application/sql",
124
+ xml: "application/xml",
125
+ // Text
126
+ txt: "text/plain",
127
+ md: "text/markdown",
128
+ markdown: "text/markdown",
129
+ log: "text/plain",
130
+ ini: "text/plain",
131
+ cfg: "text/plain",
132
+ conf: "text/plain",
133
+ env: "text/plain",
134
+ // Executables (blocked by DANGEROUS_EXTENSIONS, mapped so they report a real MIME)
135
+ sh: "application/x-sh",
136
+ bash: "application/x-sh",
137
+ zsh: "application/x-sh",
138
+ exe: "application/x-msdos-program",
139
+ dll: "application/x-msdownload",
140
+ com: "application/x-msdownload",
141
+ bat: "application/x-msdownload",
142
+ jar: "application/java-archive",
143
+ class: "application/java-vm"
144
+ };
145
+ var EXT_BY_MIME = {};
146
+ for (const [ext, mime] of Object.entries(MIME_BY_EXT)) {
147
+ EXT_BY_MIME[mime] ??= ext;
148
+ }
149
+ function mimeFromExtension(ext) {
150
+ if (!ext) return void 0;
151
+ return MIME_BY_EXT[ext.toLowerCase()];
152
+ }
153
+ function extensionFromMime(mime) {
154
+ if (!mime) return void 0;
155
+ return EXT_BY_MIME[mime.toLowerCase()];
156
+ }
157
+
158
+ // src/shared/fileExtension.ts
159
+ function getFileExtension(name) {
160
+ const dot = name.lastIndexOf(".");
161
+ if (dot === -1) return void 0;
162
+ return name.slice(dot + 1).toLowerCase() || void 0;
163
+ }
164
+
165
+ // src/server/internal/detectFileType.ts
166
+ async function detectFileType(buffer, fileName, declaredMime) {
167
+ const declaredExt = getFileExtension(fileName);
168
+ const detected = await fileTypeFromBuffer(buffer);
169
+ if (detected) {
170
+ return {
171
+ declaredExt,
172
+ declaredMime,
173
+ detectedExt: detected.ext,
174
+ detectedMime: detected.mime,
175
+ ext: detected.ext,
176
+ mime: detected.mime,
177
+ confidence: "high"
178
+ };
179
+ }
180
+ if (declaredExt) {
181
+ const mime = mimeFromExtension(declaredExt);
182
+ return {
183
+ declaredExt,
184
+ declaredMime,
185
+ ext: declaredExt,
186
+ mime: mime ?? declaredMime ?? "application/octet-stream",
187
+ confidence: mime ? "medium" : "low"
188
+ };
189
+ }
190
+ if (declaredMime) {
191
+ const mimeExt = extensionFromMime(declaredMime);
192
+ return {
193
+ declaredExt,
194
+ declaredMime,
195
+ ext: mimeExt,
196
+ mime: declaredMime,
197
+ confidence: "low"
198
+ };
199
+ }
200
+ return { confidence: "low" };
201
+ }
202
+
203
+ // src/server/internal/checkTypeSpoofing.ts
204
+ function checkForSpoofing(d) {
205
+ if (d.detectedExt && d.declaredExt && d.detectedExt !== d.declaredExt) {
206
+ return {
207
+ ok: false,
208
+ spoofed: true,
209
+ reason: `Declared ".${d.declaredExt}" but file content is actually ".${d.detectedExt}".`
210
+ };
211
+ }
212
+ if (d.detectedMime && d.declaredMime && d.detectedMime !== d.declaredMime) {
213
+ return {
214
+ ok: false,
215
+ spoofed: true,
216
+ reason: `Declared MIME "${d.declaredMime}" does not match content "${d.detectedMime}".`
217
+ };
218
+ }
219
+ return { ok: true };
220
+ }
221
+
222
+ // src/server/internal/checkFileStructure.ts
223
+ function checkFileStructure(ext, buffer) {
224
+ if (buffer.includes(0)) {
225
+ return {
226
+ ok: false,
227
+ reason: "File contains binary data but expected text content."
228
+ };
229
+ }
230
+ const text = buffer.toString("utf8");
231
+ if (ext === "json") {
232
+ try {
233
+ JSON.parse(text);
234
+ return { ok: true };
235
+ } catch {
236
+ return { ok: false, reason: "Invalid JSON format." };
237
+ }
238
+ }
239
+ if (ext === "jsonl") {
240
+ const lines = text.split("\n").filter(Boolean);
241
+ for (const line of lines) {
242
+ try {
243
+ JSON.parse(line);
244
+ } catch {
245
+ return { ok: false, reason: "Invalid JSONL format." };
246
+ }
247
+ }
248
+ return { ok: true };
249
+ }
250
+ if (ext === "csv") {
251
+ const rows = text.split("\n");
252
+ if (rows.length < 1)
253
+ return { ok: false, reason: "CSV file is empty or malformed." };
254
+ return { ok: true };
255
+ }
256
+ return { ok: true };
257
+ }
258
+
259
+ // src/shared/fileSize.ts
260
+ var SIZE_UNITS = {
261
+ b: 1,
262
+ kb: 1024,
263
+ mb: 1024 ** 2,
264
+ gb: 1024 ** 3,
265
+ tb: 1024 ** 4
266
+ };
267
+ function parseFileSize(input) {
268
+ if (input == null) return void 0;
269
+ if (typeof input === "number" && Number.isFinite(input)) return input;
270
+ if (typeof input !== "string") return void 0;
271
+ const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
272
+ if (!match) return void 0;
273
+ return parseFloat(match[1]) * (SIZE_UNITS[match[2] || "b"] ?? 1);
274
+ }
275
+ function formatFileSize(bytes) {
276
+ if (bytes === 0) return "0 B";
277
+ const units = ["B", "KB", "MB", "GB", "TB"];
278
+ const index = Math.floor(Math.log(bytes) / Math.log(1024));
279
+ return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`;
280
+ }
281
+
282
+ // src/server/internal/checkRules.ts
283
+ function checkRules(d, size, rules) {
284
+ const errors = [];
285
+ const detectedMime = d.mime ?? "application/octet-stream";
286
+ const detectedExt = d.ext ?? "unknown";
287
+ const maxSize = parseFileSize(rules.maxFileSize);
288
+ if (maxSize && size > maxSize) {
289
+ errors.push(`File exceeds maximum size of ${formatFileSize(maxSize)}.`);
290
+ }
291
+ const minSize = parseFileSize(rules.minFileSize);
292
+ if (minSize && size < minSize) {
293
+ errors.push(`File must be at least ${formatFileSize(minSize)}.`);
294
+ }
295
+ if (rules.allowedMimeTypes && !rules.allowedMimeTypes.includes(detectedMime)) {
296
+ errors.push(`MIME '${detectedMime}' is not allowed.`);
297
+ }
298
+ if (rules.blockedMimeTypes && rules.blockedMimeTypes.includes(detectedMime)) {
299
+ errors.push(`MIME '${detectedMime}' is blocked.`);
300
+ }
301
+ if (rules.allowedExtensions && !rules.allowedExtensions.includes(detectedExt)) {
302
+ errors.push(`Extension '.${detectedExt}' is not allowed.`);
303
+ }
304
+ if (rules.blockedExtensions && rules.blockedExtensions.includes(detectedExt)) {
305
+ errors.push(`Extension '.${detectedExt}' is blocked.`);
306
+ }
307
+ return errors;
308
+ }
309
+
310
+ // src/server/internal/checkDangerousTypes.ts
311
+ function checkDangerousTypes(ext, mime) {
312
+ if (ext && DANGEROUS_EXTENSIONS.has(ext)) {
313
+ return { ok: false, reason: `Extension '.${ext}' is unsafe.` };
314
+ }
315
+ if (mime && DANGEROUS_MIME.has(mime)) {
316
+ return { ok: false, reason: `MIME type '${mime}' is unsafe.` };
317
+ }
318
+ return { ok: true };
319
+ }
320
+
321
+ // src/server/core/validateFile.ts
322
+ async function validateFile(file, rules = {}) {
323
+ const errors = [];
324
+ const buffer = Buffer.from(await file.arrayBuffer());
325
+ const size = buffer.length;
326
+ const detection = await detectFileType(buffer, file.name, file.type);
327
+ const danger = checkDangerousTypes(detection.ext, detection.mime);
328
+ if (!danger.ok) errors.push(danger.reason);
329
+ const spoofing = checkForSpoofing(detection);
330
+ if (!spoofing.ok) errors.push(spoofing.reason);
331
+ if (detection.confidence !== "high") {
332
+ const struct = checkFileStructure(detection.ext ?? "", buffer);
333
+ if (!struct.ok) errors.push(struct.reason);
334
+ }
335
+ const hasDetectionFailure = !detection.mime || !detection.ext;
336
+ if (hasDetectionFailure) {
337
+ errors.push("File type detection failed");
338
+ }
339
+ errors.push(...checkRules(detection, size, rules));
340
+ if (rules.customValidators?.length) {
341
+ for (const validator of rules.customValidators) {
342
+ const msg = await validator({
343
+ buffer,
344
+ originalName: file.name,
345
+ declaredMime: file.type,
346
+ detectedMime: detection.mime,
347
+ detectedExt: detection.ext,
348
+ size
349
+ });
350
+ if (msg) errors.push(msg);
351
+ }
352
+ }
353
+ if (errors.length > 0 || hasDetectionFailure) {
354
+ return { valid: false, errors };
355
+ }
356
+ return {
357
+ valid: true,
358
+ file: {
359
+ originalName: file.name,
360
+ mime: detection.mime ?? "Unknown",
361
+ ext: detection.ext ?? "Unknown",
362
+ size,
363
+ buffer
364
+ }
365
+ };
366
+ }
367
+
368
+ // src/server/core/fileUpload.service.ts
369
+ var FileUploadService = class {
370
+ constructor(storage, logger) {
371
+ this.storage = storage;
372
+ this.logger = logger;
373
+ }
374
+ async store(files, rules, { directory }) {
375
+ const list = Array.isArray(files) ? files : [files];
376
+ const validated = [];
377
+ const errors = {};
378
+ for (const [index, file] of list.entries()) {
379
+ const result = await validateFile(file, rules);
380
+ if (result.valid) validated.push(result.file);
381
+ else
382
+ errors[index] = result.errors.length ? result.errors : ["File validation failed"];
383
+ }
384
+ if (Object.keys(errors).length > 0) {
385
+ return { ok: false, errors };
386
+ }
387
+ const stored = [];
388
+ try {
389
+ for (const file of validated) {
390
+ stored.push(await this.upload(file, directory));
391
+ }
392
+ } catch (error) {
393
+ await this.removeStored(stored);
394
+ if (error instanceof StorageError) throw error;
395
+ throw new StorageError("STORAGE_UPLOAD_FAILED", {
396
+ message: error instanceof Error ? error.message : "The storage provider couldn't store the file.",
397
+ cause: error,
398
+ meta: { provider: this.storage.provider, directory }
399
+ });
400
+ }
401
+ return { ok: true, files: stored };
402
+ }
403
+ async upload(validated, directory) {
404
+ const stored = await this.storage.upload({
405
+ buffer: validated.buffer,
406
+ mime: validated.mime,
407
+ ext: validated.ext,
408
+ directory
409
+ });
410
+ return {
411
+ originalFileName: validated.originalName,
412
+ provider: this.storage.provider,
413
+ storageId: stored.storageId,
414
+ url: stored.url,
415
+ size: validated.size,
416
+ mime: validated.mime,
417
+ ext: validated.ext
418
+ };
419
+ }
420
+ /** Deletes the files of a failed upload. A file left behind is logged, not thrown. */
421
+ async removeStored(stored) {
422
+ const outcomes = await Promise.allSettled(
423
+ stored.map(({ storageId }) => this.storage.delete(storageId))
424
+ );
425
+ outcomes.forEach((outcome, index) => {
426
+ if (outcome.status === "rejected") {
427
+ this.logger.error("storage.rollback_delete_failed", {
428
+ provider: this.storage.provider,
429
+ storageId: stored[index]?.storageId,
430
+ error: outcome.reason
431
+ });
432
+ }
433
+ });
434
+ }
435
+ };
436
+
437
+ // src/server/createFileUpload.ts
438
+ var noop = () => {
439
+ };
440
+ var SILENT_LOGGER = { error: noop };
441
+ function createFileUpload({
442
+ storage,
443
+ logger = SILENT_LOGGER
444
+ }) {
445
+ return new FileUploadService(storage, logger);
446
+ }
447
+ var LocalFileStorage = class {
448
+ /** The Provider's name, saved with each file. */
449
+ provider = "local";
450
+ root;
451
+ urlPrefix;
452
+ /**
453
+ * @param options - The folder to store under and the URL prefix.
454
+ */
455
+ constructor({ root, urlPrefix }) {
456
+ this.root = path.resolve(root);
457
+ this.urlPrefix = urlPrefix;
458
+ }
459
+ /**
460
+ * Writes a file under `directory` with a random name.
461
+ *
462
+ * @param params - The file's bytes, MIME type, extension and folder.
463
+ * @returns Where the file was stored.
464
+ */
465
+ async upload({
466
+ buffer,
467
+ mime,
468
+ ext,
469
+ directory
470
+ }) {
471
+ const fileId = crypto.randomBytes(16).toString("hex");
472
+ const storageId = `${directory}/${fileId}.${ext}`;
473
+ const filePath = this.resolve(storageId);
474
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
475
+ await fs.writeFile(filePath, buffer);
476
+ return {
477
+ url: this.getUrl(storageId),
478
+ storageId,
479
+ mime,
480
+ size: buffer.length
481
+ };
482
+ }
483
+ /**
484
+ * Reads a stored file as a stream.
485
+ *
486
+ * @param storageId - The file's Storage ID.
487
+ * @returns The file, or null when nothing is stored there.
488
+ */
489
+ async read(storageId) {
490
+ const filePath = this.resolve(storageId);
491
+ try {
492
+ const stats = await fs.stat(filePath);
493
+ if (!stats.isFile()) return null;
494
+ return {
495
+ stream: Readable.toWeb(
496
+ createReadStream(filePath)
497
+ ),
498
+ size: stats.size
499
+ };
500
+ } catch (error) {
501
+ if (error.code === "ENOENT") return null;
502
+ throw error;
503
+ }
504
+ }
505
+ /**
506
+ * Deletes a stored file; succeeds when it isn't there.
507
+ *
508
+ * @param storageId - The file's Storage ID.
509
+ */
510
+ async delete(storageId) {
511
+ await fs.rm(this.resolve(storageId), { force: true });
512
+ }
513
+ /**
514
+ * The file's URL under `urlPrefix`. Nothing serves it until your app does.
515
+ *
516
+ * @param storageId - The file's Storage ID.
517
+ * @returns The URL.
518
+ */
519
+ getUrl(storageId) {
520
+ return `${this.urlPrefix}/${storageId}`;
521
+ }
522
+ /** Maps a storage ID to a path under the root, refusing IDs that would escape it. */
523
+ resolve(storageId) {
524
+ const filePath = path.resolve(this.root, storageId);
525
+ if (!filePath.startsWith(this.root + path.sep)) {
526
+ throw new StorageError("STORAGE_INVALID_ID", {
527
+ message: `Invalid storage ID: ${storageId}`,
528
+ meta: { provider: this.provider }
529
+ });
530
+ }
531
+ return filePath;
532
+ }
533
+ };
534
+
535
+ export { LocalFileStorage, StorageError, createFileUpload };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ var zod = require('zod');
4
+
5
+ // src/zod/createZodFileSchema.ts
6
+
7
+ // src/shared/fileExtension.ts
8
+ function getFileExtension(name) {
9
+ const dot = name.lastIndexOf(".");
10
+ if (dot === -1) return void 0;
11
+ return name.slice(dot + 1).toLowerCase() || void 0;
12
+ }
13
+
14
+ // src/shared/fileSize.ts
15
+ var SIZE_UNITS = {
16
+ b: 1,
17
+ kb: 1024,
18
+ mb: 1024 ** 2,
19
+ gb: 1024 ** 3,
20
+ tb: 1024 ** 4
21
+ };
22
+ function parseFileSize(input) {
23
+ if (input == null) return void 0;
24
+ if (typeof input === "number" && Number.isFinite(input)) return input;
25
+ if (typeof input !== "string") return void 0;
26
+ const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
27
+ if (!match) return void 0;
28
+ return parseFloat(match[1]) * (SIZE_UNITS[match[2] || "b"] ?? 1);
29
+ }
30
+ function formatFileSize(bytes) {
31
+ if (bytes === 0) return "0 B";
32
+ const units = ["B", "KB", "MB", "GB", "TB"];
33
+ const index = Math.floor(Math.log(bytes) / Math.log(1024));
34
+ return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`;
35
+ }
36
+
37
+ // src/zod/createZodFileSchema.ts
38
+ function createSingleFileZodSchema(rules) {
39
+ const minSize = parseFileSize(rules.minFileSize);
40
+ const maxSize = parseFileSize(rules.maxFileSize);
41
+ return zod.z.instanceof(File).refine((f) => f.size > 0, "File is empty").refine(
42
+ (f) => !minSize || f.size >= minSize,
43
+ minSize ? `Min file size is ${formatFileSize(minSize)}` : "Invalid minimum file size"
44
+ ).refine(
45
+ (f) => !maxSize || f.size <= maxSize,
46
+ maxSize ? `Max file size is ${formatFileSize(maxSize)}` : "Invalid maximum file size"
47
+ ).refine(
48
+ (f) => {
49
+ if (!rules.allowedExtensions?.length) return true;
50
+ const ext = getFileExtension(f.name);
51
+ return !!ext && rules.allowedExtensions.includes(ext);
52
+ },
53
+ rules.allowedExtensions?.length ? `Invalid file extension. Allowed: ${rules.allowedExtensions.join(", ")}` : "Invalid file extension"
54
+ );
55
+ }
56
+ function createMultiFileZodSchema(rules) {
57
+ const singleFileSchema = createSingleFileZodSchema(rules);
58
+ return zod.z.array(singleFileSchema).min(1, "At least one file is required");
59
+ }
60
+
61
+ exports.createMultiFileZodSchema = createMultiFileZodSchema;
62
+ exports.createSingleFileZodSchema = createSingleFileZodSchema;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { S as SharedFileRules } from '../fileRules.types-CGXBnwzd.cjs';
3
+
4
+ /**
5
+ * A zod schema for one file field: rejects an empty file, a size outside the Rules, or an extension they don't allow.
6
+ *
7
+ * @param rules - The size and extension Rules.
8
+ * @returns The schema, whose messages are safe to show to users.
9
+ *
10
+ * @example
11
+ * const schema = z.object({ avatar: createSingleFileZodSchema({ maxFileSize: "2MB", allowedExtensions: ["png"] }) });
12
+ */
13
+ declare function createSingleFileZodSchema(rules: SharedFileRules): z.ZodCustom<File, File>;
14
+ /**
15
+ * A zod schema for a list of files: at least one, each checked as {@link createSingleFileZodSchema} does.
16
+ *
17
+ * @param rules - The size and extension Rules.
18
+ * @returns The schema, whose messages are safe to show to users.
19
+ */
20
+ declare function createMultiFileZodSchema(rules: SharedFileRules): z.ZodArray<z.ZodCustom<File, File>>;
21
+
22
+ export { createMultiFileZodSchema, createSingleFileZodSchema };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { S as SharedFileRules } from '../fileRules.types-CGXBnwzd.js';
3
+
4
+ /**
5
+ * A zod schema for one file field: rejects an empty file, a size outside the Rules, or an extension they don't allow.
6
+ *
7
+ * @param rules - The size and extension Rules.
8
+ * @returns The schema, whose messages are safe to show to users.
9
+ *
10
+ * @example
11
+ * const schema = z.object({ avatar: createSingleFileZodSchema({ maxFileSize: "2MB", allowedExtensions: ["png"] }) });
12
+ */
13
+ declare function createSingleFileZodSchema(rules: SharedFileRules): z.ZodCustom<File, File>;
14
+ /**
15
+ * A zod schema for a list of files: at least one, each checked as {@link createSingleFileZodSchema} does.
16
+ *
17
+ * @param rules - The size and extension Rules.
18
+ * @returns The schema, whose messages are safe to show to users.
19
+ */
20
+ declare function createMultiFileZodSchema(rules: SharedFileRules): z.ZodArray<z.ZodCustom<File, File>>;
21
+
22
+ export { createMultiFileZodSchema, createSingleFileZodSchema };
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+
3
+ // src/zod/createZodFileSchema.ts
4
+
5
+ // src/shared/fileExtension.ts
6
+ function getFileExtension(name) {
7
+ const dot = name.lastIndexOf(".");
8
+ if (dot === -1) return void 0;
9
+ return name.slice(dot + 1).toLowerCase() || void 0;
10
+ }
11
+
12
+ // src/shared/fileSize.ts
13
+ var SIZE_UNITS = {
14
+ b: 1,
15
+ kb: 1024,
16
+ mb: 1024 ** 2,
17
+ gb: 1024 ** 3,
18
+ tb: 1024 ** 4
19
+ };
20
+ function parseFileSize(input) {
21
+ if (input == null) return void 0;
22
+ if (typeof input === "number" && Number.isFinite(input)) return input;
23
+ if (typeof input !== "string") return void 0;
24
+ const match = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
25
+ if (!match) return void 0;
26
+ return parseFloat(match[1]) * (SIZE_UNITS[match[2] || "b"] ?? 1);
27
+ }
28
+ function formatFileSize(bytes) {
29
+ if (bytes === 0) return "0 B";
30
+ const units = ["B", "KB", "MB", "GB", "TB"];
31
+ const index = Math.floor(Math.log(bytes) / Math.log(1024));
32
+ return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`;
33
+ }
34
+
35
+ // src/zod/createZodFileSchema.ts
36
+ function createSingleFileZodSchema(rules) {
37
+ const minSize = parseFileSize(rules.minFileSize);
38
+ const maxSize = parseFileSize(rules.maxFileSize);
39
+ return z.instanceof(File).refine((f) => f.size > 0, "File is empty").refine(
40
+ (f) => !minSize || f.size >= minSize,
41
+ minSize ? `Min file size is ${formatFileSize(minSize)}` : "Invalid minimum file size"
42
+ ).refine(
43
+ (f) => !maxSize || f.size <= maxSize,
44
+ maxSize ? `Max file size is ${formatFileSize(maxSize)}` : "Invalid maximum file size"
45
+ ).refine(
46
+ (f) => {
47
+ if (!rules.allowedExtensions?.length) return true;
48
+ const ext = getFileExtension(f.name);
49
+ return !!ext && rules.allowedExtensions.includes(ext);
50
+ },
51
+ rules.allowedExtensions?.length ? `Invalid file extension. Allowed: ${rules.allowedExtensions.join(", ")}` : "Invalid file extension"
52
+ );
53
+ }
54
+ function createMultiFileZodSchema(rules) {
55
+ const singleFileSchema = createSingleFileZodSchema(rules);
56
+ return z.array(singleFileSchema).min(1, "At least one file is required");
57
+ }
58
+
59
+ export { createMultiFileZodSchema, createSingleFileZodSchema };