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
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
acceptAttribute,
allowedAttachmentKinds,
detectFileType,
formatFileSize,
kindOf,
} from "./attachments";
const bytes = (...parts: (number[] | string)[]) =>
new Uint8Array(
parts.flatMap((p) => (typeof p === "string" ? [...p].map((c) => c.charCodeAt(0)) : p)),
);
describe("detectFileType", () => {
it("imzalardan türü bulur", () => {
expect(detectFileType(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg");
expect(detectFileType(bytes([0x89], "PNG", [0x0d, 0x0a, 0x1a, 0x0a]))).toBe("png");
expect(detectFileType(bytes("RIFF", [0, 0, 0, 0], "WEBPVP8 "))).toBe("webp");
expect(detectFileType(bytes("%PDF-1.7"))).toBe("pdf");
});
it("HEIC: ana veya uyumlu markadan", () => {
expect(detectFileType(bytes([0, 0, 0, 24], "ftypheic", [0, 0, 0, 0], "mif1heic"))).toBe("heic");
expect(detectFileType(bytes([0, 0, 0, 28], "ftypmif1", [0, 0, 0, 0], "mif1heichevc"))).toBe(
"heic",
);
// MP4 videosu HEIC değildir.
expect(detectFileType(bytes([0, 0, 0, 24], "ftypisom", [0, 0, 2, 0], "isomiso2"))).toBeNull();
});
it("uzantı/metin/kısa içerik reddedilir", () => {
expect(detectFileType(bytes("<svg xmlns="))).toBeNull();
expect(detectFileType(bytes("GIF89a"))).toBeNull();
expect(detectFileType(bytes([0xff, 0xd8]))).toBeNull();
expect(detectFileType(new Uint8Array())).toBeNull();
});
});
describe("tür kuralları", () => {
it("gider yalnızca görsel; gelir ve transfer görsel + PDF", () => {
expect(allowedAttachmentKinds("expense")).toEqual(["image"]);
expect(allowedAttachmentKinds("income")).toEqual(["image", "pdf"]);
expect(allowedAttachmentKinds("transfer")).toEqual(["image", "pdf"]);
expect(acceptAttribute(["image"])).not.toContain("pdf");
expect(acceptAttribute(["image", "pdf"])).toContain("application/pdf");
expect(kindOf("heic")).toBe("image");
expect(kindOf("pdf")).toBe("pdf");
});
it("boyut biçimi", () => {
expect(formatFileSize(900)).toBe("900 B");
expect(formatFileSize(2048)).toBe("2 KB");
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1,5 MB");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { TransactionType } from "./transactions";
/** Ek dosya kuralları (Bölüm 6.3, 7.4). Sunucu ve istemci aynı sabitleri kullanır. */
export const MAX_ATTACHMENTS_PER_RECORD = 10;
export const ATTACHMENT_KINDS = ["image", "pdf"] as const;
export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number];
/** Dosya içeriğinden tespit edilen kaynak türler (uzantıya güvenilmez). */
export type DetectedType = "jpeg" | "png" | "webp" | "heic" | "pdf";
export function kindOf(type: DetectedType): AttachmentKind {
return type === "pdf" ? "pdf" : "image";
}
/**
* İşlem türüne göre izinli ek türleri: gider yalnızca görsel (fiş/fatura fotoğrafı);
* gelir görsel veya PDF (ör. bordro); transfer görsel veya PDF (dekont).
*/
export function allowedAttachmentKinds(type: TransactionType): readonly AttachmentKind[] {
return type === "expense" ? ["image"] : ["image", "pdf"];
}
/** `<input accept>` değeri (yalnızca seçici filtresi; asıl doğrulama sunucuda). */
export function acceptAttribute(kinds: readonly AttachmentKind[]): string {
const image = "image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif";
return kinds.includes("pdf") ? `${image},application/pdf,.pdf` : image;
}
/** Dosya içeriğinin ilk baytlarından türü bulur; desteklenmeyen içerikte null. */
export function detectFileType(bytes: Uint8Array): DetectedType | null {
const at = (offset: number, sig: number[]) =>
bytes.length >= offset + sig.length && sig.every((b, i) => bytes[offset + i] === b);
const ascii = (offset: number, text: string) =>
at(
offset,
[...text].map((c) => c.charCodeAt(0)),
);
if (at(0, [0xff, 0xd8, 0xff])) return "jpeg";
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "webp";
if (ascii(0, "%PDF-")) return "pdf";
// ISO BMFF: [boyut][ftyp][ana marka][sürüm][uyumlu markalar…]
if (ascii(4, "ftyp")) {
const boxSize = Math.min(
bytes.length,
((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0,
);
const brands: string[] = [];
const brandAt = (o: number) => String.fromCharCode(...bytes.subarray(o, o + 4));
brands.push(brandAt(8));
for (let o = 16; o + 4 <= boxSize && o < 64; o += 4) brands.push(brandAt(o));
if (brands.some((b) => HEIF_BRANDS.has(b))) return "heic";
}
return null;
}
const HEIF_BRANDS = new Set([
"heic",
"heix",
"heim",
"heis",
"hevc",
"hevx",
"hevm",
"hevs",
"mif1",
"msf1",
]);
/** "1,2 MB" gibi okunur boyut. */
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} KB`;
return `${(kb / 1024).toLocaleString("tr-TR", { maximumFractionDigits: 1 })} MB`;
}