@kanun-hq/plugin-file 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.
package/dist/index.js ADDED
@@ -0,0 +1,665 @@
1
+ import { statSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { imageSize } from "image-size";
4
+ import { deepFind, deepSet, definePlugin, getValidatorContext, useValidatorContext } from "kanun";
5
+
6
+ //#region src/adapters.ts
7
+ const dynamicImport = new Function("specifier", "return import(specifier)");
8
+ function isRecord$1(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function isBlobLike$1(value) {
12
+ return typeof Blob !== "undefined" && value instanceof Blob;
13
+ }
14
+ function isFileLike$1(value) {
15
+ if (isBlobLike$1(value)) return true;
16
+ if (!isRecord$1(value)) return false;
17
+ return [
18
+ "buffer",
19
+ "fieldname",
20
+ "filename",
21
+ "mimetype",
22
+ "name",
23
+ "originalname",
24
+ "path",
25
+ "size",
26
+ "type"
27
+ ].some((key) => typeof value[key] !== "undefined");
28
+ }
29
+ function isFileArray(value) {
30
+ return Array.isArray(value) && value.length > 0 && value.every((item) => isFileLike$1(item));
31
+ }
32
+ function hasNamedFiles(value) {
33
+ return Object.keys(value).length > 0;
34
+ }
35
+ function appendNamedFile(target, field, value) {
36
+ if (!field) return;
37
+ const current = target[field];
38
+ if (typeof current === "undefined") {
39
+ target[field] = value;
40
+ return;
41
+ }
42
+ if (Array.isArray(current)) {
43
+ current.push(value);
44
+ return;
45
+ }
46
+ target[field] = [current, value];
47
+ }
48
+ function extractNamedFilesFromRecord(value) {
49
+ if (!isRecord$1(value)) return {};
50
+ const files = {};
51
+ for (const [key, entry] of Object.entries(value)) if (isFileLike$1(entry) || isFileArray(entry)) files[key] = entry;
52
+ return files;
53
+ }
54
+ function extractFilesFromFormData(formData) {
55
+ const files = {};
56
+ formData.forEach((entry, field) => {
57
+ if (isBlobLike$1(entry)) appendNamedFile(files, field, entry);
58
+ });
59
+ return files;
60
+ }
61
+ function mergeRequestFiles(validator, requestFiles, extraContext) {
62
+ const currentContext = validator.getContext();
63
+ const existingRequestFiles = isRecord$1(currentContext.requestFiles) ? currentContext.requestFiles : {};
64
+ return validator.withContext({
65
+ ...currentContext,
66
+ ...extraContext,
67
+ requestFiles: {
68
+ ...existingRequestFiles,
69
+ ...requestFiles
70
+ }
71
+ });
72
+ }
73
+ function useRequestFilesContext(requestFiles, extraContext) {
74
+ const currentContext = getValidatorContext();
75
+ const existingRequestFiles = isRecord$1(currentContext.requestFiles) ? currentContext.requestFiles : {};
76
+ return useValidatorContext({
77
+ ...extraContext,
78
+ requestFiles: {
79
+ ...existingRequestFiles,
80
+ ...requestFiles
81
+ }
82
+ });
83
+ }
84
+ function extractExpressRequestFiles(request) {
85
+ const requestFiles = extractNamedFilesFromRecord(request.files);
86
+ if (Array.isArray(request.files)) for (const file of request.files) appendNamedFile(requestFiles, isRecord$1(file) && typeof file.fieldname === "string" ? file.fieldname : "file", file);
87
+ if (typeof request.file !== "undefined") appendNamedFile(requestFiles, isRecord$1(request.file) && typeof request.file.fieldname === "string" ? request.file.fieldname : "file", request.file);
88
+ return requestFiles;
89
+ }
90
+ /**
91
+ * Attaches uploaded files from an Express-like request object to the validator
92
+ * context under `requestFiles`. It supports both `request.file`
93
+ * and `request.files` properties, normalizing them into a consistent format.
94
+ * The original request object is also included in the context for plugin use.
95
+ *
96
+ * @param validator The validator instance to attach the files to.
97
+ * @param request The Express-like request object containing the uploaded files.
98
+ * @returns The validator instance with the updated context.
99
+ */
100
+ function withExpressUploadContext(validator, request) {
101
+ return mergeRequestFiles(validator, extractExpressRequestFiles(request), {
102
+ express: request,
103
+ request
104
+ });
105
+ }
106
+ /**
107
+ * Attaches uploaded files from an Express-like request object to the validator
108
+ * context for use within validation rules.
109
+ *
110
+ * @param request The Express-like request object containing the uploaded files.
111
+ * @returns A context object containing the uploaded files and the original request.
112
+ */
113
+ function useExpressUploadContext(request) {
114
+ return useRequestFilesContext(extractExpressRequestFiles(request), {
115
+ express: request,
116
+ request
117
+ });
118
+ }
119
+ /**
120
+ * Normalizes a multipart file part from Fastify into a consistent file-like object.
121
+ *
122
+ * @param part
123
+ * @returns
124
+ */
125
+ async function normalizeFastifyMultipartFile(part) {
126
+ if (!isRecord$1(part)) return part;
127
+ if (typeof part.toBuffer === "function") {
128
+ const buffer = await part.toBuffer();
129
+ return {
130
+ buffer,
131
+ encoding: part.encoding,
132
+ fieldname: part.fieldname,
133
+ filename: part.filename,
134
+ mimetype: part.mimetype,
135
+ originalname: part.filename,
136
+ size: buffer.byteLength,
137
+ type: part.mimetype
138
+ };
139
+ }
140
+ return part;
141
+ }
142
+ /**
143
+ * Extracts uploaded files from a Fastify-like request object, supporting various ways that files may be provided (e.g., `request.files()`, `request.parts()`, `request.file()`, and nested `request.raw.files`).
144
+ *
145
+ * @param request
146
+ * @returns
147
+ */
148
+ async function extractFastifyRequestFiles(request) {
149
+ const requestFiles = extractNamedFilesFromRecord(request.body);
150
+ if (typeof request.files === "function") {
151
+ for await (const part of request.files()) {
152
+ const normalized = await normalizeFastifyMultipartFile(part);
153
+ appendNamedFile(requestFiles, isRecord$1(normalized) && typeof normalized.fieldname === "string" ? normalized.fieldname : "file", normalized);
154
+ }
155
+ return requestFiles;
156
+ }
157
+ if (typeof request.parts === "function") {
158
+ for await (const part of request.parts()) if (isRecord$1(part) && (part.type === "file" || typeof part.filename === "string" || typeof part.toBuffer === "function")) {
159
+ const normalized = await normalizeFastifyMultipartFile(part);
160
+ appendNamedFile(requestFiles, isRecord$1(normalized) && typeof normalized.fieldname === "string" ? normalized.fieldname : "file", normalized);
161
+ }
162
+ return requestFiles;
163
+ }
164
+ Object.assign(requestFiles, extractNamedFilesFromRecord(request.files));
165
+ Object.assign(requestFiles, extractNamedFilesFromRecord(isRecord$1(request.raw) ? request.raw.files : void 0));
166
+ const singleFile = typeof request.file === "function" ? await request.file() : request.file;
167
+ if (typeof singleFile !== "undefined") {
168
+ const normalized = await normalizeFastifyMultipartFile(singleFile);
169
+ appendNamedFile(requestFiles, isRecord$1(normalized) && typeof normalized.fieldname === "string" ? normalized.fieldname : "file", normalized);
170
+ }
171
+ return requestFiles;
172
+ }
173
+ /**
174
+ * Attaches uploaded files from a Fastify-like request object to the validator context under `requestFiles`.
175
+ * It supports multiple ways that files may be provided (e.g., `request.files()`,
176
+ * `request.parts()`, `request.file()`, and nested `request.raw.files`), normalizing
177
+ * them into a consistent format. The original request object is also included in
178
+ * the context for plugin use.
179
+ *
180
+ * @param validator
181
+ * @param request
182
+ * @returns
183
+ */
184
+ async function withFastifyUploadContext(validator, request) {
185
+ return mergeRequestFiles(validator, await extractFastifyRequestFiles(request), {
186
+ fastify: request,
187
+ request
188
+ });
189
+ }
190
+ /**
191
+ * Attaches uploaded files from a Fastify-like request object to the validator
192
+ * context for use within validation rules.
193
+ *
194
+ * @param request
195
+ * @returns
196
+ */
197
+ async function useFastifyUploadContext(request) {
198
+ return useRequestFilesContext(await extractFastifyRequestFiles(request), {
199
+ fastify: request,
200
+ request
201
+ });
202
+ }
203
+ /**
204
+ * Extracts the parsed body from a Hono-like context, which may contain uploaded files.
205
+ *
206
+ * @param context
207
+ * @returns
208
+ */
209
+ async function extractHonoParsedBody(context) {
210
+ return typeof context.req?.parseBody === "function" ? await context.req.parseBody({ all: true }) : {};
211
+ }
212
+ /**
213
+ * Attaches uploaded files from a Hono-like context to the validator context under `requestFiles`.
214
+ *
215
+ * @param validator
216
+ * @param context
217
+ * @returns
218
+ */
219
+ async function withHonoUploadContext(validator, context) {
220
+ return mergeRequestFiles(validator, extractNamedFilesFromRecord(await extractHonoParsedBody(context)), {
221
+ hono: context,
222
+ request: context.req
223
+ });
224
+ }
225
+ /**
226
+ * Attaches uploaded files from a Hono-like context to the validator context for use within validation rules.
227
+ *
228
+ * @param context
229
+ * @returns
230
+ */
231
+ async function useHonoUploadContext(context) {
232
+ return useRequestFilesContext(extractNamedFilesFromRecord(await extractHonoParsedBody(context)), {
233
+ hono: context,
234
+ request: context.req
235
+ });
236
+ }
237
+ /**
238
+ * Extracts uploaded files from an H3-like event, supporting multiple sources such as
239
+ * `event.context.requestFiles`, `request.formData()`, and multipart parsing when available.
240
+ * It normalizes the files into a consistent format for use in the validator context.
241
+ *
242
+ * @param event
243
+ * @returns
244
+ */
245
+ async function readH3MultipartFiles(event) {
246
+ try {
247
+ const h3 = await dynamicImport("h3");
248
+ const parts = typeof h3.readMultipartFormData === "function" ? await h3.readMultipartFormData(event) : void 0;
249
+ if (!Array.isArray(parts)) return {};
250
+ const files = {};
251
+ for (const part of parts) {
252
+ if (!part || typeof part.name !== "string") continue;
253
+ appendNamedFile(files, part.name, {
254
+ buffer: part.data,
255
+ filename: part.filename,
256
+ mimetype: part.type,
257
+ name: part.name,
258
+ originalname: part.filename,
259
+ size: part.data?.byteLength,
260
+ type: part.type
261
+ });
262
+ }
263
+ return files;
264
+ } catch {
265
+ return {};
266
+ }
267
+ }
268
+ /**
269
+ * Extracts uploaded files from an H3-like event, checking multiple sources such as
270
+ * `event.context.requestFiles`, `request.formData()`, and multipart parsing when available.
271
+ * It normalizes the files into a consistent format for use in the validator context.
272
+ *
273
+ * @param event
274
+ * @returns
275
+ */
276
+ async function extractH3RequestFiles(event) {
277
+ let requestFiles = extractNamedFilesFromRecord(event.context?.requestFiles);
278
+ if (!hasNamedFiles(requestFiles)) {
279
+ const request = event.req ?? event.request;
280
+ if (typeof request?.formData === "function") requestFiles = extractFilesFromFormData(await request.formData());
281
+ }
282
+ if (!hasNamedFiles(requestFiles)) requestFiles = await readH3MultipartFiles(event);
283
+ return requestFiles;
284
+ }
285
+ /**
286
+ * Read files from multiple sources on the
287
+ * event, including `event.context.requestFiles`, `request.formData()`, and multipart
288
+ * parsing when available. It merges found files into the validator context
289
+ * under `requestFiles` and also attaches the original event and request for plugin use.
290
+ *
291
+ * @param validator The validator instance to which the files will be added.
292
+ * @param event The H3 event containing potential file uploads.
293
+ * @returns The validator instance with the merged file context.
294
+ */
295
+ async function withH3UploadContext(validator, event) {
296
+ return mergeRequestFiles(validator, await extractH3RequestFiles(event), {
297
+ h3: event,
298
+ request: event.req ?? event.request
299
+ });
300
+ }
301
+ /**
302
+ * Uses files from multiple sources on the event, including
303
+ * `event.context.requestFiles`, `request.formData()`, and multipart parsing when available.
304
+ *
305
+ * @param event
306
+ * @returns
307
+ */
308
+ async function useH3UploadContext(event) {
309
+ return useRequestFilesContext(await extractH3RequestFiles(event), {
310
+ h3: event,
311
+ request: event.req ?? event.request
312
+ });
313
+ }
314
+
315
+ //#endregion
316
+ //#region src/index.ts
317
+ const IMAGE_EXTENSIONS = new Set([
318
+ "avif",
319
+ "bmp",
320
+ "gif",
321
+ "heic",
322
+ "heif",
323
+ "jpeg",
324
+ "jpg",
325
+ "png",
326
+ "svg",
327
+ "tiff",
328
+ "webp"
329
+ ]);
330
+ const MIME_BY_EXTENSION = {
331
+ avif: "image/avif",
332
+ bmp: "image/bmp",
333
+ gif: "image/gif",
334
+ heic: "image/heic",
335
+ heif: "image/heif",
336
+ jpeg: "image/jpeg",
337
+ jpg: "image/jpeg",
338
+ mpg: "video/mpeg",
339
+ mpeg: "video/mpeg",
340
+ png: "image/png",
341
+ svg: "image/svg+xml",
342
+ tif: "image/tiff",
343
+ tiff: "image/tiff",
344
+ webp: "image/webp"
345
+ };
346
+ let pluginOptions = {};
347
+ let installed = false;
348
+ /**
349
+ * Check if the given value is a record (plain object).
350
+ *
351
+ * @param value
352
+ * @returns
353
+ */
354
+ function isRecord(value) {
355
+ return typeof value === "object" && value !== null && !Array.isArray(value);
356
+ }
357
+ /**
358
+ * Check if the given value is a file or an array of files.
359
+ *
360
+ * @param value
361
+ * @returns
362
+ */
363
+ function isFileLike(value) {
364
+ if (!isRecord(value)) return false;
365
+ return [
366
+ "buffer",
367
+ "filename",
368
+ "height",
369
+ "mimetype",
370
+ "name",
371
+ "originalname",
372
+ "path",
373
+ "size",
374
+ "type",
375
+ "width"
376
+ ].some((key) => typeof value[key] !== "undefined");
377
+ }
378
+ /**
379
+ * Normalize the given value into an array of files.
380
+ *
381
+ * @param value The value to normalize
382
+ * @returns An array of FileLike objects
383
+ */
384
+ function normalizeFiles(value) {
385
+ if (Array.isArray(value)) return value.filter(isFileLike);
386
+ return isFileLike(value) ? [value] : [];
387
+ }
388
+ /**
389
+ * Resolve the candidate files for the given attribute and context. T
390
+ *
391
+ * @param value
392
+ * @param attribute
393
+ * @param context
394
+ * @returns
395
+ */
396
+ async function resolveCandidateFiles(value, attribute, context) {
397
+ if (typeof value !== "undefined") return value;
398
+ if (pluginOptions.resolveFiles) {
399
+ const resolved = await pluginOptions.resolveFiles({
400
+ attribute,
401
+ context: context.context,
402
+ data: context.data,
403
+ value
404
+ });
405
+ if (typeof resolved !== "undefined") return resolved;
406
+ }
407
+ return deepFind(context.context.requestFiles ?? {}, attribute);
408
+ }
409
+ async function resolveFiles(value, attribute, context) {
410
+ const candidates = await resolveCandidateFiles(value, attribute, context);
411
+ return {
412
+ candidates,
413
+ files: normalizeFiles(candidates)
414
+ };
415
+ }
416
+ function isBlobLike(value) {
417
+ return typeof Blob !== "undefined" && value instanceof Blob;
418
+ }
419
+ function isArrayBufferReadable(value) {
420
+ return isRecord(value) && typeof value.arrayBuffer === "function";
421
+ }
422
+ async function getBuffer(file) {
423
+ if (file.buffer instanceof Uint8Array || Buffer.isBuffer(file.buffer)) return file.buffer;
424
+ if (file.buffer instanceof ArrayBuffer) return Buffer.from(file.buffer);
425
+ if (isBlobLike(file) || isArrayBufferReadable(file)) return Buffer.from(await file.arrayBuffer());
426
+ }
427
+ function getExtension(file) {
428
+ const name = [
429
+ file.originalname,
430
+ file.filename,
431
+ file.name,
432
+ file.path
433
+ ].find((candidate) => typeof candidate === "string" && candidate.length > 0);
434
+ if (!name) return;
435
+ return extname(name).replace(".", "").toLowerCase();
436
+ }
437
+ function getMimeType(file) {
438
+ const explicit = [file.mimetype, file.type].find((candidate) => typeof candidate === "string" && candidate.length > 0);
439
+ if (explicit) return explicit.toLowerCase();
440
+ const extension = getExtension(file);
441
+ if (!extension) return;
442
+ return MIME_BY_EXTENSION[extension];
443
+ }
444
+ function getFileSizeInKilobytes(file) {
445
+ if (typeof file.size === "number" && Number.isFinite(file.size)) return file.size / 1024;
446
+ if (typeof file.path === "string") try {
447
+ return statSync(file.path).size / 1024;
448
+ } catch {
449
+ return -1;
450
+ }
451
+ return -1;
452
+ }
453
+ async function getImageDimensions(file) {
454
+ if (typeof file.width === "number" && Number.isFinite(file.width) && typeof file.height === "number" && Number.isFinite(file.height)) return {
455
+ height: file.height,
456
+ width: file.width
457
+ };
458
+ const source = await getBuffer(file) ?? file.path;
459
+ if (!source) return;
460
+ try {
461
+ const dimensions = imageSize(source);
462
+ if (typeof dimensions.width === "number" && typeof dimensions.height === "number") return {
463
+ height: dimensions.height,
464
+ width: dimensions.width
465
+ };
466
+ } catch {
467
+ return;
468
+ }
469
+ }
470
+ function isImageFile(file) {
471
+ if (getMimeType(file)?.startsWith("image/")) return true;
472
+ const extension = getExtension(file);
473
+ return typeof extension === "string" && IMAGE_EXTENSIONS.has(extension);
474
+ }
475
+ function parseNumericConstraint(value) {
476
+ const parsed = Number(value);
477
+ return Number.isFinite(parsed) ? parsed : void 0;
478
+ }
479
+ function parseRatio(value) {
480
+ if (value.includes("/")) {
481
+ const [left, right] = value.split("/", 2).map(Number);
482
+ if (!Number.isFinite(left) || !Number.isFinite(right) || right === 0) return;
483
+ return left / right;
484
+ }
485
+ return parseNumericConstraint(value);
486
+ }
487
+ /**
488
+ * Parse the dimension constraints from the given parameters. Supported parameters are:
489
+ * - min_width
490
+ * - max_width
491
+ * - min_height
492
+ * - max_height
493
+ * - ratio (can be a single number or a ratio in the form of "width/height")
494
+ *
495
+ * @param parameters
496
+ * @returns
497
+ */
498
+ function parseDimensionConstraints(parameters) {
499
+ const constraints = {};
500
+ for (const parameter of parameters) {
501
+ const [name, rawValue] = parameter.split("=", 2);
502
+ if (!name || !rawValue) continue;
503
+ if (name === "min_width") constraints.minWidth = parseNumericConstraint(rawValue);
504
+ if (name === "max_width") constraints.maxWidth = parseNumericConstraint(rawValue);
505
+ if (name === "min_height") constraints.minHeight = parseNumericConstraint(rawValue);
506
+ if (name === "max_height") constraints.maxHeight = parseNumericConstraint(rawValue);
507
+ if (name === "ratio") constraints.ratio = parseRatio(rawValue);
508
+ }
509
+ return constraints;
510
+ }
511
+ /**
512
+ * Check if the given image dimensions satisfy the given constraints.
513
+ *
514
+ * @param dimensions The dimensions of the image.
515
+ * @param constraints The constraints to check against.
516
+ * @returns True if the dimensions satisfy the constraints, false otherwise.
517
+ */
518
+ function matchesDimensions(dimensions, constraints) {
519
+ if (typeof constraints.minWidth === "number" && dimensions.width < constraints.minWidth) return false;
520
+ if (typeof constraints.maxWidth === "number" && dimensions.width > constraints.maxWidth) return false;
521
+ if (typeof constraints.minHeight === "number" && dimensions.height < constraints.minHeight) return false;
522
+ if (typeof constraints.maxHeight === "number" && dimensions.height > constraints.maxHeight) return false;
523
+ if (typeof constraints.ratio === "number") {
524
+ const actualRatio = dimensions.width / dimensions.height;
525
+ if (Math.abs(actualRatio - constraints.ratio) > .01) return false;
526
+ }
527
+ return true;
528
+ }
529
+ /**
530
+ * Replace the placeholders in the dimensions message with the actual constraint values.
531
+ *
532
+ * @param message
533
+ * @param parameters
534
+ * @returns
535
+ */
536
+ function replaceDimensionsMessage(message, parameters) {
537
+ const values = Object.fromEntries(parameters.map((parameter) => parameter.split("=", 2)).filter(([key, value]) => key && value));
538
+ return message.replace(":min_width", values.min_width ?? "").replace(":min_height", values.min_height ?? "").replace(":max_width", values.max_width ?? "").replace(":max_height", values.max_height ?? "").replace(":ratio", values.ratio ?? "");
539
+ }
540
+ /**
541
+ * Normalize the given rule input into an array of rules.
542
+ *
543
+ * @param rules
544
+ * @returns
545
+ */
546
+ function normalizeRuleInput(rules) {
547
+ if (Array.isArray(rules)) return rules.flatMap((rule) => rule.split("|")).filter(Boolean);
548
+ return rules.split("|").filter(Boolean);
549
+ }
550
+ /**
551
+ * Create the validation rules for a wildcard attribute based on the given item and
552
+ * collection rules.
553
+ *
554
+ * @param attribute The name of the attribute.
555
+ * @param itemRules The rules to apply to each individual file.
556
+ * @param collectionRules The rules to apply to the array of files as a whole.
557
+ * @returns An object containing the normalized rules for the attribute.
558
+ */
559
+ function createWildcardFileRules(attribute, itemRules, collectionRules = "files") {
560
+ return {
561
+ [attribute]: normalizeRuleInput(collectionRules),
562
+ [`${attribute}.*`]: normalizeRuleInput(itemRules)
563
+ };
564
+ }
565
+ /**
566
+ * Sync the files from the request context to the validator's data.
567
+ *
568
+ * @param validator
569
+ * @param attributes
570
+ * @returns
571
+ */
572
+ function syncRequestFilesToData(validator, attributes) {
573
+ const requestFiles = validator.getContext().requestFiles ?? {};
574
+ const nextData = { ...validator.getData() };
575
+ const keys = attributes && attributes.length > 0 ? attributes : Object.keys(requestFiles);
576
+ for (const key of keys) {
577
+ const fileValue = deepFind(requestFiles, key);
578
+ if (typeof fileValue !== "undefined" && typeof deepFind(nextData, key) === "undefined") deepSet(nextData, key, fileValue);
579
+ }
580
+ return validator.setData(nextData);
581
+ }
582
+ /**
583
+ * Create the file validator plugin with the given options.
584
+ *
585
+ * @param options
586
+ * @returns
587
+ */
588
+ function createFileValidatorPlugin(options = {}) {
589
+ pluginOptions = options;
590
+ return definePlugin({
591
+ name: "@kanun/plugin-file",
592
+ install: ({ extendTranslations, registerRule, registerValueInspector }) => {
593
+ registerValueInspector({
594
+ type: "file",
595
+ matches: isFileLike,
596
+ size: (value) => getFileSizeInKilobytes(value)
597
+ });
598
+ if (!installed) {
599
+ extendTranslations({ en: {
600
+ dimensions: "The :attribute must satisfy the image dimension constraints. min_width=:min_width, min_height=:min_height, max_width=:max_width, max_height=:max_height, ratio=:ratio.",
601
+ extensions: "The :attribute must have one of the following extensions: :values.",
602
+ file: "The :attribute must be a valid file.",
603
+ files: "The :attribute must contain valid files.",
604
+ image: "The :attribute must be an image.",
605
+ max: { file: "The :attribute must not be greater than :max kilobytes." },
606
+ mimes: "The :attribute must be a file of type: :values.",
607
+ mimetypes: "The :attribute must be a file of type: :values.",
608
+ min: { file: "The :attribute must be at least :min kilobytes." },
609
+ size: { file: "The :attribute must be :size kilobytes." }
610
+ } });
611
+ registerRule("file", async function(value, _parameters, attribute = "") {
612
+ const { candidates, files } = await resolveFiles(value, attribute, this);
613
+ return !Array.isArray(candidates) && files.length === 1;
614
+ });
615
+ registerRule("files", async function(value, _parameters, attribute = "") {
616
+ const { candidates, files } = await resolveFiles(value, attribute, this);
617
+ return Array.isArray(candidates) && files.length > 0 && files.length === candidates.length;
618
+ });
619
+ registerRule("image", async function(value, _parameters, attribute = "") {
620
+ const { files } = await resolveFiles(value, attribute, this);
621
+ return files.length > 0 && files.every((file) => isImageFile(file));
622
+ });
623
+ registerRule("extensions", async function(value, parameters = [], attribute = "") {
624
+ const { files } = await resolveFiles(value, attribute, this);
625
+ const allowed = new Set(parameters.map((parameter) => parameter.toLowerCase().replace(".", "")));
626
+ return files.length > 0 && files.every((file) => {
627
+ const extension = getExtension(file);
628
+ return typeof extension === "string" && allowed.has(extension);
629
+ });
630
+ }, (message, parameters) => message.replace(":values", parameters.join(", ")));
631
+ registerRule("mimetypes", async function(value, parameters = [], attribute = "") {
632
+ const { files } = await resolveFiles(value, attribute, this);
633
+ const allowed = new Set(parameters.map((parameter) => parameter.toLowerCase()));
634
+ return files.length > 0 && files.every((file) => {
635
+ const mimeType = getMimeType(file);
636
+ return typeof mimeType === "string" && allowed.has(mimeType);
637
+ });
638
+ }, (message, parameters) => message.replace(":values", parameters.join(", ")));
639
+ registerRule("mimes", async function(value, parameters = [], attribute = "") {
640
+ const { files } = await resolveFiles(value, attribute, this);
641
+ const allowed = new Set(parameters.map((parameter) => parameter.toLowerCase().replace(".", "")));
642
+ return files.length > 0 && files.every((file) => {
643
+ const extension = getExtension(file);
644
+ return typeof extension === "string" && allowed.has(extension);
645
+ });
646
+ }, (message, parameters) => message.replace(":values", parameters.join(", ")));
647
+ registerRule("dimensions", async function(value, parameters = [], attribute = "") {
648
+ const { files } = await resolveFiles(value, attribute, this);
649
+ if (files.length === 0) return false;
650
+ const constraints = parseDimensionConstraints(parameters);
651
+ for (const file of files) {
652
+ const dimensions = await getImageDimensions(file);
653
+ if (!dimensions || !matchesDimensions(dimensions, constraints)) return false;
654
+ }
655
+ return true;
656
+ }, replaceDimensionsMessage);
657
+ installed = true;
658
+ }
659
+ }
660
+ });
661
+ }
662
+ const fileValidatorPlugin = createFileValidatorPlugin();
663
+
664
+ //#endregion
665
+ export { createFileValidatorPlugin, createWildcardFileRules, fileValidatorPlugin, syncRequestFilesToData, useExpressUploadContext, useFastifyUploadContext, useH3UploadContext, useHonoUploadContext, withExpressUploadContext, withFastifyUploadContext, withH3UploadContext, withHonoUploadContext };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@kanun-hq/plugin-file",
3
+ "version": "0.1.0",
4
+ "description": "File validation plugin for Kanun.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": "./dist/index.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "homepage": "https://arkstack-hq.github.io/kanun",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/arkstack-hq/kanun.git",
21
+ "directory": "packages/file"
22
+ },
23
+ "keywords": [
24
+ "kanun",
25
+ "validation",
26
+ "file",
27
+ "upload",
28
+ "plugin"
29
+ ],
30
+ "dependencies": {
31
+ "image-size": "^2.0.2",
32
+ "kanun": "1.0.1"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown",
36
+ "lint": "eslint src --ext .ts"
37
+ }
38
+ }