verifyPassword args were swapped after the avatar rewrite, causing argon2 to throw on PIN select.
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
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<string, { count: number; resetAt: number }>();
|
|
|
|
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 };
|
|
}
|
|
}
|