feat(attachments): depolama soyutlaması, içerikten tür tespiti, sharp/HEIC dönüşümü ve yetkili dosya servisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user