import { prisma } from "../database/client"; import { hashPassword, verifyPassword } from "../security/crypto"; import { AppError } from "../security/errors"; import fs from "node:fs"; import path from "node:path"; /** Vaste avatar-set (client toont emoji/tekening per id). */ export const PROFILE_AVATARS = [ { id: "fox", label: "Vos" }, { id: "panda", label: "Panda" }, { id: "robot", label: "Robot" }, { id: "alien", label: "Alien" }, { id: "ninja", label: "Ninja" }, { id: "pirate", label: "Piraat" }, { id: "wizard", label: "Tovenaar" }, { id: "dino", label: "Dino" }, { id: "cat", label: "Kat" }, { id: "rocket", label: "Raket" }, ] as const; export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"]; const MAX_PROFILES = 8; const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000; const PIN_MAX_ATTEMPTS = 5; const MAX_AVATAR_BYTES = 2 * 1024 * 1024; /** In-memory PIN rate limit: profileId:deviceId → attempts */ const pinAttempts = new Map(); function isAvatarId(id: string): id is ProfileAvatarId { return PROFILE_AVATARS.some((a) => a.id === id); } function normalizePin(pin: string | null | undefined): string | null { if (pin == null || pin === "") return null; const digits = String(pin).replace(/\D/g, ""); if (digits.length !== 4) { throw new AppError("INVALID_REQUEST", "PIN moet precies 4 cijfers zijn", 400); } return digits; } export function profileAvatarsDir(): string { const root = process.env.DATA_DIR || path.join(process.cwd(), "data"); const dir = path.join(root, "profile-avatars"); fs.mkdirSync(dir, { recursive: true }); return dir; } function avatarFilePath(profileId: string, ext = "jpg"): string { return path.join(profileAvatarsDir(), `${profileId}.${ext}`); } function publicProfile(row: { id: string; name: string; avatarId: string; avatarImage?: string | null; showAvatar?: boolean | null; isOwner: boolean; isKids: boolean; pinHash: string | null; }) { const hasCustomAvatar = !!row.avatarImage; return { id: row.id, name: row.name, avatarId: row.avatarId, showAvatar: row.showAvatar !== false, hasCustomAvatar, avatarUrl: hasCustomAvatar ? `/api/v1/client/profiles/${row.id}/avatar` : null, isOwner: row.isOwner, isKids: row.isKids, hasPin: !!row.pinHash, }; } async function assertCanManage( viewerId: string, activeProfileId: string | null ) { if (!activeProfileId) { throw new AppError( "FORBIDDEN", "Selecteer eerst het hoofdprofiel om te beheren", 403 ); } const me = await prisma.viewerProfile.findFirst({ where: { id: activeProfileId, viewerUserId: viewerId }, }); if (!me?.isOwner) { throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403); } } export async function ensureOwnerProfile( viewerId: string, preferredName?: string | null ) { const existing = await prisma.viewerProfile.findFirst({ where: { viewerUserId: viewerId, isOwner: true }, }); if (existing) return existing; const name = preferredName?.trim() || (await prisma.viewerUser.findUnique({ where: { id: viewerId } }))?.name?.trim() || "Hoofdprofiel"; return prisma.viewerProfile.create({ data: { viewerUserId: viewerId, name: name.slice(0, 32), avatarId: "fox", showAvatar: true, isOwner: true, isKids: false, }, }); } export class ViewerProfileService { avatars() { return PROFILE_AVATARS.map((a) => ({ ...a })); } async list(viewerId: string) { await ensureOwnerProfile(viewerId); const rows = await prisma.viewerProfile.findMany({ where: { viewerUserId: viewerId }, orderBy: [{ isOwner: "desc" }, { createdAt: "asc" }], }); return rows.map(publicProfile); } async create( viewerId: string, activeProfileId: string | null, input: { name?: string; avatarId?: string; showAvatar?: boolean; isKids?: boolean; pin?: string | null; } ) { await assertCanManage(viewerId, activeProfileId); const count = await prisma.viewerProfile.count({ where: { viewerUserId: viewerId } }); if (count >= MAX_PROFILES) { throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400); } const name = (input.name?.trim() || `Profiel ${count + 1}`).slice(0, 32); const avatarId = input.avatarId || "fox"; if (!isAvatarId(avatarId)) { throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); } const pin = normalizePin(input.pin); try { const row = await prisma.viewerProfile.create({ data: { viewerUserId: viewerId, name, avatarId, showAvatar: input.showAvatar !== false, isOwner: false, isKids: !!input.isKids, pinHash: pin ? await hashPassword(pin) : null, }, }); return publicProfile(row); } catch { throw new AppError("CONFLICT", "Profielnaam bestaat al", 409); } } async update( viewerId: string, activeProfileId: string | null, profileId: string, input: { name?: string; avatarId?: string; showAvatar?: boolean; clearAvatarImage?: boolean; isKids?: boolean; pin?: string | null; clearPin?: boolean; } ) { const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); const active = activeProfileId ? await prisma.viewerProfile.findFirst({ where: { id: activeProfileId, viewerUserId: viewerId }, }) : null; const isSelf = activeProfileId === profileId; const isOwnerActing = active?.isOwner === true; if (!isOwnerActing && !isSelf) { throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403); } // Alleen owner mag kids-flag of andermans PIN wijzigen if (!isOwnerActing && (input.isKids !== undefined || input.pin !== undefined || input.clearPin)) { if (!isSelf || input.isKids !== undefined) { throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403); } } const data: { name?: string; avatarId?: string; showAvatar?: boolean; avatarImage?: string | null; isKids?: boolean; pinHash?: string | null; } = {}; if (input.name !== undefined) { const name = input.name.trim().slice(0, 32); if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400); data.name = name; } if (input.avatarId !== undefined) { if (!isAvatarId(input.avatarId)) { throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); } data.avatarId = input.avatarId; } if (input.showAvatar !== undefined) { data.showAvatar = !!input.showAvatar; } if (input.clearAvatarImage === true) { data.avatarImage = null; try { fs.unlinkSync(avatarFilePath(profileId)); } catch { /* ignore */ } } if (input.isKids !== undefined && isOwnerActing) { if (target.isOwner && input.isKids) { throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400); } data.isKids = input.isKids; } if (input.clearPin === true && (isOwnerActing || isSelf)) { data.pinHash = null; } else if (input.pin !== undefined && (isOwnerActing || isSelf)) { const pin = normalizePin(input.pin); data.pinHash = pin ? await hashPassword(pin) : null; } try { const row = await prisma.viewerProfile.update({ where: { id: profileId }, data, }); return publicProfile(row); } catch { throw new AppError("CONFLICT", "Profielnaam bestaat al", 409); } } async setAvatarImage( viewerId: string, activeProfileId: string | null, profileId: string, imageBase64: string, mimeType?: string ) { const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); const isSelf = activeProfileId === profileId; const active = activeProfileId ? await prisma.viewerProfile.findFirst({ where: { id: activeProfileId, viewerUserId: viewerId }, }) : null; if (!active?.isOwner && !isSelf) { throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403); } const raw = imageBase64.replace(/^data:image\/\w+;base64,/, ""); let buf: Buffer; try { buf = Buffer.from(raw, "base64"); } catch { throw new AppError("INVALID_REQUEST", "Ongeldige afbeelding", 400); } if (buf.length < 32 || buf.length > MAX_AVATAR_BYTES) { throw new AppError("INVALID_REQUEST", "Foto moet tussen 1KB en 2MB zijn", 400); } const mime = (mimeType || "image/jpeg").toLowerCase(); if (!mime.startsWith("image/")) { throw new AppError("INVALID_REQUEST", "Alleen afbeeldingen toegestaan", 400); } const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : "jpg"; const fileName = `${profileId}.${ext}`; const full = path.join(profileAvatarsDir(), fileName); // Verwijder oude extensies for (const old of ["jpg", "jpeg", "png", "webp"]) { try { fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`)); } catch { /* ignore */ } } fs.writeFileSync(full, buf); const row = await prisma.viewerProfile.update({ where: { id: profileId }, data: { avatarImage: fileName, showAvatar: true }, }); return publicProfile(row); } async readAvatarImage(viewerId: string, profileId: string): Promise<{ buf: Buffer; contentType: string }> { const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target?.avatarImage) { throw new AppError("NOT_FOUND", "Geen profielfoto", 404); } const full = path.join(profileAvatarsDir(), target.avatarImage); if (!fs.existsSync(full)) { throw new AppError("NOT_FOUND", "Geen profielfoto", 404); } const ext = path.extname(target.avatarImage).toLowerCase(); const contentType = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg"; return { buf: fs.readFileSync(full), contentType }; } async remove(viewerId: string, activeProfileId: string | null, profileId: string) { await assertCanManage(viewerId, activeProfileId); const target = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); if (target.isOwner) { throw new AppError("FORBIDDEN", "Hoofdprofiel kan niet verwijderd worden", 403); } if (activeProfileId === profileId) { throw new AppError("INVALID_REQUEST", "Je kunt het actieve profiel niet verwijderen", 400); } await prisma.viewerDevice.updateMany({ where: { activeProfileId: profileId }, data: { activeProfileId: null }, }); for (const old of ["jpg", "jpeg", "png", "webp"]) { try { fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`)); } catch { /* ignore */ } } await prisma.viewerProfile.delete({ where: { id: profileId } }); return { ok: true }; } async select( viewerId: string, deviceId: string, profileId: string, pin?: string | null ) { const profile = await prisma.viewerProfile.findFirst({ where: { id: profileId, viewerUserId: viewerId }, }); if (!profile) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); if (profile.pinHash) { const key = `${profileId}:${deviceId}`; const now = Date.now(); let bucket = pinAttempts.get(key); if (!bucket || bucket.resetAt < now) { bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; pinAttempts.set(key, bucket); } if (bucket.count >= PIN_MAX_ATTEMPTS) { throw new AppError( "RATE_LIMITED", "Te veel PIN-pogingen. Probeer later opnieuw.", 429 ); } const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false; if (!ok) { bucket.count += 1; throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); } pinAttempts.delete(key); } await prisma.viewerDevice.update({ where: { id: deviceId }, data: { activeProfileId: profile.id, lastSeenAt: new Date() }, }); return { profile: publicProfile(profile) }; } async clearActive(deviceId: string) { await prisma.viewerDevice.update({ where: { id: deviceId }, data: { activeProfileId: null }, }); return { ok: true }; } }