stremio/apps/master-api/src/viewer/profiles.ts
Jos Vooges | STH 82742a1ea3 Fix profile PIN verification and restore TV tab underline style.
verifyPassword args were swapped after the avatar rewrite, causing argon2 to throw on PIN select.
2026-09-13 03:12:32 +02:00

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 };
}
}