stremio/apps/master-api/src/viewer/profiles.ts
Jos Vooges | STH cf9cdd43db Add server-synced viewer favorites for cross-device bookmarks.
Per-profile favorites API and migration so clients can share bookmarks after Dokploy deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:43:37 +02:00

313 lines
8.9 KiB
TypeScript

import { prisma } from "../database/client";
import { hashPassword, verifyPassword } from "../security/crypto";
import { AppError } from "../security/errors";
/** 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;
/** 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;
}
function publicProfile(row: {
id: string;
name: string;
avatarId: string;
isOwner: boolean;
isKids: boolean;
pinHash: string | null;
}) {
return {
id: row.id,
name: row.name,
avatarId: row.avatarId,
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",
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;
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().slice(0, 32);
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
const avatarId = input.avatarId || "fox";
if (!isAvatarId(avatarId)) {
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
}
const pin = normalizePin(input.pin);
const isKids = input.isKids === true;
try {
const row = await prisma.viewerProfile.create({
data: {
viewerUserId: viewerId,
name,
avatarId,
isOwner: false,
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;
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;
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.isKids !== undefined && isOwnerActing) {
// Owner-profiel mag geen kids worden
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 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 },
});
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 },
});
return { profile: publicProfile(profile) };
}
async clearActive(deviceId: string) {
await prisma.viewerDevice.update({
where: { id: deviceId },
data: { activeProfileId: null },
});
return { ok: true };
}
}