feat(attachments): depolama soyutlaması, içerikten tür tespiti, sharp/HEIC dönüşümü ve yetkili dosya servisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 99fc9dfea6
commit 2e1f4d7929
21 changed files with 1246 additions and 16 deletions
+74
View File
@@ -0,0 +1,74 @@
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<ProcessedImage> {
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");
}