diff --git a/README.md b/README.md index 35a9630..6859b22 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,19 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull ## Scriptler -| Komut | Açıklama | -| ----------------------------- | -------------------------------- | -| `npm run dev` | Geliştirme sunucusu | -| `npm run build` / `start` | Üretim build'i / sunucusu | -| `npm run lint` | ESLint | -| `npm run typecheck` | `tsc --noEmit` | -| `npm run test` | Vitest birim testleri | -| `npm run format` | Prettier | -| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | -| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +| Komut | Açıklama | +| ----------------------------- | --------------------------------- | +| `npm run dev` | Geliştirme sunucusu | +| `npm run build` / `start` | Üretim build'i / sunucusu | +| `npm run lint` | ESLint | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run test` | Vitest birim testleri | +| `npm run format` | Prettier | +| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | +| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +| `npm run test:integration` | Entegrasyon testleri (MongoDB) | +| `npm run migrate` | Bekleyen migration'lar | +| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil | ## Haneler ve yetki diff --git a/next.config.ts b/next.config.ts index 8e8b26e..a367e4f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,10 +42,24 @@ const securityHeaders = [ [{ key: "Strict-Transport-Security", value: "max-age=31536000" }]), ]; +/** + * /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika: + * betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'. + */ +const fileCsp = [ + "default-src 'none'", + "img-src 'self'", + "style-src 'unsafe-inline'", + "object-src 'self'", + "frame-ancestors 'self'", +].join("; "); + const nextConfig: NextConfig = { // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. output: "standalone", poweredByHeader: false, + // HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin. + serverExternalPackages: ["heic-decode", "libheif-js"], experimental: { agentFeedback: true, }, @@ -60,7 +74,16 @@ const nextConfig: NextConfig = { }, }, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [ + { source: "/:path((?!api/files/).*)", headers: securityHeaders }, + { + source: "/api/files/:path*", + headers: [ + ...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"), + { key: "Content-Security-Policy", value: fileCsp }, + ], + }, + ]; }, }; diff --git a/package-lock.json b/package-lock.json index f62a378..7d959e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "butcem", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.1.0", + "version": "0.6.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", "better-auth": "^1.7.17", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", + "embla-carousel-react": "^8.6.0", + "heic-decode": "^2.1.0", "lucide-react": "^1.68.0", "mongodb": "~7.7", "mongoose": "^9.11.4", @@ -6861,6 +6863,34 @@ "integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -8205,6 +8235,18 @@ "node": ">= 0.4" } }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9076,6 +9118,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libheif-js": { + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz", + "integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 489c51e..419a6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -22,7 +22,8 @@ "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", "icons": "tsx scripts/generate-icons.ts", "test:integration": "vitest run --config vitest.integration.config.mts", - "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts" + "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts", + "cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", @@ -30,6 +31,8 @@ "better-auth": "^1.7.17", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", + "embla-carousel-react": "^8.6.0", + "heic-decode": "^2.1.0", "lucide-react": "^1.68.0", "mongodb": "~7.7", "mongoose": "^9.11.4", diff --git a/scripts/cleanup.ts b/scripts/cleanup.ts new file mode 100644 index 0000000..7223877 --- /dev/null +++ b/scripts/cleanup.ts @@ -0,0 +1,18 @@ +/** + * Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15). + * 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir. + */ +import mongoose from "mongoose"; +import { purgeAttachments } from "@/features/attachments/service"; +import { getMongoClient } from "@/lib/db/mongo"; + +purgeAttachments() + .then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`)) + .catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + await getMongoClient().close(); + }); diff --git a/src/app/api/files/[id]/route.ts b/src/app/api/files/[id]/route.ts new file mode 100644 index 0000000..b0f307d --- /dev/null +++ b/src/app/api/files/[id]/route.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { readAttachmentForUser } from "@/features/attachments/service"; +import { getCurrentUser } from "@/lib/dal"; + +/** + * GET /api/files/:id[?v=thumb][&indir=1] — oturum ve hane yetkisi kontrol edilerek ek dosyayı + * servis eder. Erişim yoksa (başka hane, gizli hesap, silinmiş kayıt) 404: varlık sızdırılmaz. + */ +export async function GET(request: NextRequest, ctx: RouteContext<"/api/files/[id]">) { + const user = await getCurrentUser(); + if (!user) return new Response(null, { status: 401 }); + const { id } = await ctx.params; + const params = request.nextUrl.searchParams; + const file = await readAttachmentForUser( + user.id, + id, + params.get("v") === "thumb" ? "thumb" : "full", + ); + if (!file) return new Response(null, { status: 404 }); + + const disposition = params.get("indir") === "1" ? "attachment" : "inline"; + const fallbackName = file.mimeType === "application/pdf" ? "belge.pdf" : "gorsel.webp"; + const name = toServedName(file.name || fallbackName, file.mimeType); + return new Response(new Uint8Array(file.bytes), { + headers: { + "Content-Type": file.mimeType, + "Content-Length": String(file.bytes.length), + "Content-Disposition": `${disposition}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + // Yalnızca kullanıcının tarayıcısında; paylaşılan önbelleklere girmez. + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); +} + +/** Görseller WebP'ye çevrildiği için indirilen adın uzantısı içerikle uyumlu olsun. */ +function toServedName(name: string, mimeType: string): string { + const ext = mimeType === "application/pdf" ? ".pdf" : ".webp"; + return name.replace(/\.[a-z0-9]{1,5}$/i, "") + ext; +} + +function asciiName(name: string): string { + return ( + name + .normalize("NFKD") + .replace(/[^\x20-\x7e]/g, "") + .replace(/["\\]/g, "") || "dosya" + ); +} diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts new file mode 100644 index 0000000..449e6dd --- /dev/null +++ b/src/app/api/files/route.ts @@ -0,0 +1,53 @@ +import { uploadAttachment } from "@/features/attachments/service"; +import { tr } from "@/i18n/tr"; +import { fail } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { env } from "@/lib/env"; + +/** Multipart başlıkları için tolerans. */ +const MULTIPART_OVERHEAD = 64 * 1024; + +/** + * POST /api/files — tek dosya yükler (`file` alanı), aktif haneye bekleyen ek olarak kaydeder. + * Kayıt (ör. işlem) kaydedilirken ek kimlikleri gönderilir ve ek kayda bağlanır. + */ +export async function POST(request: Request) { + if (!isSameOrigin(request)) return Response.json(fail(tr.common.unauthorized), { status: 403 }); + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return Response.json(fail(tr.common.unauthorized), { status: 401 }); + + const maxMb = env().MAX_UPLOAD_MB; + const length = Number(request.headers.get("content-length") ?? 0); + if (length > maxMb * 1024 * 1024 + MULTIPART_OVERHEAD) { + return Response.json(fail(tr.attachments.errors.tooLarge(maxMb)), { status: 413 }); + } + + let file: FormDataEntryValue | null; + try { + file = (await request.formData()).get("file"); + } catch { + return Response.json(fail(tr.attachments.errors.invalid), { status: 400 }); + } + if (!(file instanceof File)) { + return Response.json(fail(tr.attachments.errors.empty), { status: 400 }); + } + + const bytes = Buffer.from(await file.arrayBuffer()); + const result = await uploadAttachment(ctx.user.id, ctx.household.id, { name: file.name, bytes }); + return Response.json(result, { status: result.ok ? 201 : 400 }); +} + +/** + * CSRF savunması: tarayıcı isteğinde Origin varsa uygulamanın kendi host'u olmalı. + * (Oturum cookie'si SameSite=Lax; bu ek bir katman.) + */ +function isSameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return true; + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + try { + return new URL(origin).host === host; + } catch { + return false; + } +} diff --git a/src/features/attachments/service.int.test.ts b/src/features/attachments/service.int.test.ts new file mode 100644 index 0000000..464ffa9 --- /dev/null +++ b/src/features/attachments/service.int.test.ts @@ -0,0 +1,326 @@ +import { readFile, rm } from "node:fs/promises"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getTransaction, + createTransaction, + updateTransaction, + setTransactionDeleted, +} from "@/features/transactions/service"; +import { env } from "@/lib/env"; +import { getStorage } from "@/lib/storage"; +import { AccountModel } from "@/models/account"; +import { AttachmentModel } from "@/models/attachment"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import type { TransactionForm } from "@/shared/schemas/transaction"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + purgeAttachments, + readAttachmentForUser, + uploadAttachment, + type AttachmentInfo, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; +let ayse = ""; +let emre = ""; +let zeynep = ""; +let fatma = ""; + +beforeAll(async () => { + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); + ayse = ids.user("ayse"); + emre = ids.user("emre"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(async () => { + await teardownDb(); + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); +}); + +const acc = (name: string, householdId = cakir) => + AccountModel.findOne({ householdId, name }) + .lean() + .then((a) => String(a!._id)); +const cat = (key: string) => + CategoryModel.findOne({ householdId: cakir, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); + +async function form(over: Partial): Promise { + return { + type: "expense", + amount: 100_00, + date: "2026-06-15", + accountId: await acc("Cüzdan"), + toAccountId: null, + categoryId: await cat("market"), + splits: [], + payee: "", + description: "", + tags: [], + attachmentIds: [], + ...over, + }; +} + +const png = (w = 40, h = 30) => + sharp({ create: { width: w, height: h, channels: 3, background: "#3366cc" } }) + .png() + .toBuffer(); +const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n", "latin1"); + +async function upload(userId: string, bytes: Buffer, name = "fis.png", householdId = cakir) { + const r = await uploadAttachment(userId, householdId, { name, bytes }); + if (!r.ok) throw new Error(r.error); + return r.data; +} + +describe("yükleme ve dönüştürme", () => { + it("JPEG: EXIF/GPS silinir, yön uygulanır, 2000 px'e küçültülür, WebP + küçük resim", async () => { + const jpeg = await sharp({ + create: { width: 3000, height: 1200, channels: 3, background: "#cc3333" }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .withExif({ IFD0: { Copyright: "gizli", Artist: "Ayşe" } }) + .toBuffer(); + expect((await sharp(jpeg).metadata()).exif).toBeDefined(); + + const info = await upload(ayse, jpeg, "../../etc/fiş.JPG"); + expect(info.kind).toBe("image"); + expect(info.name).toBe("fiş.JPG"); + // Yön 6 (90°): 3000x1200 → 1200x3000 → en fazla 2000: 800x2000 + expect([info.width, info.height]).toEqual([800, 2000]); + + const doc = await AttachmentModel.findById(info.id).lean(); + expect(doc!.mimeType).toBe("image/webp"); + expect(doc!.ownerId).toBeNull(); + const stored = await getStorage().get(doc!.storageKey); + const meta = await sharp(stored!).metadata(); + expect(meta.format).toBe("webp"); + expect(meta.exif).toBeUndefined(); + expect(meta.orientation).toBeUndefined(); + const thumb = await sharp((await getStorage().get(doc!.thumbKey!))!).metadata(); + expect(Math.max(thumb.width!, thumb.height!)).toBe(320); + }); + + it("PNG ve PDF kabul edilir; uzantıya değil içeriğe bakılır", async () => { + expect((await upload(ayse, await png(), "resim.pdf")).kind).toBe("image"); + const p = await upload(ayse, pdf, "bordro.jpg"); + expect(p.kind).toBe("pdf"); + expect(p.thumbUrl).toBeNull(); + }); + + it("desteklenmeyen, bozuk ve boş dosyalar reddedilir", async () => { + const text = await uploadAttachment(ayse, cakir, { + name: "fis.jpg", + bytes: Buffer.from(""), + }); + expect(text.ok).toBe(false); + const broken = await uploadAttachment(ayse, cakir, { + name: "x.jpg", + bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4, 5, 6]), + }); + expect(broken).toMatchObject({ ok: false }); + const fakePdf = await uploadAttachment(ayse, cakir, { + name: "x.pdf", + bytes: Buffer.from("%PDF-1.4 yarım"), + }); + expect(fakePdf.ok).toBe(false); + expect((await uploadAttachment(ayse, cakir, { name: "x", bytes: Buffer.alloc(0) })).ok).toBe( + false, + ); + }); + + it("boyut sınırı uygulanır", async () => { + const big = Buffer.alloc(env().MAX_UPLOAD_MB * 1024 * 1024 + 1); + big.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect((await uploadAttachment(ayse, cakir, { name: "b.png", bytes: big })).ok).toBe(false); + }); + + it("izleyici ve hane dışı kullanıcı yükleyemez", async () => { + expect((await uploadAttachment(zeynep, cakir, { name: "a.png", bytes: await png() })).ok).toBe( + false, + ); + expect((await uploadAttachment(fatma, cakir, { name: "a.png", bytes: await png() })).ok).toBe( + false, + ); + }); + + it.skipIf(!process.env.HEIC_SAMPLE)("HEIC (iPhone) WebP'ye dönüştürülür", async () => { + const heic = await readFile(process.env.HEIC_SAMPLE!); + const info = await upload(ayse, heic, "IMG_0001.HEIC"); + expect(info.kind).toBe("image"); + const doc = await AttachmentModel.findById(info.id).lean(); + expect(doc!.sourceType).toBe("heic"); + expect((await sharp((await getStorage().get(doc!.storageKey))!).metadata()).format).toBe( + "webp", + ); + }); +}); + +describe("işleme bağlama", () => { + let a1: AttachmentInfo; + let a2: AttachmentInfo; + let a3: AttachmentInfo; + let txId = ""; + + it("gider işlemine görseller bağlanır, sıra korunur", async () => { + a1 = await upload(ayse, await png(10, 10)); + a2 = await upload(ayse, await png(20, 10)); + a3 = await upload(ayse, await png(30, 10)); + const r = await createTransaction( + ayse, + cakir, + await form({ attachmentIds: [a2.id, a1.id, a3.id] }), + ); + expect(r.ok).toBe(true); + txId = r.ok ? r.data.id : ""; + const detail = await getTransaction(ayse, cakir, txId); + expect(detail!.attachmentCount).toBe(3); + expect(detail!.attachments.map((a) => a.id)).toEqual([a2.id, a1.id, a3.id]); + }); + + it("gider işlemine PDF eklenemez; gelirde eklenir; hata olursa işlem oluşmaz", async () => { + const p = await upload(ayse, pdf, "bordro.pdf"); + const before = await TransactionModel.countDocuments({ householdId: cakir }); + const bad = await createTransaction(ayse, cakir, await form({ attachmentIds: [p.id] })); + expect(bad).toMatchObject({ ok: false }); + expect(await TransactionModel.countDocuments({ householdId: cakir })).toBe(before); + expect((await AttachmentModel.findById(p.id).lean())!.ownerId).toBeNull(); + + const good = await createTransaction( + ayse, + cakir, + await form({ + type: "income", + categoryId: await cat("maas"), + accountId: await acc("Ziraat Maaş"), + attachmentIds: [p.id], + }), + ); + expect(good.ok).toBe(true); + }); + + it("başkasının bekleyen eki, başka hanenin eki veya başka işlemin eki bağlanamaz", async () => { + const emres = await upload(emre, await png()); + expect( + (await createTransaction(ayse, cakir, await form({ attachmentIds: [emres.id] }))).ok, + ).toBe(false); + const other = await upload(fatma, await png(), "x.png", annemler); + expect( + (await createTransaction(ayse, cakir, await form({ attachmentIds: [other.id] }))).ok, + ).toBe(false); + // a1 zaten txId'ye bağlı: başka işleme taşınamaz. + expect((await createTransaction(ayse, cakir, await form({ attachmentIds: [a1.id] }))).ok).toBe( + false, + ); + }); + + it("kayıt başına en fazla 10 ek", async () => { + const many = []; + for (let i = 0; i < 11; i++) many.push((await upload(emre, await png(5 + i, 5))).id); + expect((await createTransaction(emre, cakir, await form({ attachmentIds: many }))).ok).toBe( + false, + ); + expect( + (await createTransaction(emre, cakir, await form({ attachmentIds: many.slice(0, 10) }))).ok, + ).toBe(true); + }); + + it("düzenlemede çıkarılan ek kayıttan düşer, yeni ek eklenir (başka üye de düzenleyebilir)", async () => { + const a4 = await upload(emre, await png(44, 10)); + const detail = await getTransaction(emre, cakir, txId); + const r = await updateTransaction( + emre, + cakir, + txId, + await form({ + accountId: detail!.account.id, + categoryId: detail!.categoryId, + attachmentIds: [a3.id, a4.id], + }), + ); + expect(r.ok).toBe(true); + const after = await getTransaction(ayse, cakir, txId); + expect(after!.attachments.map((a) => a.id)).toEqual([a3.id, a4.id]); + expect(after!.attachmentCount).toBe(2); + expect((await AttachmentModel.findById(a1.id).lean())!.deletedAt).toBeInstanceOf(Date); + expect(await readAttachmentForUser(ayse, a1.id, "full")).toBeNull(); + }); + + it("dosya erişimi: hane üyeleri (izleyici dahil) okur; hane dışı ve silinmiş işlem okuyamaz", async () => { + const file = await readAttachmentForUser(zeynep, a3.id, "thumb"); + expect(file?.mimeType).toBe("image/webp"); + expect(await readAttachmentForUser(fatma, a3.id, "full")).toBeNull(); + expect(await readAttachmentForUser(ayse, "not-an-id", "full")).toBeNull(); + + await setTransactionDeleted(ayse, cakir, txId, true); + expect(await readAttachmentForUser(ayse, a3.id, "full")).toBeNull(); + await setTransactionDeleted(ayse, cakir, txId, false); + expect(await readAttachmentForUser(ayse, a3.id, "full")).not.toBeNull(); + }); + + it("bekleyen ek yalnızca yükleyene görünür", async () => { + const pending = await upload(ayse, await png()); + expect(await readAttachmentForUser(ayse, pending.id, "full")).not.toBeNull(); + expect(await readAttachmentForUser(emre, pending.id, "full")).toBeNull(); + }); + + it("gizli hesaptaki işlemin eki hesap sahibi dışındakilere kapalı", async () => { + const img = await upload(ayse, await png()); + const r = await createTransaction( + ayse, + cakir, + await form({ accountId: await acc("Ayşe — Pluxee"), attachmentIds: [img.id] }), + ); + expect(r.ok).toBe(true); + expect(await readAttachmentForUser(ayse, img.id, "full")).not.toBeNull(); + expect(await readAttachmentForUser(emre, img.id, "full")).toBeNull(); + }); +}); + +describe("temizlik", () => { + it("eski bekleyen ekler, çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri silinir", async () => { + const pending = await upload(ayse, await png()); + const old = new Date(Date.now() - 25 * 60 * 60 * 1000); + await AttachmentModel.collection.updateOne( + { _id: (await AttachmentModel.findById(pending.id))!._id }, + { $set: { createdAt: old } }, + ); + + const kept = await upload(ayse, await png()); + const r = await createTransaction(ayse, cakir, await form({ attachmentIds: [kept.id] })); + const tx = r.ok ? r.data.id : ""; + await setTransactionDeleted(ayse, cakir, tx, true); + const keptDoc = (await AttachmentModel.findById(kept.id).lean())!; + + // Silme 31 gün önce yapılmış gibi. + const now = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000); + const pendingDoc = (await AttachmentModel.findById(pending.id).lean())!; + const { removed } = await purgeAttachments(now); + expect(removed).toBeGreaterThan(0); + expect(await AttachmentModel.findById(pending.id)).toBeNull(); + expect(await getStorage().get(pendingDoc.storageKey)).toBeNull(); + expect(await AttachmentModel.findById(kept.id)).toBeNull(); + expect(await getStorage().get(keptDoc.storageKey)).toBeNull(); + expect((await TransactionModel.findById(tx).lean())!.attachmentCount).toBe(0); + }); + + it("taze bekleyen ekler ve bağlı ekler korunur", async () => { + const fresh = await upload(ayse, await png()); + const linked = await upload(ayse, await png()); + await createTransaction(ayse, cakir, await form({ attachmentIds: [linked.id] })); + await purgeAttachments(); + expect(await AttachmentModel.findById(fresh.id)).not.toBeNull(); + expect(await AttachmentModel.findById(linked.id)).not.toBeNull(); + }); +}); diff --git a/src/features/attachments/service.ts b/src/features/attachments/service.ts new file mode 100644 index 0000000..bd8925d --- /dev/null +++ b/src/features/attachments/service.ts @@ -0,0 +1,326 @@ +import "server-only"; +import { randomBytes } from "node:crypto"; +import { Types, type ClientSession } from "mongoose"; +import { resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { env } from "@/lib/env"; +import { toObjectId } from "@/lib/object-id"; +import { getStorage } from "@/lib/storage"; +import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process"; +import { AccountModel } from "@/models/account"; +import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment"; +import { TransactionModel } from "@/models/transaction"; +import { + detectFileType, + MAX_ATTACHMENTS_PER_RECORD, + type AttachmentKind, +} from "@/shared/attachments"; + +const t = tr.attachments; + +/** Bir kullanıcının bir hanede aynı anda bekletebileceği (kayda bağlanmamış) ek sayısı. */ +export const MAX_PENDING_PER_USER = 30; +/** Bağlanmamış eklerin ömrü. */ +export const PENDING_TTL_MS = 24 * 60 * 60 * 1000; +/** Silinen kayıt/ek dosyalarının fiziksel olarak silinmeden önce bekleme süresi (geri alma payı). */ +export const SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export type AttachmentInfo = { + id: string; + kind: AttachmentKind; + name: string; + size: number; + width: number | null; + height: number | null; + url: string; + thumbUrl: string | null; +}; + +type LeanAttachment = { + _id: Types.ObjectId; + kind: AttachmentKind; + originalName?: string | null; + size: number; + width?: number | null; + height?: number | null; + thumbKey?: string | null; +}; + +export function toAttachmentInfo(a: LeanAttachment): AttachmentInfo { + const id = String(a._id); + return { + id, + kind: a.kind, + name: a.originalName || (a.kind === "pdf" ? "belge.pdf" : "gorsel.webp"), + size: a.size, + width: a.width ?? null, + height: a.height ?? null, + url: `/api/files/${id}`, + thumbUrl: a.thumbKey ? `/api/files/${id}?v=thumb` : null, + }; +} + +/** Kullanıcının verdiği dosya adını güvenli hale getirir (yol, kontrol karakteri, uzunluk). */ +export function sanitizeFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? ""; + return base + .replace(/[\u0000-\u001f\u007f"<>]/g, "") + .trim() + .slice(0, 120); +} + +function newKey(): string { + const id = randomBytes(16).toString("hex"); + return `${id.slice(0, 2)}/${id}`; +} + +// --- Yükleme --- + +export async function uploadAttachment( + userId: string, + householdId: string, + file: { name: string; bytes: Buffer }, +): Promise> { + const m = await resolveMembership(userId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const maxBytes = env().MAX_UPLOAD_MB * 1024 * 1024; + if (file.bytes.length === 0) return fail(t.errors.empty); + if (file.bytes.length > maxBytes) return fail(t.errors.tooLarge(env().MAX_UPLOAD_MB)); + + const type = detectFileType(file.bytes); + if (!type) return fail(t.errors.unsupported); + + const uploader = new Types.ObjectId(userId); + const pending = await AttachmentModel.countDocuments({ + householdId: m.householdId, + ownerId: null, + uploadedBy: uploader, + deletedAt: null, + }); + if (pending >= MAX_PENDING_PER_USER) return fail(t.errors.tooManyPending); + + const key = newKey(); + const storage = getStorage(); + let doc: Record; + if (type === "pdf") { + if (!isPlausiblePdf(file.bytes)) return fail(t.errors.unreadable); + await storage.put(`${key}.pdf`, file.bytes); + doc = { + kind: "pdf", + mimeType: "application/pdf", + size: file.bytes.length, + storageKey: `${key}.pdf`, + thumbKey: null, + }; + } else { + let processed; + try { + processed = await processImage(file.bytes, type); + } catch (error) { + if (error instanceof UnreadableImageError) return fail(t.errors.unreadable); + throw error; + } + await storage.put(`${key}.webp`, processed.main); + await storage.put(`${key}.thumb.webp`, processed.thumb); + doc = { + kind: "image", + mimeType: "image/webp", + size: processed.main.length, + width: processed.width, + height: processed.height, + storageKey: `${key}.webp`, + thumbKey: `${key}.thumb.webp`, + }; + } + + const created = await AttachmentModel.create({ + ...doc, + householdId: m.householdId, + sourceType: type, + originalName: sanitizeFileName(file.name), + originalSize: file.bytes.length, + uploadedBy: uploader, + }); + return ok(toAttachmentInfo(created.toObject() as LeanAttachment)); +} + +// --- Kayda bağlama --- + +/** + * Kaydın ek listesini `ids` ile eşitler (sıra korunur). Yeni kimlikler, bu kullanıcının bu hanede + * yüklediği bekleyen ekler olmalı; listede olmayan mevcut ekler kayıttan çıkarılır (`deletedAt`). + * Kayıttaki güncel ek sayısını döner. Transaction içinde çağrılmalı (session). + */ +export async function syncOwnerAttachments(opts: { + householdId: Types.ObjectId; + actorId: string; + ownerType: AttachmentOwnerType; + ownerId: Types.ObjectId; + ids: string[]; + allowedKinds: readonly AttachmentKind[]; + session?: ClientSession; +}): Promise> { + const { householdId, ownerType, ownerId, session } = opts; + const unique = [...new Set(opts.ids)]; + if (unique.length > MAX_ATTACHMENTS_PER_RECORD) + return fail(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD)); + const objectIds = unique.map(toObjectId); + if (objectIds.some((id) => !id)) return fail(t.errors.invalid); + + const current = await AttachmentModel.find( + { householdId, ownerType, ownerId, deletedAt: null }, + { _id: 1 }, + { session }, + ).lean(); + const currentIds = new Set(current.map((a) => String(a._id))); + const newIds = objectIds.filter((id) => !currentIds.has(String(id))) as Types.ObjectId[]; + + const candidates = await AttachmentModel.find( + { _id: { $in: objectIds }, householdId, deletedAt: null }, + { kind: 1, ownerId: 1, uploadedBy: 1 }, + { session }, + ).lean(); + const byId = new Map(candidates.map((a) => [String(a._id), a])); + for (const id of newIds) { + const a = byId.get(String(id)); + // Yalnızca kendi yüklediği, henüz hiçbir kayda bağlanmamış ekler bağlanabilir. + if (!a || a.ownerId || String(a.uploadedBy) !== opts.actorId) return fail(t.errors.invalid); + } + for (const id of objectIds) { + const a = byId.get(String(id)); + if (a && !opts.allowedKinds.includes(a.kind)) return fail(t.errors.kindNotAllowed); + } + + const removed = current.filter((a) => !objectIds.some((id) => id!.equals(a._id))); + if (removed.length) { + await AttachmentModel.updateMany( + { _id: { $in: removed.map((a) => a._id) }, householdId }, + { $set: { deletedAt: new Date() } }, + { session }, + ); + } + if (objectIds.length) { + await AttachmentModel.bulkWrite( + objectIds.map((id, order) => ({ + updateOne: { + filter: { _id: id!, householdId }, + update: { $set: { ownerType, ownerId, order } }, + }, + })), + { session }, + ); + } + return ok(objectIds.length); +} + +export async function listOwnerAttachments( + householdId: Types.ObjectId, + ownerType: AttachmentOwnerType, + ownerId: Types.ObjectId, +): Promise { + const rows = await AttachmentModel.find({ householdId, ownerType, ownerId, deletedAt: null }) + .sort({ order: 1, createdAt: 1 }) + .lean(); + return rows.map((a) => toAttachmentInfo(a as unknown as LeanAttachment)); +} + +// --- Servis etme --- + +export type AttachmentFile = { + bytes: Buffer; + mimeType: string; + name: string; +}; + +/** + * Yetki kontrollü dosya okuma: kullanıcı ekin hanesinin üyesi olmalı; bekleyen ek yalnızca + * yükleyene; işleme bağlı ek, işlem kullanıcıya görünürse (silinmemiş ve başkasının gizli + * hesabına dokunmuyorsa). Erişim yoksa null (çağıran 404 döner; varlık sızdırılmaz). + */ +export async function readAttachmentForUser( + userId: string, + attachmentId: string, + variant: "full" | "thumb", +): Promise { + const id = toObjectId(attachmentId); + if (!id) return null; + await connectMongoose(); + const a = await AttachmentModel.findOne({ _id: id, deletedAt: null }).lean(); + if (!a) return null; + const m = await resolveMembership(userId, String(a.householdId), "viewer"); + if (!m) return null; + + if (!a.ownerId) { + if (String(a.uploadedBy) !== userId) return null; + } else if (a.ownerType === "transaction") { + const tx = await TransactionModel.findOne( + { _id: a.ownerId, householdId: a.householdId, deletedAt: null }, + { accountId: 1, toAccountId: 1 }, + ).lean(); + if (!tx) return null; + const hidden = await AccountModel.exists({ + _id: { $in: [tx.accountId, tx.toAccountId].filter(Boolean) }, + householdId: a.householdId, + isPrivate: true, + ownerUserId: { $ne: new Types.ObjectId(userId) }, + }); + if (hidden) return null; + } else { + return null; + } + + const key = variant === "thumb" && a.thumbKey ? a.thumbKey : a.storageKey; + const bytes = await getStorage().get(key); + if (!bytes) return null; + return { + bytes, + mimeType: variant === "thumb" && a.thumbKey ? "image/webp" : a.mimeType, + name: a.originalName ?? "", + }; +} + +// --- Temizlik --- + +/** + * Fiziksel temizlik (Bölüm 7.4): 24 saatten eski bekleyen ekler, 30 günden önce çıkarılmış + * ekler ve 30 günden önce silinmiş işlemlerin ekleri. Dosyalar silinir, kayıtlar kaldırılır. + */ +export async function purgeAttachments(now = new Date()): Promise<{ removed: number }> { + await connectMongoose(); + const retentionCutoff = new Date(now.getTime() - SOFT_DELETE_RETENTION_MS); + const pendingCutoff = new Date(now.getTime() - PENDING_TTL_MS); + + const deletedTx = await TransactionModel.find( + { deletedAt: { $ne: null, $lt: retentionCutoff }, attachmentCount: { $gt: 0 } }, + { _id: 1 }, + ).lean(); + + const victims = await AttachmentModel.find( + { + $or: [ + { ownerId: null, createdAt: { $lt: pendingCutoff } }, + { deletedAt: { $ne: null, $lt: retentionCutoff } }, + ...(deletedTx.length + ? [{ ownerType: "transaction" as const, ownerId: { $in: deletedTx.map((d) => d._id) } }] + : []), + ], + }, + { storageKey: 1, thumbKey: 1 }, + ).lean(); + + const storage = getStorage(); + for (const v of victims) { + await storage.delete(v.storageKey); + if (v.thumbKey) await storage.delete(v.thumbKey); + await AttachmentModel.deleteOne({ _id: v._id }); + } + if (deletedTx.length) { + await TransactionModel.updateMany( + { _id: { $in: deletedTx.map((d) => d._id) } }, + { $set: { attachmentCount: 0 } }, + ); + } + return { removed: victims.length }; +} diff --git a/src/lib/storage/.gitkeep b/src/lib/storage/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts new file mode 100644 index 0000000..1dff069 --- /dev/null +++ b/src/lib/storage/index.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { createLocalDriver } from "./local"; +import type { StorageDriver } from "./types"; + +export type { StorageDriver } from "./types"; +export { isValidStorageKey } from "./types"; + +/** + * Depolama soyutlaması (Bölüm 7.4): `put`, `get`, `delete`. İlk sürücü yerel disk (`UPLOAD_DIR`, + * canlıda Docker volume). İleride S3 uyumlu (MinIO) sürücü aynı arayüzle eklenebilir. + */ +const g = globalThis as unknown as { __butcemStorage?: StorageDriver }; + +export function getStorage(): StorageDriver { + g.__butcemStorage ??= createLocalDriver(env().UPLOAD_DIR); + return g.__butcemStorage; +} diff --git a/src/lib/storage/local.test.ts b/src/lib/storage/local.test.ts new file mode 100644 index 0000000..250336d --- /dev/null +++ b/src/lib/storage/local.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createLocalDriver } from "./local"; +import { isValidStorageKey } from "./types"; + +const dirs: string[] = []; +afterAll(() => Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))); + +describe("yerel depolama", () => { + it("anahtar biçimi: yol geçişi ve beklenmeyen uzantılar reddedilir", () => { + expect(isValidStorageKey("ab/0123456789abcdef0123.webp")).toBe(true); + expect(isValidStorageKey("ab/0123456789abcdef0123.thumb.webp")).toBe(true); + expect(isValidStorageKey("ab/0123456789abcdef0123.pdf")).toBe(true); + expect(isValidStorageKey("../0123456789abcdef0123.webp")).toBe(false); + expect(isValidStorageKey("ab/../../etc/passwd")).toBe(false); + expect(isValidStorageKey("/ab/0123456789abcdef0123.webp")).toBe(false); + expect(isValidStorageKey("ab/0123456789abcdef0123.html")).toBe(false); + }); + + it("put/get/delete", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "butcem-storage-")); + dirs.push(dir); + const s = createLocalDriver(dir); + const key = "cd/cdef0123456789abcdef.pdf"; + expect(await s.get(key)).toBeNull(); + await s.put(key, new TextEncoder().encode("merhaba")); + expect((await s.get(key))!.toString()).toBe("merhaba"); + await s.delete(key); + await s.delete(key); + expect(await s.get(key)).toBeNull(); + await expect(s.put("../x.pdf", new Uint8Array())).rejects.toThrow(); + }); +}); diff --git a/src/lib/storage/local.ts b/src/lib/storage/local.ts new file mode 100644 index 0000000..937f289 --- /dev/null +++ b/src/lib/storage/local.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { isValidStorageKey, type StorageDriver } from "./types"; + +/** Yerel disk sürücüsü. Yazma atomiktir (geçici dosya + rename); yarım dosya servis edilmez. */ +export function createLocalDriver(baseDir: string): StorageDriver { + const root = path.resolve(baseDir); + + function resolve(key: string): string { + if (!isValidStorageKey(key)) throw new Error(`Geçersiz depolama anahtarı: ${key}`); + const full = path.resolve(root, key); + if (!full.startsWith(root + path.sep)) throw new Error("Depolama kökü dışına çıkılamaz."); + return full; + } + + return { + async put(key, data) { + const full = resolve(key); + await mkdir(path.dirname(full), { recursive: true, mode: 0o750 }); + const tmp = `${full}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, data, { mode: 0o640 }); + await rename(tmp, full); + }, + async get(key) { + try { + return await readFile(resolve(key)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }, + async delete(key) { + await rm(resolve(key), { force: true }); + }, + }; +} diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts new file mode 100644 index 0000000..2c4dc71 --- /dev/null +++ b/src/lib/storage/types.ts @@ -0,0 +1,18 @@ +export interface StorageDriver { + /** Dosyayı yazar (varsa üzerine yazar). */ + put(key: string, data: Uint8Array): Promise; + /** Dosyayı okur; yoksa null. */ + get(key: string): Promise; + /** Dosyayı siler; yoksa sessizce geçer. */ + delete(key: string): Promise; +} + +/** + * Anahtar biçimi: "ab/[.thumb].webp|pdf". Yalnızca küçük harf, rakam, tire, nokta + * ve tek bir alt klasör; ".." veya mutlak yol olamaz (dizin dışına çıkma engellenir). + */ +const KEY_PATTERN = /^[a-z0-9]{2}\/[a-z0-9-]{16,64}(\.thumb)?\.(webp|pdf)$/; + +export function isValidStorageKey(key: string): boolean { + return KEY_PATTERN.test(key); +} diff --git a/src/lib/uploads/process.ts b/src/lib/uploads/process.ts new file mode 100644 index 0000000..467d11f --- /dev/null +++ b/src/lib/uploads/process.ts @@ -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 { + 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"); +} diff --git a/src/models/attachment.ts b/src/models/attachment.ts new file mode 100644 index 0000000..abc9199 --- /dev/null +++ b/src/models/attachment.ts @@ -0,0 +1,51 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ATTACHMENT_KINDS } from "@/shared/attachments"; + +/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */ +export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const; +export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number]; + +/** + * Ek dosya meta verisi (Bölüm 7.4). Dosyanın kendisi depolama sürücüsünde, rastgele anahtarla. + * - Yüklenince `ownerId: null` (bekleyen); kayıt kaydedilince sahibine bağlanır. + * - Bekleyen ek yalnızca yükleyene görünür ve 24 saat içinde bağlanmazsa temizlenir. + * - Kayıttan çıkarılan ek `deletedAt` alır; dosya saklama süresi sonunda silinir. + */ +const attachmentSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + ownerType: { type: String, enum: ATTACHMENT_OWNER_TYPES, default: null }, + ownerId: { type: Schema.Types.ObjectId, default: null }, + kind: { type: String, enum: ATTACHMENT_KINDS, required: true }, + /** Saklanan içeriğin türü (görseller her zaman WebP'ye çevrilir). */ + mimeType: { type: String, enum: ["image/webp", "application/pdf"], required: true }, + /** Kaynak tür (jpeg, png, webp, heic, pdf) — bilgi amaçlı. */ + sourceType: { type: String, required: true }, + originalName: { type: String, default: "", maxlength: 120 }, + size: { type: Number, required: true }, + originalSize: { type: Number, required: true }, + width: { type: Number, default: null }, + height: { type: Number, default: null }, + storageKey: { type: String, required: true }, + thumbKey: { type: String, default: null }, + uploadedBy: { type: Schema.Types.ObjectId, required: true }, + /** Kayıt içindeki sıra (carousel sırası). */ + order: { type: Number, default: 0 }, + deletedAt: { type: Date, default: null }, + }, + { timestamps: true, collection: "attachments" }, +); + +attachmentSchema.index({ householdId: 1, ownerType: 1, ownerId: 1, deletedAt: 1, order: 1 }); +attachmentSchema.index({ ownerId: 1, createdAt: 1 }); +attachmentSchema.index( + { deletedAt: 1 }, + { partialFilterExpression: { deletedAt: { $type: "date" } } }, +); +attachmentSchema.index({ storageKey: 1 }, { unique: true }); + +export type Attachment = InferSchemaType; + +export const AttachmentModel: Model = + (models.Attachment as Model | undefined) ?? + model("Attachment", attachmentSchema); diff --git a/src/proxy.ts b/src/proxy.ts index a292ad9..ec0c66b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -16,7 +16,9 @@ export function proxy(request: NextRequest) { export const config = { // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest. + // /api/files da hariç: route oturumu kendisi kontrol eder (401) ve proxy büyük yükleme + // gövdelerini tamponlamasın (proxyClientMaxBodySize). matcher: [ - "/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", + "/((?!login|api/auth|api/health|api/files|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", ], }; diff --git a/src/shared/attachments.test.ts b/src/shared/attachments.test.ts new file mode 100644 index 0000000..f655c3d --- /dev/null +++ b/src/shared/attachments.test.ts @@ -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(" { + 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"); + }); +}); diff --git a/src/shared/attachments.ts b/src/shared/attachments.ts new file mode 100644 index 0000000..dd86736 --- /dev/null +++ b/src/shared/attachments.ts @@ -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"]; +} + +/** `