@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
121 lines • 4.35 kB
JavaScript
// CSpell:words theora
import { open, stat } from 'node:fs/promises';
import { MimeTypeCategory, getMimeTypeCategory } from '@appsemble/utils';
import { fileTypeFromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import sharp from 'sharp';
const headerLength = 4096;
export class AssetUploadValidationError extends Error {
constructor(message, category) {
super(message);
this.name = 'AssetUploadValidationError';
this.Category = category;
}
}
function detectSvgMime(buffer) {
const content = buffer
.toString('utf8')
.replace(/^\uFEFF/u, '')
.trimStart();
if (!content.startsWith('<')) {
return null;
}
return /<svg[\s>]/i.test(content) ? 'image/svg+xml' : null;
}
function detectOggVideoMime(buffer, declaredMime) {
if (buffer.length < 4 || buffer.toString('ascii', 0, 4) !== 'OggS') {
return null;
}
const content = buffer.toString('ascii').toLowerCase();
if (content.includes('theora') || declaredMime === 'video/ogg') {
return 'video/ogg';
}
return null;
}
function normalizeImageMime(format) {
const mime = lookup(format);
if (mime) {
return mime;
}
if (format === 'heif') {
return 'image/heif';
}
return null;
}
function normalizeDetectedMime(mime) {
if (mime === 'video/vnd.avi') {
return 'video/x-msvideo';
}
return mime;
}
async function readHeader(path) {
const file = await open(path, 'r');
try {
const { size } = await file.stat();
const buffer = Buffer.alloc(Math.min(size, headerLength));
if (buffer.length) {
await file.read(buffer, 0, buffer.length, 0);
}
return buffer;
}
finally {
await file.close();
}
}
async function detectMime(buffer, declaredMime) {
const svgMime = detectSvgMime(buffer);
if (svgMime) {
return svgMime;
}
const detected = await fileTypeFromBuffer(buffer);
if (detected) {
return normalizeDetectedMime(detected.mime);
}
return detectOggVideoMime(buffer, declaredMime);
}
export async function validateUploadedFile({ mime, path }) {
const declaredCategory = getMimeTypeCategory(mime);
const { size } = await stat(path);
const buffer = await readHeader(path);
const detectedMime = await detectMime(buffer, mime);
const detectedCategory = detectedMime ? getMimeTypeCategory(detectedMime) : null;
const category = detectedCategory ?? declaredCategory;
if (category !== MimeTypeCategory.Image && category !== MimeTypeCategory.Video) {
return mime;
}
if (size === 0) {
throw new AssetUploadValidationError(`${category === MimeTypeCategory.Image ? 'Image' : 'Video'} uploads cannot be empty`, category);
}
if (declaredCategory &&
detectedCategory &&
declaredCategory !== detectedCategory &&
[MimeTypeCategory.Image, MimeTypeCategory.Video].includes(declaredCategory)) {
throw new AssetUploadValidationError(declaredCategory === MimeTypeCategory.Image
? 'Image uploads must contain a valid image'
: 'Video uploads must contain a supported video container', declaredCategory);
}
if (declaredCategory === MimeTypeCategory.Image || detectedCategory === MimeTypeCategory.Image) {
try {
const metadata = await sharp(path).metadata();
if (!metadata.format) {
throw new Error('Missing image format');
}
const normalizedMime = normalizeImageMime(metadata.format);
if (!normalizedMime) {
throw new Error(`Unsupported image format: ${metadata.format}`);
}
return normalizedMime;
}
catch {
throw new AssetUploadValidationError('Image uploads must contain a valid image', MimeTypeCategory.Image);
}
}
if (declaredCategory === MimeTypeCategory.Video || detectedCategory === MimeTypeCategory.Video) {
if (detectedCategory !== MimeTypeCategory.Video || !detectedMime) {
throw new AssetUploadValidationError('Video uploads must contain a supported video container', MimeTypeCategory.Video);
}
return detectedMime;
}
return mime;
}
//# sourceMappingURL=uploadValidation.js.map