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

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