@streamscloud/kit 0.83.1 → 0.84.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.
|
@@ -1,6 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome of `FileHelper.checkType`. The declared type is the name's extension, or `type` when the name has no known
|
|
3
|
+
* extension. `file` carries the right name and `type` (the original file when nothing changes).
|
|
4
|
+
* - `match` — the bytes identify the declared type.
|
|
5
|
+
* - `mismatch` — the bytes identify another type, or nothing is declared (`declaredExtension: ''`).
|
|
6
|
+
* - `unverifiable` — a format bytes cannot identify (CSV, Markdown, legacy DOC / XLS), typed as declared.
|
|
7
|
+
* - `contradicted` — the declared type is byte-identifiable and the bytes are not it.
|
|
8
|
+
* - `unknown` — nothing is declared and the bytes identify no known type.
|
|
9
|
+
*/
|
|
10
|
+
export type FileTypeCheck = {
|
|
11
|
+
status: 'match' | 'unverifiable';
|
|
12
|
+
file: File;
|
|
13
|
+
} | {
|
|
14
|
+
status: 'mismatch';
|
|
15
|
+
file: File;
|
|
16
|
+
declaredExtension: string;
|
|
17
|
+
} | {
|
|
18
|
+
status: 'contradicted';
|
|
19
|
+
declaredExtension: string;
|
|
20
|
+
} | {
|
|
21
|
+
status: 'unknown';
|
|
22
|
+
};
|
|
1
23
|
export declare class FileHelper {
|
|
2
24
|
static blobToFile(blob: Blob, fileName: string): File;
|
|
3
25
|
static getFileExtension(fileName: string): string;
|
|
4
26
|
static omitFileExtension(fileName: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Checks a file's real type against its declared one. The bytes identify images (PNG, JPEG, WebP, GIF, AVIF, HEIC, SVG),
|
|
29
|
+
* MP4, audio (WAV, MP3, M4A, AAC, FLAC), PDF, DOCX, XLSX, ZIP and fonts (WOFF2, WOFF, TTF, OTF); other formats are typed
|
|
30
|
+
* as declared. An equivalent extension (`.jpeg` for `jpg`) and any member of the declared ISO media container family
|
|
31
|
+
* (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is any ZIP archive named `.zip`. When the name lacks
|
|
32
|
+
* the right extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`.
|
|
33
|
+
* Binary formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is
|
|
34
|
+
* then read whole to confirm an SVG root.
|
|
35
|
+
*/
|
|
36
|
+
static checkType(file: File): Promise<FileTypeCheck>;
|
|
5
37
|
static truncateFileName(fileName: string, maxNameLength: number | null | undefined): string;
|
|
6
38
|
}
|
|
@@ -1,4 +1,68 @@
|
|
|
1
|
-
import { StringHelper } from '../utils';
|
|
1
|
+
import { HtmlHelper, StringHelper } from '../utils';
|
|
2
|
+
import { default as mime } from 'mime';
|
|
3
|
+
const CONTENT_DETECTABLE_EXTENSIONS = new Set([
|
|
4
|
+
'png',
|
|
5
|
+
'jpg',
|
|
6
|
+
'webp',
|
|
7
|
+
'gif',
|
|
8
|
+
'avif',
|
|
9
|
+
'heic',
|
|
10
|
+
'mp4',
|
|
11
|
+
'wav',
|
|
12
|
+
'mp3',
|
|
13
|
+
'm4a',
|
|
14
|
+
'aac',
|
|
15
|
+
'flac',
|
|
16
|
+
'pdf',
|
|
17
|
+
'docx',
|
|
18
|
+
'xlsx',
|
|
19
|
+
'zip',
|
|
20
|
+
'woff2',
|
|
21
|
+
'woff',
|
|
22
|
+
'ttf',
|
|
23
|
+
'otf'
|
|
24
|
+
]);
|
|
25
|
+
const PREFERRED_EXTENSIONS = { 'video/quicktime': 'mov', 'audio/mpeg': 'mp3', 'audio/aac': 'aac', 'audio/flac': 'flac' };
|
|
26
|
+
const SVG_TYPE = { mime: 'image/svg+xml', extension: 'svg' };
|
|
27
|
+
const ISO_MEDIA_CONTAINERS = [new Set(['mp4', 'm4v', 'm4a', 'm4b', 'mov', '3gp', '3g2', 'f4v']), new Set(['heic', 'heif'])];
|
|
28
|
+
const CONTENT_DETECTABLE_MIMES = new Set([...CONTENT_DETECTABLE_EXTENSIONS, 'svg', ...ISO_MEDIA_CONTAINERS.flatMap((family) => [...family])].map((extension) => mime.getType(extension)));
|
|
29
|
+
const sameFormat = (extensionA, extensionB) => {
|
|
30
|
+
const type = mime.getType(extensionA);
|
|
31
|
+
return type !== null && type === mime.getType(extensionB);
|
|
32
|
+
};
|
|
33
|
+
const sameContainer = (extensionA, extensionB) => ISO_MEDIA_CONTAINERS.some((family) => family.has(extensionA) && family.has(extensionB));
|
|
34
|
+
const declaredType = (file) => {
|
|
35
|
+
const extension = FileHelper.getFileExtension(file.name).toLowerCase();
|
|
36
|
+
const fromName = extension ? mime.getType(extension) : null;
|
|
37
|
+
if (fromName) {
|
|
38
|
+
return { mime: fromName, extension, fromName: true };
|
|
39
|
+
}
|
|
40
|
+
const fromType = file.type ? (PREFERRED_EXTENSIONS[file.type] ?? mime.getExtension(file.type)) : null;
|
|
41
|
+
return fromType ? { mime: file.type, extension: fromType, fromName: false } : null;
|
|
42
|
+
};
|
|
43
|
+
const retyped = (file, name, type) => name === file.name && type === file.type ? file : new File([file], name, { type, lastModified: file.lastModified });
|
|
44
|
+
const MARKUP_PROBE_BYTES = 1024;
|
|
45
|
+
const startsWithMarkup = async (blob) => {
|
|
46
|
+
const head = await blob.slice(0, MARKUP_PROBE_BYTES).text();
|
|
47
|
+
return head.trimStart().startsWith('<');
|
|
48
|
+
};
|
|
49
|
+
const isSvgDocument = async (blob) => (await startsWithMarkup(blob)) && HtmlHelper.sanitizeSvg(await blob.text()) !== null;
|
|
50
|
+
const isZipArchive = async (blob) => (await blob.slice(0, 4).text()) === 'PK\u0003\u0004';
|
|
51
|
+
const contentType = async (blob, declared) => {
|
|
52
|
+
if (declared?.extension === 'zip' && (await isZipArchive(blob))) {
|
|
53
|
+
return declared;
|
|
54
|
+
}
|
|
55
|
+
const { fileTypeFromBlob } = await import('file-type');
|
|
56
|
+
const detected = await fileTypeFromBlob(blob);
|
|
57
|
+
if (detected && declared && sameContainer(detected.ext, declared.extension)) {
|
|
58
|
+
return declared;
|
|
59
|
+
}
|
|
60
|
+
const detectedMime = detected && CONTENT_DETECTABLE_EXTENSIONS.has(detected.ext) ? mime.getType(detected.ext) : null;
|
|
61
|
+
if (detected && detectedMime) {
|
|
62
|
+
return { mime: detectedMime, extension: detected.ext };
|
|
63
|
+
}
|
|
64
|
+
return declared && (!detected || detected.ext === 'xml') && (await isSvgDocument(blob)) ? SVG_TYPE : null;
|
|
65
|
+
};
|
|
2
66
|
export class FileHelper {
|
|
3
67
|
static blobToFile(blob, fileName) {
|
|
4
68
|
return new File([blob], fileName, { type: blob.type });
|
|
@@ -15,6 +79,33 @@ export class FileHelper {
|
|
|
15
79
|
const dotIndex = fileName.lastIndexOf('.');
|
|
16
80
|
return dotIndex > 0 && dotIndex < fileName.length - 1 ? fileName.slice(0, dotIndex) : fileName;
|
|
17
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Checks a file's real type against its declared one. The bytes identify images (PNG, JPEG, WebP, GIF, AVIF, HEIC, SVG),
|
|
84
|
+
* MP4, audio (WAV, MP3, M4A, AAC, FLAC), PDF, DOCX, XLSX, ZIP and fonts (WOFF2, WOFF, TTF, OTF); other formats are typed
|
|
85
|
+
* as declared. An equivalent extension (`.jpeg` for `jpg`) and any member of the declared ISO media container family
|
|
86
|
+
* (`.m4a` holding an `isom` MP4, `.heif` for HEIC) is a match, as is any ZIP archive named `.zip`. When the name lacks
|
|
87
|
+
* the right extension, it is appended and the original name stays intact: `photo.jpg` holding a PNG → `photo.jpg.png`.
|
|
88
|
+
* Binary formats read only the head; SVG is looked for only when something is declared, and a markup-looking file is
|
|
89
|
+
* then read whole to confirm an SVG root.
|
|
90
|
+
*/
|
|
91
|
+
static async checkType(file) {
|
|
92
|
+
const declared = declaredType(file);
|
|
93
|
+
const content = await contentType(file, declared);
|
|
94
|
+
const appended = (extension) => `${file.name}.${extension}`;
|
|
95
|
+
if (content) {
|
|
96
|
+
if (declared && sameFormat(declared.extension, content.extension)) {
|
|
97
|
+
return { status: 'match', file: retyped(file, declared.fromName ? file.name : appended(content.extension), content.mime) };
|
|
98
|
+
}
|
|
99
|
+
return { status: 'mismatch', file: retyped(file, appended(content.extension), content.mime), declaredExtension: declared?.extension ?? '' };
|
|
100
|
+
}
|
|
101
|
+
if (!declared) {
|
|
102
|
+
return { status: 'unknown' };
|
|
103
|
+
}
|
|
104
|
+
if (CONTENT_DETECTABLE_MIMES.has(mime.getType(declared.extension))) {
|
|
105
|
+
return { status: 'contradicted', declaredExtension: declared.extension };
|
|
106
|
+
}
|
|
107
|
+
return { status: 'unverifiable', file: retyped(file, declared.fromName ? file.name : appended(declared.extension), declared.mime) };
|
|
108
|
+
}
|
|
18
109
|
static truncateFileName(fileName, maxNameLength) {
|
|
19
110
|
if (!maxNameLength) {
|
|
20
111
|
return fileName;
|
|
@@ -30,8 +30,16 @@ export declare const FileRules: {
|
|
|
30
30
|
min2: number;
|
|
31
31
|
}, message?: string) => FileValidationRule;
|
|
32
32
|
MaxSize: (bytes: number, message?: string) => FileValidationRule;
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* `verifyContent` also reads the file through `FileHelper.checkType` and rejects it when the bytes contradict the name
|
|
35
|
+
* (`mismatch`, `contradicted`); a file the bytes cannot judge passes on its name.
|
|
36
|
+
*/
|
|
37
|
+
Mime: (accept: string, message?: string, options?: {
|
|
38
|
+
verifyContent?: boolean;
|
|
39
|
+
}) => FileValidationRule;
|
|
40
|
+
VideoAspectRatio: (ratios: string[], message?: string, options?: {
|
|
41
|
+
tolerancePercent?: number;
|
|
42
|
+
}) => FileValidationRule;
|
|
35
43
|
/**
|
|
36
44
|
* Validates a video's width / height ratio against an inclusive `[min, max]` interval. Each
|
|
37
45
|
* bound is a `'w:h'` string or a decimal; both are optional, so the interval can be open-ended
|
|
@@ -41,8 +49,12 @@ export declare const FileRules: {
|
|
|
41
49
|
VideoAspectRatioRange: (range: {
|
|
42
50
|
min?: AspectRatioBound;
|
|
43
51
|
max?: AspectRatioBound;
|
|
44
|
-
}, message?: string,
|
|
45
|
-
|
|
52
|
+
}, message?: string, options?: {
|
|
53
|
+
tolerancePercent?: number;
|
|
54
|
+
}) => FileValidationRule;
|
|
55
|
+
VideoOrientation: (orientation: VideoOrientationValue, message?: string, options?: {
|
|
56
|
+
tolerancePercent?: number;
|
|
57
|
+
}) => FileValidationRule;
|
|
46
58
|
VideoDimensions: (limits: {
|
|
47
59
|
max1: number;
|
|
48
60
|
max2: number;
|
|
@@ -65,8 +65,13 @@ const maxDimensionsMessage = (limits) => {
|
|
|
65
65
|
const limit = toSides(limits.max1, limits.max2);
|
|
66
66
|
return messages.maxDimensions(limit.long, limit.short);
|
|
67
67
|
};
|
|
68
|
+
const DEFAULT_TOLERANCE_PERCENT = 1;
|
|
68
69
|
const formatExtensions = (extensions) => extensions.map((ext) => '.' + ext.replace(/^\./, '')).join(', ');
|
|
69
70
|
const messages = new FileValidationLocalization();
|
|
71
|
+
const rejectContradictedContent = async (file, message) => {
|
|
72
|
+
const { status } = await FileHelper.checkType(file);
|
|
73
|
+
return status === 'mismatch' || status === 'contradicted' ? (message ?? messages.unsupportedType) : null;
|
|
74
|
+
};
|
|
70
75
|
/**
|
|
71
76
|
* Built-in rule factories. Each takes a constraint and an optional `message`; when omitted, the
|
|
72
77
|
* rule falls back to a localized default from `FileValidationLocalization` (kit ships no English
|
|
@@ -117,20 +122,30 @@ export const FileRules = {
|
|
|
117
122
|
return actual.long < limit.long || actual.short < limit.short ? (message ?? messages.minDimensions(limit.long, limit.short)) : null;
|
|
118
123
|
},
|
|
119
124
|
MaxSize: (bytes, message) => (file) => file.size > bytes ? (message ?? messages.maxSize(StringHelper.toFileSizeString(bytes))) : null,
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `verifyContent` also reads the file through `FileHelper.checkType` and rejects it when the bytes contradict the name
|
|
127
|
+
* (`mismatch`, `contradicted`); a file the bytes cannot judge passes on its name.
|
|
128
|
+
*/
|
|
129
|
+
Mime: (accept, message, options) => {
|
|
130
|
+
const rule = (file) => {
|
|
131
|
+
if (!matchesAcceptedFileTypes(file, accept)) {
|
|
132
|
+
return message ?? messages.unsupportedType;
|
|
133
|
+
}
|
|
134
|
+
return options?.verifyContent ? rejectContradictedContent(file, message) : null;
|
|
135
|
+
};
|
|
122
136
|
rule.accept = accept;
|
|
123
137
|
return rule;
|
|
124
138
|
},
|
|
125
|
-
VideoAspectRatio: (ratios, message,
|
|
139
|
+
VideoAspectRatio: (ratios, message, options) => async (file) => {
|
|
126
140
|
const { width, height } = await readMedia(file);
|
|
127
141
|
if (!width || !height) {
|
|
128
142
|
return message ?? messages.aspectRatio(ratios.join(', '));
|
|
129
143
|
}
|
|
130
144
|
const actual = width / height;
|
|
145
|
+
const tolerance = options?.tolerancePercent ?? DEFAULT_TOLERANCE_PERCENT;
|
|
131
146
|
const matches = ratios.some((ratio) => {
|
|
132
147
|
const target = parseRatio(ratio);
|
|
133
|
-
return Number.isFinite(target) && Math.abs((actual / target) * 100 - 100) <=
|
|
148
|
+
return Number.isFinite(target) && Math.abs((actual / target) * 100 - 100) <= tolerance;
|
|
134
149
|
});
|
|
135
150
|
return matches ? null : (message ?? messages.aspectRatio(ratios.join(', ')));
|
|
136
151
|
},
|
|
@@ -140,7 +155,7 @@ export const FileRules = {
|
|
|
140
155
|
* (`{ min }` = "this wide or wider", `{ max }` = "up to this"). `tolerancePercent` widens the
|
|
141
156
|
* interval outward on both edges. An empty `{}` specifies no constraint and accepts everything.
|
|
142
157
|
*/
|
|
143
|
-
VideoAspectRatioRange: (range, message,
|
|
158
|
+
VideoAspectRatioRange: (range, message, options) => async (file) => {
|
|
144
159
|
const { width, height } = await readMedia(file);
|
|
145
160
|
if (!width || !height) {
|
|
146
161
|
return message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max));
|
|
@@ -148,18 +163,20 @@ export const FileRules = {
|
|
|
148
163
|
const actual = width / height;
|
|
149
164
|
const lo = resolveBound(range.min);
|
|
150
165
|
const hi = resolveBound(range.max);
|
|
151
|
-
const
|
|
166
|
+
const tolerance = options?.tolerancePercent ?? DEFAULT_TOLERANCE_PERCENT;
|
|
167
|
+
const factor = 1 + tolerance / 100;
|
|
152
168
|
const okLo = lo === null || actual >= lo / factor;
|
|
153
169
|
const okHi = hi === null || actual <= hi * factor;
|
|
154
170
|
return okLo && okHi ? null : (message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max)));
|
|
155
171
|
},
|
|
156
|
-
VideoOrientation: (orientation, message,
|
|
172
|
+
VideoOrientation: (orientation, message, options) => async (file) => {
|
|
157
173
|
const { width, height } = await readMedia(file);
|
|
158
174
|
if (!width || !height) {
|
|
159
175
|
return message ?? messages.orientation(orientation);
|
|
160
176
|
}
|
|
161
177
|
const actual = width / height;
|
|
162
|
-
const
|
|
178
|
+
const tolerance = options?.tolerancePercent ?? DEFAULT_TOLERANCE_PERCENT;
|
|
179
|
+
const margin = tolerance / 100;
|
|
163
180
|
const ok = orientation === 'landscape' ? actual > 1 + margin : orientation === 'portrait' ? actual < 1 - margin : Math.abs(actual - 1) <= margin;
|
|
164
181
|
return ok ? null : (message ?? messages.orientation(orientation));
|
|
165
182
|
},
|
|
@@ -2,6 +2,7 @@ export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
|
|
|
2
2
|
export type { AspectRatioBound, FileValidationResult, FileValidationRule, FileValidationRuleSets, VideoOrientationValue } from './file-validation-types';
|
|
3
3
|
export type { BlobUpload, BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile, UploadingFileStatus } from './upload-types';
|
|
4
4
|
export type { AddOptions, UploadMediaStoreOptions } from './upload-media-store.svelte';
|
|
5
|
+
export type { FileTypeCheck } from './file-helper';
|
|
5
6
|
export { toBlobWithName, toFileWithUploadUrl } from './types';
|
|
6
7
|
export { uploadBlob } from './blob-storage';
|
|
7
8
|
export { openFile } from './open-file';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamscloud/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.0",
|
|
4
4
|
"author": "StreamsCloud",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -496,6 +496,7 @@
|
|
|
496
496
|
"dequal": "^2.0.3",
|
|
497
497
|
"dompurify": "^3.4.5",
|
|
498
498
|
"esm-env": "^1.2.2",
|
|
499
|
+
"file-type": "^22.1.1",
|
|
499
500
|
"fuse.js": "^7.3.0",
|
|
500
501
|
"hugerte": "^1.0.10",
|
|
501
502
|
"mime": "^4.1.0",
|
|
@@ -536,6 +537,7 @@
|
|
|
536
537
|
"eslint-plugin-svelte": "^3.23.0",
|
|
537
538
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
538
539
|
"esm-env": "^1.2.2",
|
|
540
|
+
"file-type": "^22.1.1",
|
|
539
541
|
"fuse.js": "^7.3.0",
|
|
540
542
|
"globals": "^17.12.0",
|
|
541
543
|
"hugerte": "^1.0.10",
|