admin/lib/fileValidation.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

192 lines
5.9 KiB
TypeScript

import { texts, format } from '@/texts'
const IMAGE_SIGNATURES: Record<string, (bytes: Uint8Array) => boolean> = {
'image/jpeg': (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff,
'image/png': (bytes) => [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, index) => bytes[index] === byte),
'image/webp': (bytes) =>
new TextDecoder().decode(bytes.slice(0, 4)) === 'RIFF' && new TextDecoder().decode(bytes.slice(8, 12)) === 'WEBP',
}
const HEIC_BRANDS = new Set(['heic', 'heif', 'mif1', 'msf1', 'hevx', 'heim', 'heis', 'hevm', 'hevs'])
const HEIC_MIME_TYPES = new Set(['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'])
const IMAGE_PROCESSING_TIMEOUT_MS = 30_000
/** Accept list for `<input type="file">` — includes HEIC so iPhone Photos picker works. */
export const SAFE_IMAGE_ACCEPT = [...Object.keys(IMAGE_SIGNATURES), 'image/heic', 'image/heif', '.heic', '.heif'].join(',')
function decodeAscii(bytes: Uint8Array, start: number, end: number): string {
return new TextDecoder().decode(bytes.slice(start, end))
}
function isHeicBytes(bytes: Uint8Array): boolean {
if (bytes.length < 12) return false
if (decodeAscii(bytes, 4, 8) !== 'ftyp') return false
return HEIC_BRANDS.has(decodeAscii(bytes, 8, 12))
}
function isHeicMime(mime: string): boolean {
return HEIC_MIME_TYPES.has(mime.toLowerCase())
}
function isHeicFileName(name: string): boolean {
return /\.hei[cf]$/i.test(name)
}
/** Prefer magic bytes over `file.type` (often empty on iOS Safari). */
export function detectImageMime(bytes: Uint8Array, declaredType = ''): string | null {
for (const [mime, matches] of Object.entries(IMAGE_SIGNATURES)) {
if (matches(bytes)) return mime
}
if (isHeicBytes(bytes) || isHeicMime(declaredType)) return 'image/heic'
return null
}
function extensionForMime(mime: string): string {
switch (mime) {
case 'image/png':
return 'png'
case 'image/gif':
return 'gif'
case 'image/webp':
return 'webp'
case 'image/heic':
return 'heic'
case 'image/heif':
return 'heif'
default:
return 'jpg'
}
}
function withMimeType(file: File, mime: string): File {
if (file.type === mime) return file
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
const nextName = `${baseName}.${extensionForMime(mime)}`
return new File([file], nextName, { type: mime, lastModified: file.lastModified })
}
/**
* Converts HEIC → JPEG in-browser when possible (Safari/WebKit on iPhone).
* Returns `null` — instead of throwing — when the browser can't decode HEIC
* (Chrome/Edge/Firefox, notably on Windows), so the caller can fall back to
* uploading the original HEIC file for the backend to convert.
*/
async function convertHeicToJpeg(file: File): Promise<File | null> {
let bitmap: ImageBitmap
try {
bitmap = await createImageBitmap(file)
} catch {
return null
}
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error(texts.common.imagePrepareFailed)
ctx.drawImage(bitmap, 0, 0)
const blob = await withImageProcessingTimeout(
new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => {
if (result) resolve(result)
else reject(new Error(texts.common.imageConvertFailed))
},
'image/jpeg',
0.92
)
})
)
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: Date.now() })
} finally {
bitmap.close()
}
}
function withImageProcessingTimeout<T>(operation: Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
reject(new Error(texts.common.imageProcessingTimedOut))
}, IMAGE_PROCESSING_TIMEOUT_MS)
operation.then(
(value) => {
window.clearTimeout(timeout)
resolve(value)
},
(error: unknown) => {
window.clearTimeout(timeout)
reject(error instanceof Error ? error : new Error(texts.common.imagePrepareFailed))
}
)
})
}
function sizeError(file: File, maxSizeMb: number): string | null {
if (file.size === 0) return texts.common.imageEmpty
if (file.size > maxSizeMb * 1024 * 1024) {
return format(texts.common.imageTooLarge, { maxMb: maxSizeMb })
}
return null
}
/**
* Normalize an image picked on any device (esp. iPhone) into an upload-ready File:
* - sniffs magic bytes when `file.type` is empty
* - converts HEIC/HEIF → JPEG when the browser can decode it
*
* Returns the File, or a Persian error string.
*/
export async function prepareImageForUpload(file: File, maxSizeMb = 6): Promise<File | string> {
const tooLarge = sizeError(file, maxSizeMb)
if (tooLarge) return tooLarge
const bytes = new Uint8Array(await withImageProcessingTimeout(file.slice(0, 16).arrayBuffer()))
const sniffed = detectImageMime(bytes, file.type) ?? (isHeicFileName(file.name) ? 'image/heic' : null)
if (!sniffed) {
return texts.common.imageTypeNotAllowed
}
try {
if (sniffed === 'image/heic') {
const jpeg = await convertHeicToJpeg(file)
// Browser couldn't decode HEIC itself (e.g. Chrome/Edge on Windows) —
// send the original file as-is; the backend converts it server-side.
if (!jpeg) return withMimeType(file, sniffed)
const afterConvert = sizeError(jpeg, maxSizeMb)
return afterConvert ?? jpeg
}
return withMimeType(file, sniffed)
} catch (error) {
return error instanceof Error ? error.message : texts.common.imagePrepareFailed
}
}
export async function validateImageFile(file: File, maxSizeMb = 6): Promise<string | null> {
const prepared = await prepareImageForUpload(file, maxSizeMb)
return typeof prepared === 'string' ? prepared : null
}