@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/LICENSE +21 -0
- package/README.md +670 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +665 -0
- package/package.json +38 -0
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
|
+
}
|