feat: secure KYC storage, Google OAuth, terms gating

This commit is contained in:
arifal
2026-04-28 23:10:21 +07:00
parent 58da4608ac
commit 05d0929f7a
41 changed files with 3087 additions and 262 deletions
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import {
isKycKind,
mimeFromKey,
readDecrypted,
} from "@/lib/secure-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface RouteCtx {
params: Promise<{ id: string; kind: string }>;
}
export async function GET(_req: NextRequest, ctx: RouteCtx) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id, kind } = await ctx.params;
if (!isKycKind(kind)) {
return NextResponse.json({ error: "Kind tidak valid" }, { status: 400 });
}
const verification = await organizerRepo.findById(id);
if (!verification) {
return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
}
const isOwner = verification.userId === session.user.id;
const isAdmin = isAdminEmail(session.user.email);
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const key = kind === "ktp" ? verification.ktpImageKey : verification.selfieKey;
if (!key) {
return NextResponse.json({ error: "File belum diunggah" }, { status: 404 });
}
let plain: Buffer;
try {
plain = await readDecrypted(kind, key);
} catch {
return NextResponse.json({ error: "File tidak dapat dibuka" }, { status: 500 });
}
return new NextResponse(new Uint8Array(plain), {
status: 200,
headers: {
"Content-Type": mimeFromKey(key),
"Content-Length": String(plain.length),
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": `inline; filename="${kind}-${id}"`,
},
});
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import {
ALLOWED_KYC_MIME,
MAX_KYC_FILE_BYTES,
isKycKind,
saveEncrypted,
} from "@/lib/secure-storage";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: "Body bukan multipart/form-data" }, { status: 400 });
}
const kind = String(form.get("kind") ?? "");
const file = form.get("file");
if (!isKycKind(kind)) {
return NextResponse.json({ error: "kind harus 'ktp' atau 'selfie'" }, { status: 400 });
}
if (!(file instanceof File)) {
return NextResponse.json({ error: "File wajib diisi" }, { status: 400 });
}
if (!ALLOWED_KYC_MIME.has(file.type)) {
return NextResponse.json(
{ error: "Hanya menerima JPG, PNG, atau WebP" },
{ status: 415 },
);
}
if (file.size > MAX_KYC_FILE_BYTES) {
return NextResponse.json({ error: "File maksimal 5MB" }, { status: 413 });
}
const buf = Buffer.from(await file.arrayBuffer());
const meta = await saveEncrypted(kind, buf, file.type);
return NextResponse.json({
key: meta.key,
mime: meta.mime,
size: meta.size,
});
}