* fix(jellyfin): vérifier que le jeton Jellyfin appartient à l'utilisateur annoncé
L'échange du plugin (POST /api/v1/auth/jellyfin) validait le jeton avec
GET /Users/{jellyfinUserId}, que Jellyfin accepte avec le jeton de
n'importe quel utilisateur. Un utilisateur Jellyfin pouvait ainsi obtenir
le jeton d'API FanKarr d'un autre compte, administrateur compris.
Le jeton est maintenant vérifié avec GET /Users/Me, et son propriétaire
doit correspondre à jellyfinUserId.
* feat(auth): connexion avec un compte Jellyfin
Les utilisateurs Jellyfin se connectent à l'interface web avec leurs
identifiants Jellyfin, vérifiés par Jellyfin (POST /Users/AuthenticateByName).
- Import des utilisateurs Jellyfin au choix depuis Paramètres › Jellyfin et
API ; l'import automatique horaire devient un réglage (activé par défaut).
Un compte FanKarr du même nom est lié au lieu d'être recréé.
- Seuls les comptes importés peuvent se connecter avec Jellyfin, sauf si
« Autoriser les comptes pas encore importés » est activé (désactivé par
défaut) : le compte invité est alors créé à la première connexion, depuis
l'interface web ou le plugin.
- Interrupteur « Connexion à FanKarr avec un compte Jellyfin » (activé par
défaut).
- L'identifiant du serveur Jellyfin est enregistré et vérifié à chaque
connexion ; la session Jellyfin ouverte pour la vérification est fermée
aussitôt.
- Connexion limitée à 5 échecs en 15 minutes par nom d'utilisateur et adresse.
- Les comptes importés n'ont pas de mot de passe FanKarr : la page Profil
l'indique, l'administrateur peut toujours en définir un.
201 lines
8.8 KiB
TypeScript
201 lines
8.8 KiB
TypeScript
|
||
import { Router } from 'express'
|
||
import { findByApiToken, findByJellyfinId, importJellyfinUser, safeUser } from '../users.js'
|
||
import { validateJellyfinToken } from '../lib/jellyfin.js'
|
||
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
|
||
import { autoDownloadRequest } from './requests.js'
|
||
import { readSettings } from '../settings.js'
|
||
import { readAvailable } from '../lib/github-cache.js'
|
||
import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js'
|
||
import { logger } from '../logger.js'
|
||
import type { Request, Response, NextFunction } from 'express'
|
||
|
||
const router = Router()
|
||
|
||
// ── Jeton d'API ───────────────────────────────────────────────
|
||
|
||
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
|
||
const header = req.headers.authorization
|
||
if (!header?.startsWith('Bearer ')) {
|
||
res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer <jeton>)' }); return
|
||
}
|
||
const user = findByApiToken(header.slice(7))
|
||
if (!user) {
|
||
res.status(401).json({ error: 'Jeton d\'API invalide' }); return
|
||
}
|
||
req.user = user
|
||
next()
|
||
}
|
||
|
||
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
|
||
// Échange un jeton Jellyfin contre un jeton FanKarr
|
||
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 ?? 'Échec de l\'authentification Jellyfin' }); return
|
||
}
|
||
|
||
let fanUser = findByJellyfinId(info.userId!)
|
||
if (!fanUser && readSettings().jellyfinNewUserLogin) {
|
||
fanUser = importJellyfinUser({ Id: info.userId!, Name: info.username! })?.user
|
||
}
|
||
if (!fanUser) {
|
||
logger.warn('public-api', `Connexion via Jellyfin : « ${info.username} » n'est pas encore importé dans FanKarr`)
|
||
res.status(404).json({
|
||
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de l'importer depuis Paramètres › Jellyfin et API.`,
|
||
}); return
|
||
}
|
||
|
||
logger.info('public-api', `Connexion via Jellyfin réussie 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= ─────────────────────────────
|
||
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<number>(availableIds)
|
||
const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||
|
||
// Demandes actives de cet utilisateur, indexées par serieId
|
||
const activeRequests = getRequestsForUser(req.user!.id)
|
||
.filter(r => r.status !== 'rejected' && r.status !== 'completed')
|
||
const requestMap = new Map<number, { id: string; status: string; seasons: number[]; episodes: number[] }>()
|
||
for (const r of activeRequests) {
|
||
const me = r.requesters.find(rq => rq.userId === req.user!.id)
|
||
if (!me) continue
|
||
requestMap.set(r.serieId, {
|
||
id : r.id,
|
||
status : r.status,
|
||
seasons : me.seasons,
|
||
episodes: me.episodes ?? [],
|
||
})
|
||
}
|
||
|
||
const results = all
|
||
.filter(s => availableSet.has(s.id))
|
||
.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.poster_image ?? null,
|
||
year : s.year ?? null,
|
||
rating : s.rating?.value ?? null,
|
||
description : s.plot ?? null,
|
||
request : requestMap.get(s.id) ?? null,
|
||
}))
|
||
|
||
res.json(results)
|
||
} catch (err) {
|
||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||
}
|
||
})
|
||
|
||
// ── GET /api/v1/series/:id ────────────────────────────────────
|
||
// Relais de l'API Fankai
|
||
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
|
||
const id = Number(req.params.id)
|
||
if (isNaN(id)) { res.status(400).json({ error: 'Identifiant de série invalide' }); return }
|
||
try {
|
||
const [serieRaw, seasonsData] = await Promise.all([
|
||
fankaiGet(`/series/${id}`),
|
||
fankaiGet(`/series/${id}/seasons`),
|
||
])
|
||
const serie = normalizeSerie(serieRaw)
|
||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||
|
||
const seasonsWithEpisodes = await Promise.all(
|
||
seasons.map(async (season: any) => {
|
||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? []))
|
||
.map((ep: any) => ({
|
||
id : ep.id,
|
||
episode_number: ep.episode_number,
|
||
title : ep.title ?? null,
|
||
image : normalizeEpisode(ep).thumb_image ?? null,
|
||
}))
|
||
return {
|
||
...normalizeSeason(season),
|
||
season_number: season.season_number,
|
||
episodes,
|
||
}
|
||
})
|
||
)
|
||
|
||
res.json({
|
||
id : serie.id,
|
||
title : serie.title,
|
||
original_title: serie.original_title ?? null,
|
||
image : serie.poster_image ?? null,
|
||
seasons : seasonsWithEpisodes,
|
||
})
|
||
} catch (err) {
|
||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||
}
|
||
})
|
||
|
||
// ── POST /api/v1/requests ─────────────────────────────────────
|
||
router.post('/v1/requests', requireApiToken, (req, res) => {
|
||
const { serieId, serieName, seasons, episodes } = req.body
|
||
if (!serieId || !serieName) {
|
||
res.status(400).json({ error: 'serieId et serieName requis' }); return
|
||
}
|
||
try {
|
||
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
|
||
const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : []
|
||
|
||
let request = upsertRequest(
|
||
req.user!.id,
|
||
Number(serieId),
|
||
String(serieName),
|
||
submittedSeasons,
|
||
submittedEpisodes,
|
||
)
|
||
|
||
const { requestAutoDownloadUsers } = readSettings()
|
||
const userId = req.user!.id
|
||
const allowed = requestAutoDownloadUsers === 'all'
|
||
|| (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId))
|
||
|
||
if (allowed) {
|
||
const isNew = request.status === 'pending'
|
||
if (isNew) request = approveRequest(request.id)
|
||
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
|
||
autoDownloadRequest(request, dlOverride).catch(err =>
|
||
logger.warn('public-api', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
|
||
)
|
||
}
|
||
|
||
res.json(request)
|
||
} catch (err) {
|
||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
|
||
}
|
||
})
|
||
|
||
// ── GET /api/v1/requests ──────────────────────────────────────
|
||
router.get('/v1/requests', requireApiToken, (req, res) => {
|
||
res.json(getRequestsForUser(req.user!.id))
|
||
})
|
||
|
||
export default router
|