@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
- Mime: (accept: string, message?: string) => FileValidationRule;
34
- VideoAspectRatio: (ratios: string[], message?: string, tolerancePercent?: number) => FileValidationRule;
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, tolerancePercent?: number) => FileValidationRule;
45
- VideoOrientation: (orientation: VideoOrientationValue, message?: string, tolerancePercent?: number) => FileValidationRule;
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
- Mime: (accept, message) => {
121
- const rule = (file) => (matchesAcceptedFileTypes(file, accept) ? null : (message ?? messages.unsupportedType));
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, tolerancePercent = 1) => async (file) => {
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) <= tolerancePercent;
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, tolerancePercent = 1) => async (file) => {
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 factor = 1 + tolerancePercent / 100;
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, tolerancePercent = 1) => async (file) => {
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 margin = tolerancePercent / 100;
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.83.1",
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",