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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user