import "server-only"; import sharp from "sharp"; import type { DetectedType } from "@/shared/attachments"; /** Ana görsel uzun kenar sınırı (fiş okunabilir kalsın, dosya küçülsün). */ export const MAX_IMAGE_EDGE = 2000; export const THUMB_EDGE = 320; /** Dekompresyon bombasına karşı piksel sınırı (~50 MP). */ const MAX_INPUT_PIXELS = 50_000_000; export type ProcessedImage = { main: Buffer; thumb: Buffer; width: number; height: number; }; export class UnreadableImageError extends Error {} /** * Görseli normalize eder: EXIF yönüne göre döndürür, en fazla 2000 px'e küçültür ve WebP'ye * çevirir. sharp çıktıya meta veri yazmaz (withMetadata kullanılmadığı için EXIF/GPS/ICC dahil * tüm meta veri düşer). HEIC (iPhone, HEVC) sharp'ın hazır derlemesinde çözülemediği için * libheif (WASM) ile çözülür. */ export async function processImage(bytes: Buffer, type: DetectedType): Promise { try { const source = type === "heic" ? await decodeHeic(bytes) : sharpFrom(bytes).rotate(); const { data, info } = await source .resize({ width: MAX_IMAGE_EDGE, height: MAX_IMAGE_EDGE, fit: "inside", withoutEnlargement: true, }) .webp({ quality: 82 }) .toBuffer({ resolveWithObject: true }); const thumb = await sharp(data) .resize({ width: THUMB_EDGE, height: THUMB_EDGE, fit: "inside", withoutEnlargement: true }) .webp({ quality: 70 }) .toBuffer(); return { main: data, thumb, width: info.width, height: info.height }; } catch (error) { throw new UnreadableImageError(error instanceof Error ? error.message : String(error)); } } function sharpFrom(bytes: Buffer) { return sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }); } async function decodeHeic(bytes: Buffer) { // Bazı .heic dosyaları aslında AV1 (AVIF) içerir; sharp bunları doğrudan çözebilir. try { const meta = await sharpFrom(bytes).metadata(); if (meta.compression === "av1") return sharpFrom(bytes).rotate(); } catch { // libheif ile denenecek. } const { default: decode } = await import("heic-decode"); const image = await decode({ buffer: bytes }); if (image.width * image.height > MAX_INPUT_PIXELS) throw new Error("Görsel çok büyük."); // libheif yönlendirmeyi (irot/imir) çözerken uygular; ham RGBA piksellerden devam edilir. return sharp(Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength), { raw: { width: image.width, height: image.height, channels: 4 }, }); } /** PDF'ler değiştirilmeden saklanır; yalnızca yapı kontrolü (başlık + dosya sonu işareti). */ export function isPlausiblePdf(bytes: Buffer): boolean { if (bytes.subarray(0, 5).toString("latin1") !== "%PDF-") return false; const tail = bytes.subarray(Math.max(0, bytes.length - 2048)).toString("latin1"); return tail.includes("%%EOF"); }