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:
@@ -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
|
||||
|
||||
|
||||
+24
-1
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Generated
+53
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof setupSeededDb>>;
|
||||
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<TransactionForm>): Promise<TransactionForm> {
|
||||
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("<script>alert(1)</script>"),
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ActionResult<AttachmentInfo>> {
|
||||
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<string, unknown>;
|
||||
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<ActionResult<number>> {
|
||||
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<AttachmentInfo[]> {
|
||||
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<AttachmentFile | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface StorageDriver {
|
||||
/** Dosyayı yazar (varsa üzerine yazar). */
|
||||
put(key: string, data: Uint8Array): Promise<void>;
|
||||
/** Dosyayı okur; yoksa null. */
|
||||
get(key: string): Promise<Buffer | null>;
|
||||
/** Dosyayı siler; yoksa sessizce geçer. */
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anahtar biçimi: "ab/<rastgele-id>[.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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<typeof attachmentSchema>;
|
||||
|
||||
export const AttachmentModel: Model<Attachment> =
|
||||
(models.Attachment as Model<Attachment> | undefined) ??
|
||||
model<Attachment>("Attachment", attachmentSchema);
|
||||
+3
-1
@@ -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).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module "heic-decode" {
|
||||
type Decoded = { width: number; height: number; data: Uint8ClampedArray };
|
||||
function decode(input: { buffer: Uint8Array | ArrayBuffer }): Promise<Decoded>;
|
||||
export default decode;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadEnv } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
@@ -35,6 +37,8 @@ export default defineConfig({
|
||||
SEED_ADMIN_EMAIL: "admin@test.local",
|
||||
SEED_ADMIN_PASSWORD: "Test-Admin-12345",
|
||||
SEED_USER_PASSWORD: "Test-User-12345",
|
||||
// Ek dosyalar geçici dizine (her çalıştırmada test dosyası temizler).
|
||||
UPLOAD_DIR: path.join(tmpdir(), "butcem-test-uploads"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user