diff --git a/server/auth.ts b/server/auth.ts index 8a8725d..8f18e07 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -1,112 +1,101 @@ -import fs from 'fs' +import fs from 'fs' import path from 'path' import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' +import jwt from 'jsonwebtoken' import type { Request, Response, NextFunction } from 'express' import { JWT_SECRET } from './secret.js' -import { DATA_DIR } from './config.js' -import { logger } from './logger.js' +import { DATA_DIR } from './config.js' +import { logger } from './logger.js' +import { + findByUsername, findById, findByApiToken, + createUser, changePassword, regenerateApiToken, safeUser, hasUsers, + type User, +} from './users.js' -// AUTH_TOKEN_EXPIRY : durée de validité du token JWT -// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration -const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d' -const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' +// ── Typage Express étendu ───────────────────────────────────── +declare global { + namespace Express { + interface Request { user?: User } + } +} + +// ── Durée JWT ───────────────────────────────────────────────── +const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d' +const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY -function signToken(username: string): string { +function signToken(user: User): string { + const payload = { id: user.id, username: user.username, role: user.role } return NO_EXPIRY - ? jwt.sign({ username }, JWT_SECRET) - : jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any }) + ? jwt.sign(payload, JWT_SECRET) + : jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any }) } -const DATA_PATH = path.join(DATA_DIR, 'auth.json') -const SALT_ROUNDS = 10 - -interface AuthData { - username : string - passwordHash: string -} - -// ── Helpers fichier ─────────────────────────────────────────── -function readAuth(): AuthData | null { - try { - if (!fs.existsSync(DATA_PATH)) return null - return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8')) - } catch { return null } -} - -function writeAuth(data: AuthData): void { - fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true }) - fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2)) +function setCookieAndRespond(res: Response, user: User): void { + const token = signToken(user) + res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) + res.json({ success: true, user: safeUser(user) }) } // ── Routes ──────────────────────────────────────────────────── // GET /api/auth/status export function authStatus(req: Request, res: Response): void { - const existing = readAuth() - const token = req.cookies?.fankarr_token + const setup = hasUsers() + const token = req.cookies?.fankarr_token - let loggedIn = false - if (token) { - try { - jwt.verify(token, JWT_SECRET) - loggedIn = true - } catch { - logger.debug('auth', 'Token invalide ou expiré') - } + if (!token) { res.json({ setup, loggedIn: false }); return } + + try { + const payload = jwt.verify(token, JWT_SECRET) as any + const user = findById(payload.id) + if (!user) { res.json({ setup, loggedIn: false }); return } + res.json({ setup, loggedIn: true, role: user.role, username: user.username, userId: user.id }) + } catch { + res.json({ setup, loggedIn: false }) } - - res.json({ setup: !!existing, loggedIn }) } -// POST /api/auth/setup +// POST /api/auth/setup (premier lancement uniquement) export function authSetup(req: Request, res: Response): void { - if (readAuth()) { + if (hasUsers()) { logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà') - res.status(400).json({ error: 'Un compte existe déjà' }) - return + res.status(400).json({ error: 'Un compte existe déjà' }); return } const { username, password } = req.body if (!username || !password) { - res.status(400).json({ error: 'Username et password requis' }) - return + res.status(400).json({ error: 'Username et password requis' }); return } - const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS) - writeAuth({ username, passwordHash }) - logger.info('auth', `Compte créé pour "${username}"`) - - const token = signToken(username) - res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) - res.json({ success: true }) + try { + const user = createUser(username, password, 'admin') + logger.info('auth', `Premier compte admin créé pour "${username}"`) + setCookieAndRespond(res, user) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } } // POST /api/auth/login export function authLogin(req: Request, res: Response): void { - const auth = readAuth() - if (!auth) { - res.status(400).json({ error: 'Aucun compte configuré' }) - return + if (!hasUsers()) { + res.status(400).json({ error: 'Aucun compte configuré' }); return } const { username, password } = req.body if (!username || !password) { - res.status(400).json({ error: 'Username et password requis' }) - return + res.status(400).json({ error: 'Username et password requis' }); return } - if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) { + const user = findByUsername(username) + if (!user || !bcrypt.compareSync(password, user.passwordHash)) { logger.warn('auth', `Échec de connexion pour "${username}"`) - res.status(401).json({ error: 'Identifiants incorrects' }) - return + res.status(401).json({ error: 'Identifiants incorrects' }); return } - const token = signToken(username) - res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) - logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`) - res.json({ success: true }) + logger.info('auth', `Connexion de "${username}" (rôle: ${user.role}, expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`) + setCookieAndRespond(res, user) } // POST /api/auth/logout @@ -114,27 +103,98 @@ export function authLogout(req: Request, res: Response): void { const token = req.cookies?.fankarr_token if (token) { try { - const payload = jwt.verify(token, JWT_SECRET) as any - logger.info('auth', `Déconnexion de "${payload.username}"`) + const p = jwt.verify(token, JWT_SECRET) as any + logger.info('auth', `Déconnexion de "${p.username}"`) } catch {} } res.clearCookie('fankarr_token') res.json({ success: true }) } -// Middleware protection des routes +// GET /api/auth/me +export function authMe(req: Request, res: Response): void { + res.json(safeUser(req.user!)) +} + +// POST /api/auth/change-password +export function authChangePassword(req: Request, res: Response): void { + const { currentPassword, newPassword } = req.body + if (!currentPassword || !newPassword) { + res.status(400).json({ error: 'Mot de passe actuel et nouveau requis' }); return + } + if (newPassword.length < 6) { + res.status(400).json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' }); return + } + try { + changePassword(req.user!.id, currentPassword, newPassword) + res.json({ success: true }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +} + +// POST /api/auth/regenerate-token +export function authRegenerateToken(req: Request, res: Response): void { + try { + const token = regenerateApiToken(req.user!.id) + res.json({ apiToken: token }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +} + +// POST /api/auth/register (via lien d'invitation) +export function authRegister(req: Request, res: Response): void { + const { username, password, inviteCode } = req.body + if (!username || !password || !inviteCode) { + res.status(400).json({ error: 'Username, password et code d\'invitation requis' }); return + } + if (password.length < 6) { + res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return + } + // La validation du code se fait dans le router invites (middleware) + // Ici on reçoit déjà un username/password validés — la création est déléguée au router + res.status(400).json({ error: 'Utilisez /api/invites/:code/register' }) +} + +// ── Middlewares ─────────────────────────────────────────────── + export function requireAuth(req: Request, res: Response, next: NextFunction): void { const token = req.cookies?.fankarr_token if (!token) { logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`) - res.status(401).json({ error: 'Non authentifié' }) - return + res.status(401).json({ error: 'Non authentifié' }); return } try { - jwt.verify(token, JWT_SECRET) + const payload = jwt.verify(token, JWT_SECRET) as any + const user = findById(payload.id) + if (!user) { + res.status(401).json({ error: 'Utilisateur introuvable' }); return + } + req.user = user next() } catch { logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`) res.status(401).json({ error: 'Token invalide' }) } -} \ No newline at end of file +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + requireAuth(req, res, () => { + if (req.user?.role !== 'admin') { + logger.warn('auth', `Accès admin refusé pour "${req.user?.username}" (${req.method} ${req.path})`) + res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return + } + next() + }) +} + +/** Accepte aussi l'authentification par token API (header Authorization: Bearer ) */ +export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization + if (authHeader?.startsWith('Bearer ')) { + const user = findByApiToken(authHeader.slice(7)) + if (user) { req.user = user; next(); return } + } + requireAuth(req, res, next) +} diff --git a/server/index.ts b/server/index.ts index afb5a81..e5c34f6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,8 @@ import cookieParser from 'cookie-parser' import http from 'http' import fs from 'fs' import path from 'path' -import { authStatus, authSetup, authLogin, authLogout } from './auth.js' +import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, requireAuth, requireAdmin } from './auth.js' +import { migrateIfNeeded } from './users.js' import { readSettings } from './settings.js' import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' @@ -20,6 +21,11 @@ import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/gi import { pushNotif } from './lib/notifs.js' import { checkNfoUpdates } from './lib/nfo.js' +import usersRouter from './routes/users.js' +import invitesRouter from './routes/invites.js' +import requestsRouter from './routes/requests.js' +import jellyfinRouter from './routes/jellyfin.js' +import publicApiRouter from './routes/public-api.js' import settingsRouter from './routes/settings.js' import torrentClientsRouter from './routes/torrent-clients.js' import seriesRouter from './routes/series.js' @@ -51,23 +57,39 @@ if (fs.existsSync(PUBLIC_PATH)) { app.use(express.static(PUBLIC_PATH)) } -// ── Auth ─────────────────────────────────────────────────────── -app.get('/api/auth/status', authStatus) -app.post('/api/auth/setup', authSetup) -app.post('/api/auth/login', authLogin) -app.post('/api/auth/logout', authLogout) +// ── Migration mono-user → multi-user ────────────────────────── +migrateIfNeeded() -// ── Routes ───────────────────────────────────────────────────── -app.use('/api', settingsRouter) -app.use('/api', torrentClientsRouter) -app.use('/api', seriesRouter) -app.use('/api', downloadsRouter) -app.use('/api', organizeRouter) -app.use('/api', importRouter) -app.use('/api', systemRouter) -app.use('/api', nfoUpdatesRouter) -app.use('/api', plexRouter) -app.use('/api', rssSyncRouter) +// ── Auth ─────────────────────────────────────────────────────── +app.get ('/api/auth/status', authStatus) +app.post('/api/auth/setup', authSetup) +app.post('/api/auth/login', authLogin) +app.post('/api/auth/logout', authLogout) +app.get ('/api/auth/me', requireAuth, authMe) +app.post('/api/auth/change-password', requireAuth, authChangePassword) +app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken) + +// ── API publique v1 (auth par token) ────────────────────────── +app.use('/api', publicApiRouter) + +// ── Routes (admin uniquement) ────────────────────────────────── +app.use('/api', requireAdmin, usersRouter) +app.use('/api', requireAdmin, jellyfinRouter) +app.use('/api', requireAdmin, settingsRouter) +app.use('/api', requireAdmin, torrentClientsRouter) +app.use('/api', requireAdmin, downloadsRouter) +app.use('/api', requireAdmin, organizeRouter) +app.use('/api', requireAdmin, importRouter) +app.use('/api', requireAdmin, systemRouter) +app.use('/api', requireAdmin, nfoUpdatesRouter) +app.use('/api', requireAdmin, plexRouter) +app.use('/api', requireAdmin, rssSyncRouter) + +// ── Routes accessibles à tous les utilisateurs connectés ─────── +app.use('/api', requireAuth, seriesRouter) +app.use('/api', requireAuth, requestsRouter) +// Invites : mix public + admin (le router gère ses propres middlewares) +app.use('/api', invitesRouter) // ── Catch-all SPA ────────────────────────────────────────────── if (fs.existsSync(PUBLIC_PATH)) { diff --git a/server/invites.ts b/server/invites.ts new file mode 100644 index 0000000..8b0ebaa --- /dev/null +++ b/server/invites.ts @@ -0,0 +1,91 @@ +/** + * Système d'invitations + */ + +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import { DATA_DIR } from './config.js' +import { logger } from './logger.js' + +export interface Invite { + code : string + createdBy: string // userId de l'admin + createdAt: string + expiresAt: string | null // null = pas d'expiration + maxUses : number | null // null = utilisations illimitées + uses : number + note? : string +} + +const INVITES_PATH = path.join(DATA_DIR, 'invites.json') + +// ── Helpers fichier ─────────────────────────────────────────── + +export function readInvites(): Invite[] { + try { + if (!fs.existsSync(INVITES_PATH)) return [] + return JSON.parse(fs.readFileSync(INVITES_PATH, 'utf-8')) as Invite[] + } catch { return [] } +} + +function writeInvites(invites: Invite[]): void { + fs.mkdirSync(path.dirname(INVITES_PATH), { recursive: true }) + fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2)) +} + +// ── CRUD ────────────────────────────────────────────────────── + +export function createInvite(opts: { + createdBy: string + expiresInHours?: number | null + maxUses?: number | null + note?: string +}): Invite { + const invite: Invite = { + code : crypto.randomBytes(16).toString('hex'), + createdBy: opts.createdBy, + createdAt: new Date().toISOString(), + expiresAt: opts.expiresInHours + ? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString() + : null, + maxUses : opts.maxUses ?? 1, + uses : 0, + note : opts.note, + } + writeInvites([...readInvites(), invite]) + logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (maxUses: ${invite.maxUses ?? '∞'}, expire: ${invite.expiresAt ?? 'jamais'})`) + return invite +} + +export function deleteInvite(code: string): void { + const invites = readInvites() + if (!invites.find(i => i.code === code)) throw new Error('Invitation introuvable') + writeInvites(invites.filter(i => i.code !== code)) +} + +export function findInvite(code: string): Invite | undefined { + return readInvites().find(i => i.code === code) +} + +/** Vérifie si un code est valide (non expiré, non épuisé) */ +export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } { + const invite = findInvite(code) + if (!invite) return { valid: false, reason: 'Code d\'invitation invalide' } + if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) { + return { valid: false, reason: 'Ce lien d\'invitation a expiré' } + } + if (invite.maxUses !== null && invite.uses >= invite.maxUses) { + return { valid: false, reason: 'Ce lien d\'invitation a atteint son nombre maximum d\'utilisations' } + } + return { valid: true, invite } +} + +/** Incrémente le compteur d'utilisations */ +export function consumeInvite(code: string): void { + const invites = readInvites() + const invite = invites.find(i => i.code === code) + if (!invite) return + invite.uses++ + writeInvites(invites) +} diff --git a/server/lib/jellyfin.ts b/server/lib/jellyfin.ts new file mode 100644 index 0000000..5ee4d68 --- /dev/null +++ b/server/lib/jellyfin.ts @@ -0,0 +1,91 @@ +/** + * Helpers pour l'API Jellyfin + * Utilisé pour la sync d'utilisateurs et la validation SSO + */ + +import { readSettings } from '../settings.js' +import { logger } from '../logger.js' + +const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"' + +function adminHeaders(token: string): Record { + return { + 'X-Emby-Token': token, + 'Authorization': `${CLIENT_HEADER}, Token="${token}"`, + 'Content-Type': 'application/json', + } +} + +function userHeaders(token: string): Record { + return { + 'Authorization': `${CLIENT_HEADER}, Token="${token}"`, + 'Content-Type': 'application/json', + } +} + +// ── Types Jellyfin ──────────────────────────────────────────── + +export interface JellyfinUser { + Id : string + Name : string + HasPassword : boolean + Policy : { IsAdministrator: boolean; IsDisabled: boolean } +} + +// ── Test de connexion ───────────────────────────────────────── + +export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> { + try { + const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, { + headers: adminHeaders(token), + }) + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` } + const data = await res.json() + return { ok: true, version: data.Version } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Erreur réseau' } + } +} + +// ── Récupération des utilisateurs Jellyfin ──────────────────── + +export async function fetchJellyfinUsers(): Promise { + const { jellyfinUrl, jellyfinAdminToken } = readSettings() + if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou token manquant)') + + const base = jellyfinUrl.replace(/\/$/, '') + const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) }) + if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`) + return res.json() +} + +// ── Validation d'un token utilisateur Jellyfin (SSO) ────────── + +export interface JellyfinTokenInfo { + valid : boolean + userId? : string + username?: string + error? : string +} + +export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise { + const { jellyfinUrl } = readSettings() + if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' } + + try { + const base = jellyfinUrl.replace(/\/$/, '') + const res = await fetch(`${base}/Users/${jellyfinUserId}`, { + headers: userHeaders(jellyfinToken), + }) + if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' } + if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` } + + const user: JellyfinUser = await res.json() + if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' } + + logger.debug('jellyfin', `Token valide pour "${user.Name}" (${user.Id.slice(0, 8)}…)`) + return { valid: true, userId: user.Id, username: user.Name } + } catch (err) { + return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' } + } +} diff --git a/server/requests.ts b/server/requests.ts new file mode 100644 index 0000000..51583e2 --- /dev/null +++ b/server/requests.ts @@ -0,0 +1,165 @@ +/** + * Système de demandes utilisateurs + */ + +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import { DATA_DIR } from './config.js' +import { logger } from './logger.js' +import { findById } from './users.js' + +export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed' + +export interface Requester { + userId : string + username : string + seasons : number[] // numéros de saison ; [] = toutes les saisons dispo + requestedAt: string +} + +export interface SerieRequest { + id : string + serieId : number + serieName : string + requesters : Requester[] + status : RequestStatus + rejectionMessage?: string + createdAt : string + updatedAt : string +} + +const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json') + +// ── Helpers fichier ─────────────────────────────────────────── + +export function readRequests(): SerieRequest[] { + try { + if (!fs.existsSync(REQUESTS_PATH)) return [] + return JSON.parse(fs.readFileSync(REQUESTS_PATH, 'utf-8')) as SerieRequest[] + } catch { return [] } +} + +function writeRequests(requests: SerieRequest[]): void { + fs.mkdirSync(path.dirname(REQUESTS_PATH), { recursive: true }) + fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2)) +} + +/** Union des saisons demandées sur tous les requesters ([] = toutes) */ +export function mergedSeasons(req: SerieRequest): number[] { + if (req.requesters.some(r => r.seasons.length === 0)) return [] + const all = new Set() + req.requesters.forEach(r => r.seasons.forEach(s => all.add(s))) + return [...all].sort((a, b) => a - b) +} + +// ── CRUD ────────────────────────────────────────────────────── + +/** + * Créer ou fusionner une demande. + * - Si une demande active (non rejected/completed) existe pour la série → fusionne les saisons de cet user + * - Sinon → crée une nouvelle demande + */ +export function upsertRequest( + userId : string, + serieId : number, + serieName: string, + seasons : number[], // [] = toutes les saisons +): SerieRequest { + const user = findById(userId) + if (!user) throw new Error('Utilisateur introuvable') + + const requests = readRequests() + + // Chercher une demande active (pas rejected ni completed) pour cette série + const existing = requests.find(r => + r.serieId === serieId && + r.status !== 'rejected' && + r.status !== 'completed' + ) + + const now = new Date().toISOString() + + if (existing) { + // Mettre à jour ou ajouter ce requester dans la demande existante + const idx = existing.requesters.findIndex(r => r.userId === userId) + if (idx >= 0) { + // Mettre à jour les saisons (union) + if (seasons.length === 0 || existing.requesters[idx].seasons.length === 0) { + existing.requesters[idx].seasons = [] // l'une ou l'autre est "toutes" → toutes + } else { + const merged = new Set([...existing.requesters[idx].seasons, ...seasons]) + existing.requesters[idx].seasons = [...merged].sort((a, b) => a - b) + } + existing.requesters[idx].requestedAt = now + } else { + existing.requesters.push({ userId, username: user.username, seasons, requestedAt: now }) + } + existing.updatedAt = now + writeRequests(requests) + logger.info('requests', `Demande mise à jour pour "${serieName}" par "${user.username}"`) + return existing + } + + // Nouvelle demande + const request: SerieRequest = { + id : crypto.randomUUID(), + serieId, + serieName, + requesters: [{ userId, username: user.username, seasons, requestedAt: now }], + status : 'pending', + createdAt : now, + updatedAt : now, + } + writeRequests([...requests, request]) + logger.info('requests', `Nouvelle demande pour "${serieName}" par "${user.username}"`) + return request +} + +export function approveRequest(id: string): SerieRequest { + const requests = readRequests() + const req = requests.find(r => r.id === id) + if (!req) throw new Error('Demande introuvable') + req.status = 'approved' + req.rejectionMessage = undefined + req.updatedAt = new Date().toISOString() + writeRequests(requests) + logger.info('requests', `Demande "${req.serieName}" approuvée`) + return req +} + +export function rejectRequest(id: string, message?: string): SerieRequest { + const requests = readRequests() + const req = requests.find(r => r.id === id) + if (!req) throw new Error('Demande introuvable') + req.status = 'rejected' + req.rejectionMessage = message + req.updatedAt = new Date().toISOString() + writeRequests(requests) + logger.info('requests', `Demande "${req.serieName}" refusée`) + return req +} + +export function completeRequest(id: string): SerieRequest { + const requests = readRequests() + const req = requests.find(r => r.id === id) + if (!req) throw new Error('Demande introuvable') + req.status = 'completed' + req.updatedAt = new Date().toISOString() + writeRequests(requests) + return req +} + +export function deleteRequest(id: string): void { + const requests = readRequests() + if (!requests.find(r => r.id === id)) throw new Error('Demande introuvable') + writeRequests(requests.filter(r => r.id !== id)) +} + +export function getRequestsForUser(userId: string): SerieRequest[] { + return readRequests().filter(r => r.requesters.some(req => req.userId === userId)) +} + +export function getPendingCount(): number { + return readRequests().filter(r => r.status === 'pending').length +} diff --git a/server/routes/invites.ts b/server/routes/invites.ts new file mode 100644 index 0000000..73d00ac --- /dev/null +++ b/server/routes/invites.ts @@ -0,0 +1,75 @@ +import { Router } from 'express' +import { requireAdmin } from '../auth.js' +import { + readInvites, createInvite, deleteInvite, + validateInvite, consumeInvite, +} from '../invites.js' +import { createUser, safeUser } from '../users.js' + +const router = Router() + +// GET /api/invites (admin) +router.get('/invites', requireAdmin, (_req, res) => { + res.json(readInvites()) +}) + +// POST /api/invites (admin) +router.post('/invites', requireAdmin, (req, res) => { + const { expiresInHours, maxUses, note } = req.body + try { + const invite = createInvite({ + createdBy : req.user!.id, + expiresInHours: expiresInHours ?? null, + maxUses : maxUses ?? 1, + note, + }) + res.json(invite) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// DELETE /api/invites/:code (admin) +router.delete('/invites/:code', requireAdmin, (req, res) => { + try { + deleteInvite(req.params.code) + res.json({ success: true }) + } catch (err) { + res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// GET /api/invites/:code/info (public — pour afficher la page d'inscription) +router.get('/invites/:code/info', (req, res) => { + const result = validateInvite(req.params.code) + if (!result.valid) { + res.status(400).json({ valid: false, reason: result.reason }); return + } + res.json({ valid: true, note: result.invite.note ?? null }) +}) + +// POST /api/invites/:code/register (public — inscription via invitation) +router.post('/invites/:code/register', (req, res) => { + const result = validateInvite(req.params.code) + if (!result.valid) { + res.status(400).json({ error: result.reason }); return + } + + const { username, password } = req.body + if (!username || !password) { + res.status(400).json({ error: 'username et password requis' }); return + } + if (password.length < 6) { + res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return + } + + try { + const user = createUser(username, password, 'user') + consumeInvite(req.params.code) + res.json({ success: true, user: safeUser(user) }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +export default router diff --git a/server/routes/jellyfin.ts b/server/routes/jellyfin.ts new file mode 100644 index 0000000..290fc53 --- /dev/null +++ b/server/routes/jellyfin.ts @@ -0,0 +1,73 @@ +import { Router } from 'express' +import { readSettings, writeSettings } from '../settings.js' +import { readUsers, createUser, safeUser } from '../users.js' +import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js' +import { logger } from '../logger.js' + +const router = Router() + +// GET /api/jellyfin/settings +router.get('/jellyfin/settings', (_req, res) => { + const { jellyfinUrl, jellyfinAdminToken } = readSettings() + res.json({ + jellyfinUrl, + // Ne jamais retourner le token en clair — juste indiquer s'il est configuré + hasToken: !!jellyfinAdminToken, + }) +}) + +// POST /api/jellyfin/settings +router.post('/jellyfin/settings', (req, res) => { + const { jellyfinUrl, jellyfinAdminToken } = req.body + const update: Record = {} + if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim() + if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim() + const updated = writeSettings(update) + res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken }) +}) + +// POST /api/jellyfin/test +router.post('/jellyfin/test', async (_req, res) => { + const { jellyfinUrl, jellyfinAdminToken } = readSettings() + if (!jellyfinUrl || !jellyfinAdminToken) { + res.status(400).json({ ok: false, error: 'URL et token requis' }); return + } + const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken) + res.json(result) +}) + +// POST /api/jellyfin/sync +router.post('/jellyfin/sync', async (_req, res) => { + try { + const jellyfinUsers = await fetchJellyfinUsers() + const fankarrUsers = readUsers() + + const results = { created: 0, skipped: 0, users: [] as string[] } + + for (const jUser of jellyfinUsers) { + // Ignorer les comptes désactivés + if (jUser.Policy?.IsDisabled) { results.skipped++; continue } + + const exists = fankarrUsers.some(u => + u.username.toLowerCase() === jUser.Name.toLowerCase() + ) + + if (exists) { results.skipped++; continue } + + // Créer le compte avec un mot de passe aléatoire (auth via SSO uniquement) + const randomPass = Math.random().toString(36).slice(2, 10) + + Math.random().toString(36).slice(2, 10) + createUser(jUser.Name, randomPass, 'user') + results.created++ + results.users.push(jUser.Name) + logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`) + } + + logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`) + res.json(results) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' }) + } +}) + +export default router diff --git a/server/routes/public-api.ts b/server/routes/public-api.ts new file mode 100644 index 0000000..e274657 --- /dev/null +++ b/server/routes/public-api.ts @@ -0,0 +1,125 @@ +/** + * API publique v1 — pour le plugin Jellyfin et intégrations tierces + * Auth : Bearer {apiToken} dans le header Authorization + */ + +import { Router } from 'express' +import { findByApiToken } from '../users.js' +import { validateJellyfinToken } from '../lib/jellyfin.js' +import { readUsers, safeUser } from '../users.js' +import { upsertRequest, getRequestsForUser } from '../requests.js' +import { readAvailable } from '../lib/github-cache.js' +import { fankaiGet, normalizeSerie } from '../lib/serie-helpers.js' +import { logger } from '../logger.js' +import type { Request, Response, NextFunction } from 'express' + +const router = Router() + +// ── Middleware auth API token ───────────────────────────────── + +function requireApiToken(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization + if (!header?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Token requis (Authorization: Bearer )' }); return + } + const user = findByApiToken(header.slice(7)) + if (!user) { + res.status(401).json({ error: 'Token invalide' }); return + } + req.user = user + next() +} + +// ── POST /api/v1/auth/jellyfin ──────────────────────────────── +// Échange un token Jellyfin contre un token FanKarr +// Body: { jellyfinUserId, jellyfinToken } +router.post('/v1/auth/jellyfin', async (req, res) => { + const { jellyfinUserId, jellyfinToken } = req.body + if (!jellyfinUserId || !jellyfinToken) { + res.status(400).json({ error: 'jellyfinUserId et jellyfinToken requis' }); return + } + + const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken) + if (!info.valid) { + res.status(401).json({ error: info.error ?? 'Authentification Jellyfin échouée' }); return + } + + // Trouver l'utilisateur FanKarr correspondant (par username) + const users = readUsers() + const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase()) + if (!fanUser) { + logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`) + res.status(404).json({ + error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`, + }); return + } + + logger.info('public-api', `SSO Jellyfin réussi pour "${fanUser.username}"`) + res.json({ + token : fanUser.apiToken, + username: fanUser.username, + role : fanUser.role, + }) +}) + +// ── GET /api/v1/auth/me ─────────────────────────────────────── +router.get('/v1/auth/me', requireApiToken, (req, res) => { + res.json(safeUser(req.user!)) +}) + +// ── GET /api/v1/series/search?q= ───────────────────────────── +// Recherche dans le catalogue FanKai +router.get('/v1/series/search', requireApiToken, async (req, res) => { + const q = String(req.query.q ?? '').trim().toLowerCase() + try { + const [apiData, availableIds] = await Promise.all([ + fankaiGet('/series'), + readAvailable(), + ]) + const availableSet = new Set(availableIds) + const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) + + const results = all + .filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q)) + .slice(0, 50) + .map(s => ({ + id : s.id, + title : s.title, + original_title: s.original_title ?? null, + image : s.image ?? null, + available : availableSet.has(s.id), + })) + + res.json(results) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// ── POST /api/v1/requests ───────────────────────────────────── +// Créer ou mettre à jour une demande +router.post('/v1/requests', requireApiToken, (req, res) => { + const { serieId, serieName, seasons } = req.body + if (!serieId || !serieName) { + res.status(400).json({ error: 'serieId et serieName requis' }); return + } + try { + const request = upsertRequest( + req.user!.id, + Number(serieId), + String(serieName), + Array.isArray(seasons) ? seasons.map(Number) : [], + ) + res.json(request) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// ── GET /api/v1/requests ────────────────────────────────────── +// Demandes de l'utilisateur authentifié +router.get('/v1/requests', requireApiToken, (req, res) => { + res.json(getRequestsForUser(req.user!.id)) +}) + +export default router diff --git a/server/routes/requests.ts b/server/routes/requests.ts new file mode 100644 index 0000000..fc3a6ef --- /dev/null +++ b/server/routes/requests.ts @@ -0,0 +1,71 @@ +import { Router } from 'express' +import { requireAdmin } from '../auth.js' +import { + readRequests, upsertRequest, approveRequest, + rejectRequest, completeRequest, deleteRequest, + getRequestsForUser, getPendingCount, mergedSeasons, +} from '../requests.js' + +const router = Router() + +// GET /api/requests — admin voit tout, user voit les siennes +router.get('/requests', (req, res) => { + const list = req.user!.role === 'admin' + ? readRequests() + : getRequestsForUser(req.user!.id) + res.json(list) +}) + +// GET /api/requests/pending-count — pour le widget dashboard +router.get('/requests/pending-count', (req, res) => { + const count = req.user!.role === 'admin' + ? getPendingCount() + : getRequestsForUser(req.user!.id).filter(r => r.status === 'pending').length + res.json({ count }) +}) + +// POST /api/requests — créer ou mettre à jour une demande +router.post('/requests', (req, res) => { + const { serieId, serieName, seasons } = req.body + if (!serieId || !serieName) { + res.status(400).json({ error: 'serieId et serieName requis' }); return + } + try { + const request = upsertRequest( + req.user!.id, + Number(serieId), + String(serieName), + Array.isArray(seasons) ? seasons.map(Number) : [], + ) + res.json(request) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// PATCH /api/requests/:id — admin : approve / reject / complete +router.patch('/requests/:id', requireAdmin, (req, res) => { + const { action, rejectionMessage } = req.body + try { + let request + if (action === 'approve') request = approveRequest(req.params.id) + else if (action === 'reject') request = rejectRequest(req.params.id, rejectionMessage) + else if (action === 'complete') request = completeRequest(req.params.id) + else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return } + res.json(request) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// DELETE /api/requests/:id — admin seulement +router.delete('/requests/:id', requireAdmin, (req, res) => { + try { + deleteRequest(req.params.id) + res.json({ success: true }) + } catch (err) { + res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +export default router diff --git a/server/routes/users.ts b/server/routes/users.ts new file mode 100644 index 0000000..da399ef --- /dev/null +++ b/server/routes/users.ts @@ -0,0 +1,66 @@ +import { Router } from 'express' +import { + readUsers, createUser, updateUser, deleteUser, + regenerateApiToken, safeUser, +} from '../users.js' + +const router = Router() + +// GET /api/users +router.get('/users', (_req, res) => { + res.json(readUsers().map(safeUser)) +}) + +// POST /api/users +router.post('/users', (req, res) => { + const { username, password, role } = req.body + if (!username || !password) { + res.status(400).json({ error: 'username et password requis' }); return + } + if (password.length < 6) { + res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return + } + try { + const user = createUser(username, password, role === 'admin' ? 'admin' : 'user') + res.json(safeUser(user)) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// PATCH /api/users/:id +router.patch('/users/:id', (req, res) => { + const { username, password, role } = req.body + try { + const user = updateUser(req.params.id, { username, password, role }) + res.json(safeUser(user)) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// DELETE /api/users/:id +router.delete('/users/:id', (req, res) => { + // Empêche l'admin de se supprimer lui-même + if (req.user?.id === req.params.id) { + res.status(400).json({ error: 'Impossible de supprimer son propre compte' }); return + } + try { + deleteUser(req.params.id) + res.json({ success: true }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +// POST /api/users/:id/regenerate-token +router.post('/users/:id/regenerate-token', (req, res) => { + try { + const token = regenerateApiToken(req.params.id) + res.json({ apiToken: token }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + +export default router diff --git a/server/settings.ts b/server/settings.ts index 03eccc7..967521c 100644 --- a/server/settings.ts +++ b/server/settings.ts @@ -7,23 +7,29 @@ const DATA_PATH = path.join(DATA_DIR, 'settings.json') export interface Settings { mediaPath : string completePath : string - organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy' + organizeMode : 'hardlink' | 'copy' | 'move' category : string nfoSupport : boolean autoImport : boolean devMode : boolean - deleteTorrentOnMove : boolean // ← ajouter + deleteTorrentOnMove : boolean + requestAutoImport : boolean // demandes users : import auto ou validation admin + jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096) + jellyfinAdminToken : string // Token admin Jellyfin pour la sync } const defaults: Settings = { - mediaPath : '', - completePath: '', - organizeMode: 'hardlink', - category : 'fankai', - nfoSupport : false, - autoImport : true, - devMode : false, + mediaPath : '', + completePath : '', + organizeMode : 'hardlink', + category : 'fankai', + nfoSupport : false, + autoImport : true, + devMode : false, deleteTorrentOnMove: false, + requestAutoImport : false, + jellyfinUrl : '', + jellyfinAdminToken : '', } export function readSettings(): Settings { diff --git a/server/torrent-clients/alldebrid.ts b/server/torrent-clients/alldebrid.ts new file mode 100644 index 0000000..f4e572a --- /dev/null +++ b/server/torrent-clients/alldebrid.ts @@ -0,0 +1,196 @@ +import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js' +import { logger } from '../logger.js' + +const AD_BASE = 'https://api.alldebrid.com/v4' +const AD_AGENT = 'FanKarr' + +function adUrl(config: Record, path: string): string { + const sep = path.includes('?') ? '&' : '?' + return `${AD_BASE}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}` +} + +async function adFetch( + config: Record, + method: 'GET' | 'POST' | 'DELETE', + path : string, + body ?: URLSearchParams | FormData, +): Promise { + const res = await fetch(adUrl(config, path), { method, body }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`AllDebrid HTTP ${res.status} : ${text}`) + } + const json = await res.json() + if (json.status === 'error') { + throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`) + } + return json.data +} + +// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready, +// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available, +// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached +function mapStatus(statusCode: number): TorrentInfo['state'] { + switch (statusCode) { + case 4: return 'seeding' + case 1: + case 0: + case 2: + case 3: + case 10: return 'downloading' + case 5: + case 6: + case 7: + case 8: + case 9: + case 11: return 'error' + default: return 'unknown' + } +} + +function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] { + const result: TorrentFileProgress[] = [] + let idx = startIdx + for (const node of nodes) { + if (node.e) { + const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx) + result.push(...children) + idx += children.length + } else if (node.n) { + result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 }) + } + } + return result +} + +const adDriver: TorrentClientDriver = { + definition: { + id : 'alldebrid', + label : 'AllDebrid', + fields: [ + { key: 'apiKey', label: 'Clé API', type: 'password', required: true }, + { key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false }, + ], + }, + + async test(config) { + try { + const data = await adFetch(config, 'GET', '/user') + const username = data.user?.username ?? data.user?.login ?? '?' + logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`) + return { ok: true, message: `Connecté en tant que ${username}` } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('alldebrid', `Test de connexion échoué : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const data = await adFetch(config, 'GET', '/user') + const username = data.user?.username ?? data.user?.login ?? '?' + return { online: true, version: `AD — ${username}` } + } catch { + return { online: false } + } + }, + + async add(config, url, options?: DownloadOptions) { + const isMagnet = url.startsWith('magnet:') + const hash = (options?.infohash?.toLowerCase() ?? null) + || url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase() + || null + + if (hash) { + try { + const data = await adFetch(config, 'GET', '/magnet/status') + const magnets: any[] = data.magnets ?? [] + const existing = magnets.find((m: any) => m.hash?.toLowerCase() === hash) + if (existing) { + logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur AllDebrid`) + return + } + } catch {} + } + + if (isMagnet) { + const body = new URLSearchParams() + body.append('magnets[]', url) + const data = await adFetch(config, 'POST', '/magnet/upload', body) + const id = data.magnets?.[0]?.id + logger.info('alldebrid', `Magnet ajouté → ID ${id}`) + } else { + const torrentRes = await fetch(url) + if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`) + const torrentBuf = await torrentRes.arrayBuffer() + const formData = new FormData() + formData.append('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent') + const data = await adFetch(config, 'POST', '/magnet/upload/file', formData) + const id = data.magnets?.[0]?.id + logger.info('alldebrid', `.torrent uploadé → ID ${id}`) + } + }, + + async list(config, _category?) { + const data = await adFetch(config, 'GET', '/magnet/status') + const magnets: any[] = data.magnets ?? [] + const localBase = String(config.localPath ?? '').replace(/\/+$/, '') + + return magnets.map((m: any): TorrentInfo => { + const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0 + const size = typeof m.size === 'number' ? m.size : 0 + const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0 + const progress = size > 0 + ? Math.round((downloaded / size) * 100) + : (statusCode === 4 ? 100 : 0) + const save_path = localBase ? `${localBase}/${m.filename ?? m.id}` : '' + + return { + hash : String(m.hash ?? m.id ?? '').toLowerCase(), + name : String(m.filename ?? m.id ?? ''), + state : mapStatus(statusCode), + progress, + size, + downloaded, + uploaded : 0, + ratio : 0, + speed : typeof m.speed === 'number' ? m.speed : 0, + upspeed : 0, + eta : typeof m.eta === 'number' ? m.eta : -1, + save_path, + category : '', + } + }) + }, + + async remove(config, hash, _deleteFiles?) { + const data = await adFetch(config, 'GET', '/magnet/status') + const magnets: any[] = data.magnets ?? [] + const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase()) + if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`) + await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`) + logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`) + }, + + async getFiles(config, hash) { + const data = await adFetch(config, 'GET', '/magnet/status') + const magnets: any[] = data.magnets ?? [] + const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase()) + if (!target) return [] + + const detail = await adFetch(config, 'GET', `/magnet/status?id=${target.id}`) + const magnet = Array.isArray(detail.magnets) ? detail.magnets[0] : detail + + const statusCode = typeof magnet.statusCode === 'number' ? magnet.statusCode : 0 + const size = typeof magnet.size === 'number' ? magnet.size : 0 + const downloaded = typeof magnet.downloaded === 'number' ? magnet.downloaded : 0 + const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0) + + if (!Array.isArray(magnet.files)) return [] + + return flattenFiles(magnet.files).map(f => ({ ...f, progress: overallProgress })) + }, +} + +export default adDriver diff --git a/server/users.ts b/server/users.ts new file mode 100644 index 0000000..051f2d2 --- /dev/null +++ b/server/users.ts @@ -0,0 +1,152 @@ +/** + * Gestion des utilisateurs multi-user + * Migration automatique depuis l'ancien auth.json mono-user + */ + +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import bcrypt from 'bcryptjs' +import { DATA_DIR } from './config.js' +import { logger } from './logger.js' + +export type UserRole = 'admin' | 'user' + +export interface User { + id : string + username : string + passwordHash: string + role : UserRole + apiToken : string + createdAt : string +} + +const USERS_PATH = path.join(DATA_DIR, 'users.json') +const LEGACY_PATH = path.join(DATA_DIR, 'auth.json') +const SALT_ROUNDS = 10 + +// ── Migration mono → multi-user ─────────────────────────────── + +export function migrateIfNeeded(): void { + if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user + if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré + + try { + const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as { + username: string + passwordHash: string + } + const admin: User = { + id : crypto.randomUUID(), + username : legacy.username, + passwordHash: legacy.passwordHash, + role : 'admin', + apiToken : crypto.randomBytes(32).toString('hex'), + createdAt : new Date().toISOString(), + } + writeUsers([admin]) + logger.info('users', `Migration auth.json → users.json réussie pour "${legacy.username}"`) + } catch (err) { + logger.error('users', `Échec migration auth.json : ${err instanceof Error ? err.message : err}`) + } +} + +// ── Helpers fichier ─────────────────────────────────────────── + +export function readUsers(): User[] { + try { + if (!fs.existsSync(USERS_PATH)) return [] + return JSON.parse(fs.readFileSync(USERS_PATH, 'utf-8')) as User[] + } catch { return [] } +} + +export function writeUsers(users: User[]): void { + fs.mkdirSync(path.dirname(USERS_PATH), { recursive: true }) + fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2)) +} + +export function hasUsers(): boolean { return readUsers().length > 0 } +export function hasAdmin(): boolean { return readUsers().some(u => u.role === 'admin') } + +export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) } +export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) } +export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) } + +/** Sérialise sans passwordHash pour les réponses API */ +export function safeUser(user: User) { + const { passwordHash: _, ...safe } = user + return safe +} + +// ── CRUD ────────────────────────────────────────────────────── + +export function createUser(username: string, password: string, role: UserRole = 'user'): User { + const users = readUsers() + if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) { + throw new Error(`Nom d'utilisateur "${username}" déjà utilisé`) + } + const user: User = { + id : crypto.randomUUID(), + username, + passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), + role, + apiToken : crypto.randomBytes(32).toString('hex'), + createdAt : new Date().toISOString(), + } + writeUsers([...users, user]) + logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`) + return user +} + +export function updateUser( + id : string, + changes: Partial<{ username: string; password: string; role: UserRole }>, +): User { + const users = readUsers() + const idx = users.findIndex(u => u.id === id) + if (idx === -1) throw new Error('Utilisateur introuvable') + + const user = users[idx] + + if (changes.username !== undefined && changes.username !== user.username) { + if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) { + throw new Error(`Nom d'utilisateur "${changes.username}" déjà utilisé`) + } + user.username = changes.username + } + if (changes.role !== undefined) user.role = changes.role + if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS) + + writeUsers(users) + logger.info('users', `Utilisateur "${user.username}" mis à jour`) + return user +} + +export function deleteUser(id: string): void { + const users = readUsers() + const user = users.find(u => u.id === id) + if (!user) throw new Error('Utilisateur introuvable') + writeUsers(users.filter(u => u.id !== id)) + logger.info('users', `Utilisateur "${user.username}" supprimé`) +} + +export function changePassword(id: string, currentPassword: string, newPassword: string): void { + const users = readUsers() + const user = users.find(u => u.id === id) + if (!user) throw new Error('Utilisateur introuvable') + if (!bcrypt.compareSync(currentPassword, user.passwordHash)) { + throw new Error('Mot de passe actuel incorrect') + } + user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS) + writeUsers(users) + logger.info('users', `Mot de passe changé pour "${user.username}"`) +} + +export function regenerateApiToken(id: string): string { + const users = readUsers() + const user = users.find(u => u.id === id) + if (!user) throw new Error('Utilisateur introuvable') + user.apiToken = crypto.randomBytes(32).toString('hex') + writeUsers(users) + return user.apiToken +} diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index 49d7361..bbd05ba 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -31,18 +31,81 @@
- + - -