Compare commits

...
8 Commits
Author SHA1 Message Date
zacharyandClaude Opus 5.5 e13d43d186 feat(auth): authentification par proxy (oauth2-proxy, Authelia…)
Derrière un proxy d'authentification, FanKarr demandait une seconde
connexion. Avec PROXY_AUTH_HEADER, la session est ouverte à partir du nom
d'utilisateur transmis par le proxy.

- server/lib/proxy-auth.ts : lecture de l'en-tête, uniquement pour les
  connexions venant de PROXY_AUTH_TRUSTED_PROXIES (adresse du socket, pas
  X-Forwarded-For) ; fonction désactivée si cette liste est vide
- compte rapproché par nom (sans casse) ; création d'un compte utilisateur
  sans mot de passe avec PROXY_AUTH_AUTO_CREATE ; premier compte toujours
  créé par l'assistant
- server/auth.ts : middleware proxyAuth avant les routes, qui ouvre ou
  remplace la session ; /api/auth/status expose proxyAuth et logoutUrl
- déconnexion : redirection vers PROXY_AUTH_LOGOUT_URL si défini
- profil et gestion des utilisateurs : libellés pour les comptes sans mot
  de passe qui ne viennent pas de Jellyfin
- tests vitest du module ; fichiers de test exclus de la compilation serveur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:01:57 +02:00
zacharyandClaude Opus 5 804436ff70 feat: support d'un chemin de base (BASE_URL) pour reverse proxy
FanKarr supposait jusqu'ici être servi à la racine du domaine. La variable
d'environnement BASE_URL (ex. /fankarr) permet de l'héberger dans un
sous-dossier, sans reconstruire l'image : le préfixe est appliqué au démarrage.

- server/config.ts : BASE_URL normalisée et validée ('' = racine)
- server/index.ts  : toutes les routes montées sous BASE_URL via un Router,
  index.html servi avec <base href> et window.__BASE_URL__, redirection de /
- server/auth.ts   : cookie de session limité au chemin de base
- src/base-url.ts  : préfixage des appels fetch('/api/…') en un point unique,
  helpers withBase / absoluteUrl
- src/router       : createWebHistory(BASE_URL)
- vite.config.ts   : base './' (assets relatifs, résolus par <base href>)

Comportement inchangé lorsque BASE_URL n'est pas défini.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:57:25 +02:00
github-actions[bot] c0452661d4 chore: update version.txt to v3.25.0 2026-09-21 14:28:33 +00:00
Quentin Robert fedc3e82a4 chore(main): release 3.25.0 (#155) 2026-09-21 16:28:04 +02:00
ElPouki bd8c233e45 feat(auth): connexion avec un compte Jellyfin (#154)
* 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.
2026-09-21 16:27:04 +02:00
github-actions[bot] 8940ced4b1 chore: update version.txt to v3.24.2 2026-09-21 12:25:44 +00:00
Quentin Robert 7809a16685 chore(main): release 3.24.2 (#153) 2026-09-21 14:25:11 +02:00
ElPouki ddc6d665b6 fix(surveillance): détection des nouveaux épisodes par la date (#152) 2026-09-21 14:23:38 +02:00
32 changed files with 1245 additions and 241 deletions

No files matched your search

+11
View File
@@ -10,7 +10,18 @@
# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration # Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
# AUTH_TOKEN_EXPIRY=30d # AUTH_TOKEN_EXPIRY=30d
# Authentification par proxy (oauth2-proxy, Authelia…) : en-tête contenant le
# nom d'utilisateur, lu uniquement pour les adresses de PROXY_AUTH_TRUSTED_PROXIES
# PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username
# PROXY_AUTH_TRUSTED_PROXIES=127.0.0.1
# PROXY_AUTH_AUTO_CREATE=false
# PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out
# ── Serveur ─────────────────────────────────────────────────────────────────── # ── Serveur ───────────────────────────────────────────────────────────────────
# Port d'écoute (défaut : 9898) # Port d'écoute (défaut : 9898)
# PORT=9898 # PORT=9898
# Sous-dossier derrière un reverse proxy (défaut : racine du domaine)
# Exemple : /fankarr pour https://exemple.tld/fankarr
# BASE_URL=/fankarr
+19
View File
@@ -1,5 +1,24 @@
# Changelog # Changelog
## [3.25.0](https://github.com/Masutayunikon/FanKarr/compare/v3.24.2...v3.25.0) (2026-09-21)
### Features
* **auth:** connexion avec un compte Jellyfin ([bd8c233](https://github.com/Masutayunikon/FanKarr/commit/bd8c233e45147a8ed3187907f7531379e5d15d09))
### Bug Fixes
* **jellyfin:** vérifier que le jeton Jellyfin appartient à l'utilisateur annoncé ([bd8c233](https://github.com/Masutayunikon/FanKarr/commit/bd8c233e45147a8ed3187907f7531379e5d15d09))
## [3.24.2](https://github.com/Masutayunikon/FanKarr/compare/v3.24.1...v3.24.2) (2026-09-21)
### Bug Fixes
* **surveillance:** détection des nouveaux épisodes par la date ([#152](https://github.com/Masutayunikon/FanKarr/issues/152)) ([ddc6d66](https://github.com/Masutayunikon/FanKarr/commit/ddc6d665b6a0564f3a37a6fd211519381a813f5a))
## [3.24.1](https://github.com/Masutayunikon/FanKarr/compare/v3.24.0...v3.24.1) (2026-09-16) ## [3.24.1](https://github.com/Masutayunikon/FanKarr/compare/v3.24.0...v3.24.1) (2026-09-16)
+102 -11
View File
@@ -87,13 +87,13 @@ Inspiré de Radarr et Sonarr, pour les éditions Kai et Yabai
### Utilisateurs et Jellyfin ### Utilisateurs et Jellyfin
- Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation - Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation
- Synchronisation Jellyfin : un compte FanKarr est créé pour chaque utilisateur Jellyfin, toutes les heures ou à la demande - Import des utilisateurs Jellyfin, au choix ou automatiquement toutes les heures
- Connexion depuis le plugin Jellyfin avec le compte Jellyfin - Connexion à FanKarr avec le compte Jellyfin, depuis l'interface web ou le plugin Jellyfin
- Un jeton d'API personnel par utilisateur pour l'API publique - Un jeton d'API personnel par utilisateur pour l'API publique
### Système ### Système
- Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface - Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface
- Authentification par mot de passe, session JWT - Authentification par mot de passe FanKarr ou Jellyfin, session JWT, tentatives limitées
- Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs. - Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs.
- Installation avec Docker, Runtipi ou un binaire autonome - Installation avec Docker, Runtipi ou un binaire autonome
@@ -198,11 +198,88 @@ FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les
| `JWT_SECRET` | généré | Secret des sessions, généré dans `secret.key` (dossier de configuration) si absent | | `JWT_SECRET` | généré | Secret des sessions, généré dans `secret.key` (dossier de configuration) si absent |
| `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) | | `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
| `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie | | `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
| `BASE_URL` | non défini | Sous-dossier derrière un reverse proxy (ex. `/fankarr`) |
| `PROXY_AUTH_HEADER` | non défini | En-tête contenant le nom d'utilisateur authentifié par le proxy (ex. `Remote-User`) |
| `PROXY_AUTH_TRUSTED_PROXIES` | non défini | Adresses autorisées à envoyer cet en-tête (IP ou plages IPv4 CIDR, séparées par des virgules) |
| `PROXY_AUTH_AUTO_CREATE` | `false` | Crée un compte utilisateur à la première connexion d'un nom inconnu |
| `PROXY_AUTH_LOGOUT_URL` | non défini | Page de déconnexion du proxy, ouverte par « Se déconnecter » (ex. `/oauth2/sign_out`) |
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`. Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
--- ---
### Reverse proxy en sous-dossier
Par défaut, FanKarr est servi à la racine du domaine. Pour l'héberger sous un
sous-dossier (`https://exemple.tld/fankarr`), renseignez `BASE_URL` :
```yaml
environment:
- BASE_URL=/fankarr
```
L'application, l'API et l'API publique v1 sont alors servies sous ce préfixe
(`/fankarr/api/v1/...`), et une requête sur `/` y est redirigée. Aucune
reconstruction de l'image n'est nécessaire : le préfixe est appliqué au
démarrage.
Côté proxy, transmettez l'URL **sans réécrire le chemin** :
```nginx
location /fankarr/ {
proxy_pass http://127.0.0.1:9898; # pas de / final : le chemin est conservé
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Laissez `BASE_URL` vide pour un sous-domaine dédié (`https://fankarr.exemple.tld`).
---
### Authentification par proxy (oauth2-proxy, Authelia…)
Si un proxy d'authentification (oauth2-proxy devant Keycloak, Authelia,
Authentik…) contrôle déjà l'accès à FanKarr, FanKarr peut ouvrir la session
directement à partir du nom d'utilisateur qu'il transmet, sans second écran de
connexion :
```yaml
environment:
- PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username # en-tête posé par le proxy
- PROXY_AUTH_TRUSTED_PROXIES=172.18.0.0/16 # adresse(s) du reverse proxy
- PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out # facultatif
# - PROXY_AUTH_AUTO_CREATE=true # facultatif
```
- Le nom transmis est rapproché d'un compte FanKarr existant, sans tenir compte
de la casse. Un nom inconnu est refusé (avertissement dans les journaux), sauf
avec `PROXY_AUTH_AUTO_CREATE=true` : un compte **utilisateur** sans mot de
passe est alors créé. Les rôles se gèrent dans Paramètres › Utilisateurs.
- L'en-tête n'est lu que pour les connexions venant de
`PROXY_AUTH_TRUSTED_PROXIES` : sans cette variable, la fonction reste
désactivée. Ne publiez pas le port de FanKarr au-delà du proxy et des
applications qui utilisent l'API.
- Le premier compte administrateur se crée toujours avec l'assistant.
- La connexion par mot de passe et par Jellyfin reste possible, ainsi que les
jetons d'API.
Exemple avec NPMplus et oauth2-proxy : choisissez `oauth2proxy` dans
« Auth Request » de la location FanKarr. L'en-tête `X-User` que NPMplus
transmet contient l'identifiant interne de l'utilisateur (`sub`, un UUID avec
Keycloak) et non son nom de connexion. Transmettez plutôt ce dernier en ajoutant
dans la configuration avancée de la location :
```nginx
auth_request_set $preferred_username $upstream_http_x_auth_request_preferred_username;
proxy_set_header X-Forwarded-Preferred-Username $preferred_username;
```
puis `PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username`. oauth2-proxy doit
tourner avec `--set-xauthrequest`.
---
### Premier lancement ### Premier lancement
1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur). 1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
@@ -264,6 +341,26 @@ Le fichier garde environ les 2 000 dernières lignes. Les messages `debug` ne so
--- ---
## Comptes Jellyfin
Vos utilisateurs Jellyfin peuvent se connecter à FanKarr avec leurs identifiants Jellyfin. Il faut renseigner l'adresse du serveur et une clé API dans **Paramètres › Jellyfin et API**.
### Import des utilisateurs
Un utilisateur Jellyfin importé reçoit un compte invité, sans mot de passe FanKarr. Si un compte FanKarr porte déjà son nom, ce compte est lié à l'utilisateur Jellyfin au lieu d'être recréé. Pour importer :
- choisissez les comptes depuis **Paramètres › Jellyfin et API › Importer des utilisateurs** ;
- ou laissez l'import automatique, qui importe toutes les heures chaque utilisateur Jellyfin actif (activé par défaut).
### Connexion
Sur l'écran de connexion, un utilisateur importé saisit son nom et son mot de passe Jellyfin. FanKarr les fait vérifier par Jellyfin et ne les conserve pas. Deux réglages dans **Paramètres › Jellyfin et API** :
- **Connexion à FanKarr avec un compte Jellyfin** (activé par défaut) ;
- **Autoriser les comptes pas encore importés** (désactivé par défaut) : un compte invité est créé à la première connexion.
Un compte FanKarr qui n'a pas été importé ou lié ne peut pas s'ouvrir avec un mot de passe Jellyfin. Après 5 échecs en 15 minutes pour un même nom d'utilisateur depuis la même adresse, FanKarr bloque les tentatives jusqu'à la fin de ces 15 minutes. Un mot de passe erroné saisi dans FanKarr compte aussi comme un échec dans Jellyfin. Si le verrouillage après plusieurs échecs est activé pour un utilisateur dans Jellyfin (désactivé par défaut), son compte y est désactivé une fois la limite atteinte.
---
## Plugin Jellyfin FanKarr Search ## Plugin Jellyfin FanKarr Search
Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur : vos utilisateurs parcourent le catalogue et demandent des séries sans quitter Jellyfin. Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur : vos utilisateurs parcourent le catalogue et demandent des séries sans quitter Jellyfin.
@@ -272,13 +369,7 @@ Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur
Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)**. Les instructions d'installation sont dans le README du plugin. Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)**. Les instructions d'installation sont dans le README du plugin.
### Synchronisation des utilisateurs Les utilisateurs se connectent au plugin avec leur compte Jellyfin : le plugin échange leur session Jellyfin contre leur jeton FanKarr. Le compte doit être importé (voir [Comptes Jellyfin](#comptes-jellyfin)), sauf si les comptes pas encore importés sont autorisés.
FanKarr crée un compte invité pour chaque utilisateur Jellyfin actif qui n'en a pas encore (même nom d'utilisateur). La synchronisation se fait :
- automatiquement toutes les heures, si Jellyfin est configuré ;
- à la demande, depuis **Paramètres › Jellyfin et API › Synchroniser les utilisateurs**.
Les comptes ainsi créés reçoivent un mot de passe aléatoire. Ces utilisateurs se connectent par le plugin, qui échange leur session Jellyfin contre leur jeton FanKarr.
--- ---
@@ -306,7 +397,7 @@ Content-Type: application/json
{ "jellyfinUserId": "...", "jellyfinToken": "..." } { "jellyfinUserId": "...", "jellyfinToken": "..." }
``` ```
Renvoie `{ token, username, role }`. Le `token` s'utilise ensuite en `Bearer` pour tous les autres appels. Si aucun compte FanKarr ne porte le nom de l'utilisateur Jellyfin, la réponse est une erreur 404 : lancez la synchronisation Jellyfin. Renvoie `{ token, username, role }`. Le `token` s'utilise ensuite en `Bearer` pour tous les autres appels. FanKarr vérifie auprès de Jellyfin (`/Users/Me`) que le jeton appartient bien à `jellyfinUserId`. Si l'utilisateur Jellyfin n'est pas encore importé, la réponse est une erreur 404, sauf si les comptes pas encore importés sont autorisés : le compte est alors créé.
### Recherche ### Recherche
+1
View File
@@ -6,6 +6,7 @@ services:
- PUID=1000 # UID de votre utilisateur (id -u) - PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g) - PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Fuseau horaire - TZ=Europe/Paris # Fuseau horaire
# - BASE_URL=/fankarr # Sous-dossier derrière un reverse proxy (facultatif)
volumes: volumes:
- ./config:/config # Configuration, journaux et données - ./config:/config # Configuration, journaux et données
- /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks) - /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
+123 -22
View File
@@ -4,15 +4,18 @@ import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js' import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js' import { DATA_DIR, BASE_URL } from './config.js'
import { logger } from './logger.js' import { logger } from './logger.js'
import { import {
findByUsername, findById, findByApiToken, findByUsername, findById, findByApiToken, findByJellyfinId, importJellyfinUser,
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin, createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
type User, type User,
} from './users.js' } from './users.js'
import { readSettings } from './settings.js' import { readSettings, type Settings } from './settings.js'
import { startOnboarding } from './onboarding.js' import { startOnboarding } from './onboarding.js'
import { isJellyfinConfigured, authenticateJellyfinUser, getJellyfinServerId } from './lib/jellyfin.js'
import { loginRetryAfter, recordLoginFailure, clearLoginFailures } from './lib/login-throttle.js'
import { proxyAuthUser, PROXY_AUTH_ENABLED, PROXY_AUTH_LOGOUT_URL } from './lib/proxy-auth.js'
// ── Typage Express étendu ───────────────────────────────────── // ── Typage Express étendu ─────────────────────────────────────
declare global { declare global {
@@ -26,6 +29,10 @@ const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
// Le cookie est limité au chemin de base : deux instances derrière le même
// domaine (/fankarr, /fankarr-test) ne partagent pas leur session
const COOKIE_PATH = BASE_URL || '/'
function signToken(user: User): string { function signToken(user: User): string {
const payload = { id: user.id, username: user.username, role: user.role } const payload = { id: user.id, username: user.username, role: user.role }
return NO_EXPIRY return NO_EXPIRY
@@ -33,33 +40,66 @@ function signToken(user: User): string {
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any }) : jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
} }
function setCookieAndRespond(res: Response, user: User): void { function setSessionCookie(res: Response, user: User): string {
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
const token = signToken(user) const token = signToken(user)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax', path: COOKIE_PATH })
return token
}
function setCookieAndRespond(res: Response, user: User): void {
setSessionCookie(res, user)
res.json({ success: true, user: safeUser(user) }) res.json({ success: true, user: safeUser(user) })
} }
// Identifiant du compte de la session, ou undefined si le cookie est absent ou invalide
function sessionUserId(req: Request): string | undefined {
const token = req.cookies?.fankarr_token
if (!token) return undefined
try { return (jwt.verify(token, JWT_SECRET) as any).id } catch { return undefined }
}
// ── Authentification par proxy ────────────────────────────────
// Placé avant les routes : quand le proxy désigne un compte, la session FanKarr
// est ouverte (ou remplacée si elle appartient à un autre compte), et la suite
// du traitement voit un cookie valide comme après une connexion classique.
export function proxyAuth(req: Request, res: Response, next: NextFunction): void {
const user = proxyAuthUser(req)
if (user && sessionUserId(req) !== user.id) {
const token = setSessionCookie(res, user)
req.cookies = { ...req.cookies, fankarr_token: token }
logger.info('auth', `Connexion de « ${user.username} » via le proxy d'authentification (rôle : ${user.role})`)
}
next()
}
// ── Routes ──────────────────────────────────────────────────── // ── Routes ────────────────────────────────────────────────────
function jellyfinLoginEnabled(s: Settings): boolean {
return s.jellyfinLogin && isJellyfinConfigured(s)
}
// GET /api/auth/status // GET /api/auth/status
export function authStatus(req: Request, res: Response): void { export function authStatus(req: Request, res: Response): void {
const setup = hasUsers() const settings = readSettings()
const token = req.cookies?.fankarr_token const setup = hasUsers()
const jellyfinLogin = jellyfinLoginEnabled(settings)
const proxy = { proxyAuth: PROXY_AUTH_ENABLED, logoutUrl: PROXY_AUTH_LOGOUT_URL || null }
const token = req.cookies?.fankarr_token
if (!token) { res.json({ setup, loggedIn: false }); return } if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
try { try {
const payload = jwt.verify(token, JWT_SECRET) as any const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id) const user = findById(payload.id)
if (!user) { res.json({ setup, loggedIn: false }); return } if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
res.json({ res.json({
setup, loggedIn: true, role: user.role, username: user.username, userId: user.id, setup, loggedIn: true, jellyfinLogin, ...proxy, role: user.role, username: user.username, userId: user.id,
onboardingDone: !!readSettings().onboardingCompletedAt, onboardingDone: !!settings.onboardingCompletedAt,
tourSeen : !!user.tourSeenAt, tourSeen : !!user.tourSeenAt,
}) })
} catch { } catch {
res.json({ setup, loggedIn: false }) res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy })
} }
} }
@@ -88,25 +128,84 @@ export function authSetup(req: Request, res: Response): void {
} }
} }
// Compte FanKarr lié à l'utilisateur Jellyfin authentifié, ou undefined si refusé
async function loginWithJellyfin(username: string, password: string, ip: string): Promise<{ user?: User; unreachable?: boolean }> {
const result = await authenticateJellyfinUser(username, password, ip)
if (!result.ok) {
if (result.reason === 'unreachable') {
logger.warn('auth', `Connexion Jellyfin de « ${username} » impossible : ${result.error}`)
return { unreachable: true }
}
return {}
}
const serverId = await getJellyfinServerId()
if (!serverId) {
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : impossible de lire l'identifiant du serveur Jellyfin avec la clé API`)
return {}
}
if (result.serverId !== serverId) {
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : la réponse vient d'un autre serveur Jellyfin que celui configuré`)
return {}
}
const linked = findByJellyfinId(result.id)
if (linked) return { user: linked }
if (!readSettings().jellyfinNewUserLogin) {
logger.warn('auth', `Connexion Jellyfin de « ${result.name} » refusée : compte pas encore importé`)
return {}
}
const imported = importJellyfinUser({ Id: result.id, Name: result.name })
if (imported?.action === 'created') logger.info('auth', `Compte créé pour « ${result.name} » à sa première connexion Jellyfin`)
return { user: imported?.user }
}
// POST /api/auth/login // POST /api/auth/login
export function authLogin(req: Request, res: Response): void { export async function authLogin(req: Request, res: Response): Promise<void> {
if (!hasUsers()) { if (!hasUsers()) {
res.status(400).json({ error: 'Aucun compte configuré' }); return res.status(400).json({ error: 'Aucun compte configuré' }); return
} }
const { username, password } = req.body const { username, password } = req.body ?? {}
if (!username || !password) { if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
} }
const user = findByUsername(username) const ip = req.ip ?? ''
if (!user || !bcrypt.compareSync(password, user.passwordHash)) { const wait = loginRetryAfter(ip, username)
logger.warn('auth', `Échec de la connexion de « ${username} »`) if (wait > 0) {
res.status(401).json({ error: 'Identifiants incorrects' }); return logger.warn('auth', `Connexion de « ${username} » bloquée : trop de tentatives depuis ${ip}`)
res.set('Retry-After', String(wait))
res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${Math.ceil(wait / 60)} min.` }); return
} }
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`) const session = NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY
setCookieAndRespond(res, user) const user = findByUsername(username)
if (user && bcrypt.compareSync(password, user.passwordHash)) {
clearLoginFailures(ip, username)
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${session})`)
setCookieAndRespond(res, user); return
}
// Jellyfin n'est interrogé que pour un compte importé, ou un nouveau compte si le réglage l'autorise
const settings = readSettings()
const tryJellyfin = jellyfinLoginEnabled(settings) && (user ? !!user.jellyfinId : settings.jellyfinNewUserLogin)
if (tryJellyfin) {
const jf = await loginWithJellyfin(username, password, ip)
if (jf.user) {
clearLoginFailures(ip, username)
logger.info('auth', `Connexion de « ${jf.user.username} » via Jellyfin (rôle : ${jf.user.role}, session : ${session})`)
setCookieAndRespond(res, jf.user); return
}
if (jf.unreachable) {
res.status(503).json({ error: 'Connexion Jellyfin impossible : Jellyfin ne répond pas.' }); return
}
}
recordLoginFailure(ip, username)
logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' })
} }
// POST /api/auth/logout // POST /api/auth/logout
@@ -118,7 +217,9 @@ export function authLogout(req: Request, res: Response): void {
logger.info('auth', `Déconnexion de « ${p.username} »`) logger.info('auth', `Déconnexion de « ${p.username} »`)
} catch {} } catch {}
} }
res.clearCookie('fankarr_token') res.clearCookie('fankarr_token', { path: COOKIE_PATH })
// Cookie éventuellement posé à la racine avant l'activation de BASE_URL
if (BASE_URL) res.clearCookie('fankarr_token', { path: '/' })
res.json({ success: true }) res.json({ success: true })
} }
+18 -1
View File
@@ -56,4 +56,21 @@ const isInContainer = (): boolean => {
export const DATA_DIR = isInContainer() export const DATA_DIR = isInContainer()
? '/config' ? '/config'
: path.join(BASE_DIR, 'config'); : path.join(BASE_DIR, 'config');
// ── Chemin de base (reverse proxy en sous-dossier) ─────────────
// BASE_URL=/fankarr : l'application est servie sous https://exemple.tld/fankarr
// Valeur normalisée : '' (racine) ou '/xxx' sans slash final
function normalizeBaseUrl(raw: string | undefined): string {
const trimmed = (raw ?? '').trim()
if (!trimmed || trimmed === '/') return ''
const withLead = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const cleaned = withLead.replace(/\/+$/, '')
if (!/^(\/[A-Za-z0-9._~-]+)+$/.test(cleaned)) {
console.warn(`[config] BASE_URL « ${trimmed} » invalide (attendu : /mon-app), valeur ignorée`)
return ''
}
return cleaned
}
export const BASE_URL = normalizeBaseUrl(process.env.BASE_URL)
+65 -35
View File
@@ -3,7 +3,7 @@ import cookieParser from 'cookie-parser'
import http from 'http' import http from 'http'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js' import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin, proxyAuth } from './auth.js'
import { migrateIfNeeded } from './users.js' import { migrateIfNeeded } from './users.js'
import { migrateOnboarding } from './onboarding.js' import { migrateOnboarding } from './onboarding.js'
import { readSettings } from './settings.js' import { readSettings } from './settings.js'
@@ -17,7 +17,7 @@ import realDebridDriver from './torrent-clients/real-debrid.js'
import delugeDriver from './torrent-clients/deluge.js' import delugeDriver from './torrent-clients/deluge.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js' import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js' import { DATA_DIR, BASE_DIR, BASE_URL } from './config.js'
import { readAvailable, readInfohashMap } from './lib/github-cache.js' import { readAvailable, readInfohashMap } from './lib/github-cache.js'
import { loadCatalog } from './lib/serie-helpers.js' import { loadCatalog } from './lib/serie-helpers.js'
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js' import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
@@ -25,7 +25,7 @@ import { pushNotif } from './lib/notifs.js'
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js' import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
import { readRequests, completeRequest } from './requests.js' import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js' import { checkNfoUpdates } from './lib/nfo.js'
import { runJellyfinSync } from './routes/jellyfin.js' import { runJellyfinImport } from './routes/jellyfin.js'
import cors from 'cors'; import cors from 'cors';
import usersRouter from './routes/users.js' import usersRouter from './routes/users.js'
@@ -56,6 +56,9 @@ registerDriver(delugeDriver)
const app = express() const app = express()
const PORT = Number(process.env.PORT) || 9898 const PORT = Number(process.env.PORT) || 9898
// Toutes les routes de l'application sont montées sous BASE_URL ('' = racine)
const site = express.Router()
app.use(express.json()) app.use(express.json())
app.use(cookieParser()) app.use(cookieParser())
@@ -68,7 +71,8 @@ app.use(cors({
const PUBLIC_PATH = path.join(BASE_DIR, 'public') const PUBLIC_PATH = path.join(BASE_DIR, 'public')
if (fs.existsSync(PUBLIC_PATH)) { if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH)) // index: false — index.html passe par le fallback SPA, qui y injecte BASE_URL
site.use(express.static(PUBLIC_PATH, { index: false }))
} }
// ── Migration vers le multi-utilisateur ─────────────────────── // ── Migration vers le multi-utilisateur ───────────────────────
@@ -76,17 +80,19 @@ migrateIfNeeded()
migrateOnboarding() migrateOnboarding()
// ── Authentification ─────────────────────────────────────────── // ── Authentification ───────────────────────────────────────────
app.get ('/api/auth/status', authStatus) // Session ouverte d'après l'en-tête du proxy d'authentification (PROXY_AUTH_HEADER)
app.post('/api/auth/setup', authSetup) site.use(proxyAuth)
app.post('/api/auth/login', authLogin) site.get ('/api/auth/status', authStatus)
app.post('/api/auth/logout', authLogout) site.post('/api/auth/setup', authSetup)
app.get ('/api/auth/me', requireAuth, authMe) site.post('/api/auth/login', authLogin)
app.post('/api/auth/change-password', requireAuth, authChangePassword) site.post('/api/auth/logout', authLogout)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken) site.get ('/api/auth/me', requireAuth, authMe)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen) site.post('/api/auth/change-password', requireAuth, authChangePassword)
site.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
site.post('/api/auth/tour-seen', requireAuth, authTourSeen)
// ── API publique v1 (authentification par jeton) ────────────── // ── API publique v1 (authentification par jeton) ──────────────
app.use('/api', publicApiRouter) site.use('/api', publicApiRouter)
// ── Garde admin par préfixe ─────────────────────────────────── // ── Garde admin par préfixe ───────────────────────────────────
// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs // requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
@@ -98,7 +104,7 @@ const ADMIN_PREFIXES = [
'/rename-episode', '/rename-all', '/purge-nfo', '/rename-episode', '/rename-all', '/purge-nfo',
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug', '/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
] ]
app.use('/api', (req, res, next) => { site.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) { if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
return requireAdmin(req, res, next) return requireAdmin(req, res, next)
} }
@@ -106,31 +112,53 @@ app.use('/api', (req, res, next) => {
}) })
// ── Routes (admins et utilisateurs connectés) ────────────────── // ── Routes (admins et utilisateurs connectés) ──────────────────
app.use('/api', usersRouter) site.use('/api', usersRouter)
app.use('/api', jellyfinRouter) site.use('/api', jellyfinRouter)
app.use('/api', settingsRouter) site.use('/api', settingsRouter)
app.use('/api', torrentClientsRouter) site.use('/api', torrentClientsRouter)
app.use('/api', downloadsRouter) site.use('/api', downloadsRouter)
app.use('/api', organizeRouter) site.use('/api', organizeRouter)
app.use('/api', importRouter) site.use('/api', importRouter)
app.use('/api', systemRouter) site.use('/api', systemRouter)
app.use('/api', nfoUpdatesRouter) site.use('/api', nfoUpdatesRouter)
app.use('/api', plexRouter) site.use('/api', plexRouter)
app.use('/api', rssSyncRouter) site.use('/api', rssSyncRouter)
app.use('/api', invitesRouter) site.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter) site.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter) site.use('/api', requireAuth, requestsRouter)
// ── Application web (toutes les autres routes) ───────────────── // ── Application web (toutes les autres routes) ─────────────────
// index.html est servi avec <base href> et window.__BASE_URL__ : le client
// (routeur et appels /api) suit ainsi le préfixe du reverse proxy sans rebuild.
let indexHtmlCache: string | null = null
function renderIndexHtml(): string {
if (indexHtmlCache !== null) return indexHtmlCache
const raw = fs.readFileSync(path.join(PUBLIC_PATH, 'index.html'), 'utf-8')
const injected =
`<base href="${BASE_URL}/">\n` +
` <script>window.__BASE_URL__ = ${JSON.stringify(BASE_URL)}</script>`
indexHtmlCache = raw.replace('<head>', `<head>\n ${injected}`)
return indexHtmlCache
}
if (fs.existsSync(PUBLIC_PATH)) { if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) }) site.get('*path', (_req, res) => {
res.type('html').send(renderIndexHtml())
})
}
// ── Montage sous le chemin de base ─────────────────────────────
app.use(BASE_URL || '/', site)
if (BASE_URL) {
// Accès à la racine : redirection vers l'application
app.get('/', (_req, res) => { res.redirect(`${BASE_URL}/`) })
} }
// ── Démarrage ────────────────────────────────────────────────── // ── Démarrage ──────────────────────────────────────────────────
const server = http.createServer({ maxHeaderSize: 32768 }, app) const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => { server.listen(PORT, async () => {
const url = `http://localhost:${PORT}` const url = `http://localhost:${PORT}${BASE_URL}`
console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`) console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
logger.info('api', `Serveur démarré sur ${url}`) logger.info('api', `Serveur démarré sur ${url}`)
try { try {
@@ -266,11 +294,13 @@ server.listen(PORT, async () => {
}, 6 * 60 * 60_000) }, 6 * 60 * 60_000)
}, 60_000) }, 60_000)
// Sans effet si Jellyfin n'est pas configuré // Sans effet si Jellyfin n'est pas configuré ou si l'import automatique est coupé
const autoImportJellyfin = () => {
if (!readSettings().jellyfinAutoImport) return
runJellyfinImport().catch(err => logger.error('jellyfin', `Échec de l'import des utilisateurs Jellyfin : ${err instanceof Error ? err.message : err}`))
}
setTimeout(() => { setTimeout(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`)) autoImportJellyfin()
setInterval(() => { setInterval(autoImportJellyfin, 60 * 60_000)
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 2 * 60_000) }, 2 * 60_000)
}) })
+100 -2
View File
@@ -1,8 +1,14 @@
import { readSettings } from '../settings.js' import crypto from 'crypto'
import { readSettings, writeSettings, type Settings } from '../settings.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"' const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
const TIMEOUT_MS = 10_000
export function isJellyfinConfigured(s: Settings = readSettings()): boolean {
return !!(s.jellyfinUrl && s.jellyfinAdminToken)
}
function adminHeaders(token: string): Record<string, string> { function adminHeaders(token: string): Record<string, string> {
return { return {
@@ -24,6 +30,7 @@ function userHeaders(token: string): Record<string, string> {
export interface JellyfinUser { export interface JellyfinUser {
Id : string Id : string
Name : string Name : string
ServerId? : string
HasPassword : boolean HasPassword : boolean
Policy : { IsAdministrator: boolean; IsDisabled: boolean } Policy : { IsAdministrator: boolean; IsDisabled: boolean }
} }
@@ -55,6 +62,82 @@ export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
return res.json() return res.json()
} }
// ── Identifiant du serveur ────────────────────────────────────
export async function getJellyfinServerId(): Promise<string | null> {
const { jellyfinUrl, jellyfinAdminToken, jellyfinServerId } = readSettings()
if (jellyfinServerId) return jellyfinServerId
if (!jellyfinUrl || !jellyfinAdminToken) return null
try {
const res = await fetch(`${jellyfinUrl.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(jellyfinAdminToken),
signal : AbortSignal.timeout(TIMEOUT_MS),
})
if (!res.ok) return null
const data = await res.json()
if (!data?.Id) return null
const id = normalizeJellyfinId(data.Id)
writeSettings({ jellyfinServerId: id })
logger.info('jellyfin', `Identifiant du serveur Jellyfin enregistré (${id.slice(0, 8)}…)`)
return id
} catch {
return null
}
}
// ── Authentification par nom et mot de passe ──────────────────
export type JellyfinAuthResult =
| { ok: true; id: string; name: string; serverId: string }
| { ok: false; reason: 'credentials' | 'unreachable'; error?: string }
export async function authenticateJellyfinUser(username: string, password: string, clientIp?: string): Promise<JellyfinAuthResult> {
const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { ok: false, reason: 'unreachable', error: 'Jellyfin non configuré' }
const base = jellyfinUrl.replace(/\/$/, '')
const deviceId = `fankarr-web-${crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16)}`
const client = `MediaBrowser Client="FanKarr", Device="FanKarr (connexion web)", DeviceId="${deviceId}", Version="1.0"`
let data: any
try {
const res = await fetch(`${base}/Users/AuthenticateByName`, {
method : 'POST',
headers: {
'Authorization': client,
'Content-Type' : 'application/json',
...(clientIp ? { 'X-Forwarded-For': clientIp.replace(/^::ffff:/, '') } : {}),
},
body : JSON.stringify({ Username: username, Pw: password }),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
if (res.status === 401 || res.status === 403) return { ok: false, reason: 'credentials' }
if (!res.ok) return { ok: false, reason: 'unreachable', error: `Jellyfin a répondu HTTP ${res.status}` }
data = await res.json()
} catch (err) {
return { ok: false, reason: 'unreachable', error: err instanceof Error ? err.message : 'Erreur réseau' }
}
if (!data?.User?.Id) return { ok: false, reason: 'unreachable', error: 'Réponse Jellyfin inattendue' }
// Session fermée aussitôt : FanKarr ne garde aucun jeton Jellyfin
if (data.AccessToken) {
fetch(`${base}/Sessions/Logout`, {
method : 'POST',
headers: { 'Authorization': `${client}, Token="${data.AccessToken}"` },
signal : AbortSignal.timeout(TIMEOUT_MS),
}).catch(() => {})
}
return {
ok : true,
id : normalizeJellyfinId(data.User.Id),
name : data.User.Name,
serverId: normalizeJellyfinId(data.ServerId ?? data.User.ServerId ?? ''),
}
}
// ── Validation d'un jeton utilisateur Jellyfin ──────────────── // ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo { export interface JellyfinTokenInfo {
@@ -64,21 +147,36 @@ export interface JellyfinTokenInfo {
error? : string error? : string
} }
export function normalizeJellyfinId(id: string): string {
return String(id).replace(/-/g, '').toLowerCase()
}
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> { export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
const { jellyfinUrl } = readSettings() const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' } if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
try { try {
const base = jellyfinUrl.replace(/\/$/, '') const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users/${jellyfinUserId}`, { // /Users/{id}
const res = await fetch(`${base}/Users/Me`, {
headers: userHeaders(jellyfinToken), headers: userHeaders(jellyfinToken),
}) })
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' } if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` } if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json() const user: JellyfinUser = await res.json()
if (normalizeJellyfinId(user.Id) !== normalizeJellyfinId(jellyfinUserId)) {
logger.warn('jellyfin', `Jeton de « ${user.Name} » présenté pour un autre utilisateur Jellyfin (${String(jellyfinUserId).slice(0, 8)}…)`)
return { valid: false, error: 'Ce jeton Jellyfin appartient à un autre utilisateur' }
}
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' } if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
const serverId = await getJellyfinServerId()
if (serverId && user.ServerId && normalizeJellyfinId(user.ServerId) !== serverId) {
logger.error('jellyfin', `Jeton de « ${user.Name} » refusé : il vient d'un autre serveur Jellyfin que celui configuré`)
return { valid: false, error: 'Ce jeton vient d\'un autre serveur Jellyfin' }
}
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`) logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name } return { valid: true, userId: user.Id, username: user.Name }
} catch (err) { } catch (err) {
+37
View File
@@ -0,0 +1,37 @@
const MAX_FAILURES = 5
const WINDOW_MS = 15 * 60_000
const failures = new Map<string, { count: number; firstAt: number }>()
function keyOf(ip: string, username: string): string {
return `${ip}|${username.toLowerCase()}`
}
function sweep(now: number): void {
for (const [key, entry] of failures) {
if (now - entry.firstAt >= WINDOW_MS) failures.delete(key)
}
}
// Secondes avant une nouvelle tentative, 0 si la connexion est permise
export function loginRetryAfter(ip: string, username: string): number {
const key = keyOf(ip, username)
const entry = failures.get(key)
if (!entry) return 0
const elapsed = Date.now() - entry.firstAt
if (elapsed >= WINDOW_MS) { failures.delete(key); return 0 }
return entry.count >= MAX_FAILURES ? Math.ceil((WINDOW_MS - elapsed) / 1000) : 0
}
export function recordLoginFailure(ip: string, username: string): void {
const now = Date.now()
if (failures.size > 10_000) sweep(now)
const key = keyOf(ip, username)
const entry = failures.get(key)
if (!entry || now - entry.firstAt >= WINDOW_MS) failures.set(key, { count: 1, firstAt: now })
else entry.count++
}
export function clearLoginFailures(ip: string, username: string): void {
failures.delete(keyOf(ip, username))
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Request } from 'express'
const users = vi.hoisted(() => ({
list: [] as { id: string; username: string; role: string }[],
}))
vi.mock('../logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('../users.js', () => ({
hasUsers : () => users.list.length > 0,
findByUsername: (name: string) => users.list.find(u => u.username.toLowerCase() === name.toLowerCase()),
createUser : (username: string, _password: string, role: string) => {
const user = { id: `id-${username}`, username, role }
users.list.push(user)
return user
},
}))
const ENV_KEYS = ['PROXY_AUTH_HEADER', 'PROXY_AUTH_TRUSTED_PROXIES', 'PROXY_AUTH_AUTO_CREATE', 'PROXY_AUTH_LOGOUT_URL']
async function load(env: Record<string, string>) {
for (const key of ENV_KEYS) delete process.env[key]
Object.assign(process.env, env)
vi.resetModules()
return import('./proxy-auth.js')
}
function request(remoteAddress: string, headers: Record<string, string> = {}): Request {
return { socket: { remoteAddress }, headers } as unknown as Request
}
beforeEach(() => {
users.list = [{ id: 'id-alice', username: 'Alice', role: 'admin' }]
})
afterEach(() => {
for (const key of ENV_KEYS) delete process.env[key]
})
describe('adresses de confiance', () => {
it('reconnaît une plage IPv4, une adresse exacte et une IPv4 vue par un socket IPv6', async () => {
const { parseTrustedProxies, isTrustedAddress } = await load({})
const trusted = parseTrustedProxies('172.18.0.0/16, 10.0.0.5, fd00::1')
expect(isTrustedAddress('172.18.3.4', trusted)).toBe(true)
expect(isTrustedAddress('::ffff:172.18.0.2', trusted)).toBe(true)
expect(isTrustedAddress('10.0.0.5', trusted)).toBe(true)
expect(isTrustedAddress('FD00::1', trusted)).toBe(true)
expect(isTrustedAddress('172.19.0.1', trusted)).toBe(false)
expect(isTrustedAddress('10.0.0.6', trusted)).toBe(false)
expect(isTrustedAddress(undefined, trusted)).toBe(false)
})
it('ignore les entrées invalides', async () => {
const { parseTrustedProxies } = await load({})
expect(parseTrustedProxies('pas-une-ip, 10.0.0.0/40, fd00::/64')).toHaveLength(0)
})
})
describe('proxyAuthUser', () => {
const enabled = { PROXY_AUTH_HEADER: 'X-User', PROXY_AUTH_TRUSTED_PROXIES: '172.18.0.0/16' }
it('est désactivé sans adresse de confiance, même si l\'en-tête est défini', async () => {
const mod = await load({ PROXY_AUTH_HEADER: 'X-User' })
expect(mod.PROXY_AUTH_ENABLED).toBe(false)
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))).toBeUndefined()
})
it('renvoie le compte désigné par un proxy de confiance, sans tenir compte de la casse', async () => {
const mod = await load(enabled)
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))?.id).toBe('id-alice')
})
it('ignore l\'en-tête venant d\'une adresse non déclarée', async () => {
const mod = await load(enabled)
expect(mod.proxyAuthUser(request('192.168.1.50', { 'x-user': 'alice' }))).toBeUndefined()
})
it('ne fait rien sans en-tête', async () => {
const mod = await load(enabled)
expect(mod.proxyAuthUser(request('172.18.0.2'))).toBeUndefined()
})
it('ne crée pas de compte inconnu par défaut', async () => {
const mod = await load(enabled)
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
expect(users.list).toHaveLength(1)
})
it('crée un compte utilisateur avec PROXY_AUTH_AUTO_CREATE', async () => {
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
const user = mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))
expect(user).toMatchObject({ username: 'bob', role: 'user' })
})
it('laisse l\'assistant créer le premier compte', async () => {
users.list = []
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
expect(users.list).toHaveLength(0)
})
it('n\'expose l\'adresse de déconnexion que si l\'authentification par proxy est active', async () => {
expect((await load({ PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('')
expect((await load({ ...enabled, PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('/oauth2/sign_out')
})
})
+118
View File
@@ -0,0 +1,118 @@
import net from 'net'
import type { Request } from 'express'
import { logger } from '../logger.js'
import { findByUsername, createUser, hasUsers, type User } from '../users.js'
// ── Authentification déléguée à un proxy (oauth2-proxy, Authelia…) ──
// Le proxy authentifie l'utilisateur et transmet son nom dans un en-tête.
// L'en-tête n'est lu que pour les connexions venant d'une adresse déclarée
// dans PROXY_AUTH_TRUSTED_PROXIES : sans cela, n'importe quel client joignant
// directement le port de FanKarr pourrait se faire passer pour un autre.
function parseBool(raw: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes((raw ?? '').trim().toLowerCase())
}
type Matcher = (ip: string) => boolean
function ipv4ToInt(ip: string): number {
return ip.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0
}
// '::ffff:172.18.0.5' (IPv4 vue par un socket IPv6) → '172.18.0.5'
export function normalizeIp(ip: string): string {
const trimmed = ip.trim()
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed)
return mapped ? mapped[1] : trimmed.toLowerCase()
}
// Entrée acceptée : adresse IPv4/IPv6 exacte ou plage IPv4 en notation CIDR
function parseTrustedEntry(entry: string): Matcher | null {
const [addr, bitsRaw] = entry.split('/')
const ip = normalizeIp(addr)
if (bitsRaw === undefined) {
return net.isIP(ip) ? (candidate) => candidate === ip : null
}
const bits = Number(bitsRaw)
if (net.isIPv4(ip) && Number.isInteger(bits) && bits >= 0 && bits <= 32) {
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
const base = ipv4ToInt(ip) & mask
return (candidate) => net.isIPv4(candidate) && (ipv4ToInt(candidate) & mask) === base
}
return null
}
export function parseTrustedProxies(raw: string | undefined): Matcher[] {
const matchers: Matcher[] = []
for (const entry of (raw ?? '').split(',').map(e => e.trim()).filter(Boolean)) {
const matcher = parseTrustedEntry(entry)
if (matcher) matchers.push(matcher)
else logger.warn('auth', `PROXY_AUTH_TRUSTED_PROXIES : « ${entry} » ignoré (attendu : adresse IP ou plage IPv4 CIDR)`)
}
return matchers
}
export function isTrustedAddress(remoteAddress: string | undefined, matchers: Matcher[]): boolean {
if (!remoteAddress) return false
const ip = normalizeIp(remoteAddress)
return matchers.some(match => match(ip))
}
const HEADER = (process.env.PROXY_AUTH_HEADER ?? '').trim().toLowerCase()
const TRUSTED = parseTrustedProxies(process.env.PROXY_AUTH_TRUSTED_PROXIES)
const AUTO_CREATE = parseBool(process.env.PROXY_AUTH_AUTO_CREATE)
const LOGOUT_URL = (process.env.PROXY_AUTH_LOGOUT_URL ?? '').trim()
export const PROXY_AUTH_ENABLED = (() => {
if (!HEADER) return false
if (TRUSTED.length === 0) {
logger.error('auth', 'PROXY_AUTH_HEADER est défini mais PROXY_AUTH_TRUSTED_PROXIES est vide : authentification par proxy désactivée')
return false
}
logger.info('auth', `Authentification par proxy activée (en-tête ${HEADER}${AUTO_CREATE ? ', création automatique des comptes' : ''})`)
return true
})()
export const PROXY_AUTH_LOGOUT_URL = PROXY_AUTH_ENABLED ? LOGOUT_URL : ''
// Avertissements déjà émis, pour ne pas les répéter à chaque requête
const warned = new Set<string>()
function warnOnce(key: string, message: string): void {
if (warned.has(key) || warned.size > 1_000) return
warned.add(key)
logger.warn('auth', message)
}
// Compte FanKarr désigné par le proxy, ou undefined si la requête n'en vient pas
export function proxyAuthUser(req: Request): User | undefined {
if (!PROXY_AUTH_ENABLED) return undefined
const raw = req.headers[HEADER]
const username = (Array.isArray(raw) ? raw[0] : raw)?.trim()
if (!username) return undefined
// Adresse du socket et non req.ip : X-Forwarded-For est fourni par le client
if (!isTrustedAddress(req.socket.remoteAddress, TRUSTED)) {
warnOnce(`ip|${req.socket.remoteAddress}`, `En-tête ${HEADER} ignoré : la connexion vient de ${req.socket.remoteAddress}, absent de PROXY_AUTH_TRUSTED_PROXIES`)
return undefined
}
// Premier lancement : le compte admin est créé par l'assistant
if (!hasUsers()) return undefined
const user = findByUsername(username)
if (user) return user
if (!AUTO_CREATE) {
warnOnce(`user|${username.toLowerCase()}`, `Utilisateur « ${username} » transmis par le proxy inconnu de FanKarr : créez le compte ou activez PROXY_AUTH_AUTO_CREATE`)
return undefined
}
try {
// Sans mot de passe FanKarr, comme un compte importé de Jellyfin : la connexion passe par le proxy
return createUser(username, '', 'user')
} catch (err) {
logger.error('auth', `Création du compte « ${username} » transmis par le proxy impossible : ${err instanceof Error ? err.message : err}`)
return undefined
}
}
+76 -57
View File
@@ -1,4 +1,4 @@
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */ /** Surveillance : envoie au client les épisodes dont le torrent est publié après activation (toutes les 6 h). */
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
@@ -136,6 +136,44 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
return ids return ids
} }
// ── Torrents publiés ──────────────────────────────────────────
// pub_date nyaa : « AAAA-MM-JJ HH:MM » en UTC
function pubTime(t: any): number {
return typeof t.pub_date === 'string' ? Date.parse(`${t.pub_date.replace(' ', 'T')}Z`) : NaN
}
interface ReleaseEpisode { key: string; name: string; pathEntry: any }
interface Release { torrent: any; episodes: ReleaseEpisode[] }
// Torrents Fankai de la série et épisodes qu'ils contiennent
function listReleases(sd: any): Release[] {
const byHash = new Map<string, Release>()
const add = (t: any) => {
const hash = t.infohash?.toLowerCase()
if (t.fankai && hash && (t.torrent_url || t.magnet) && !byHash.has(hash)) byHash.set(hash, { torrent: t, episodes: [] })
}
for (const t of sd.torrents ?? []) add(t)
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) add(t)
for (const ep of season.episodes ?? []) for (const t of ep.torrents ?? []) add(t)
}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const key = `${season.season_number}:${ep.episode_number}`
const name = `S${String(season.season_number).padStart(2, '0')}E${String(ep.episode_number).padStart(2, '0')}`
const hashes = new Set<string>([...(ep.paths ?? []), ...(ep.torrents ?? [])].map((x: any) => x?.infohash?.toLowerCase()).filter(Boolean))
for (const hash of hashes) {
const release = byHash.get(hash)
if (!release || release.episodes.some(e => e.key === key)) continue
release.episodes.push({ key, name, pathEntry: (ep.paths ?? []).find((p: any) => p?.infohash?.toLowerCase() === hash) ?? null })
}
}
}
return [...byHash.values()]
}
// ── Vérification des séries surveillées ─────────────────────── // ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> { export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
@@ -172,76 +210,56 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
continue continue
} }
const title = sd.title ?? sd.show_title ?? String(serieId) const title = sd.title ?? sd.show_title ?? String(serieId)
const activatedAt = new Date(syncedEntry.addedAt) const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized) const organizedIds = getOrganizedEpisodeIds(sd, organized)
const releases = listReleases(sd)
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`) logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
const candidateIds = new Set<number>() // Clé saison:épisode : les variantes x264/x265 d'un même épisode comptent pour un
for (const season of sd.seasons ?? []) { const organizedKeys = new Set<string>()
for (const ep of season.episodes ?? []) { for (const season of sd.seasons ?? [])
if (!ep.date_added) continue for (const ep of season.episodes ?? [])
const epDate = new Date(ep.date_added) if (organizedIds.has(ep.id)) organizedKeys.add(`${season.season_number}:${ep.episode_number}`)
if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) continue
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue
candidateIds.add(ep.id)
}
}
if (candidateIds.size === 0) { // Nouvel épisode
const firstPub = new Map<string, number>()
for (const { torrent, episodes } of releases) {
const time = pubTime(torrent)
for (const { key } of episodes)
firstPub.set(key, Math.min(firstPub.get(key) ?? Infinity, Number.isNaN(time) ? -Infinity : time))
}
const candidates = new Set([...firstPub].filter(([key, time]) => time > activatedAt.getTime() && !organizedKeys.has(key)).map(([key]) => key))
if (candidates.size === 0) {
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`) logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
continue continue
} }
logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`) logger.info('rss-sync', `« ${title} » : ${candidates.size} épisode(s) à télécharger`)
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = [] const toDownload: { url: string; magnet: string | null; file_index: number | null; file_path: string | null; infohash: string; label: string }[] = []
const coveredIds = new Set<number>() const covered = new Set<string>()
const missing = (r: Release) => r.episodes.filter(e => candidates.has(e.key) && !covered.has(e.key))
for (const season of sd.seasons ?? []) { for (const release of [...releases].sort((a, b) => missing(b).length - missing(a).length)) {
for (const t of season.torrents ?? []) { const eps = missing(release)
if (!t.fankai) continue if (eps.length === 0) continue
const url = t.torrent_url ?? t.magnet for (const e of eps) covered.add(e.key)
if (!url) continue
const hash = t.infohash?.toLowerCase()
if (hash && activeHashes.has(hash)) { skipped++; continue }
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) => const hash = release.torrent.infohash.toLowerCase()
candidateIds.has(ep.id) && !coveredIds.has(ep.id) if (activeHashes.has(hash)) { skipped++; continue }
)
if (!coversSomeCandidate) { skipped++; continue }
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` }) const single = eps.length === 1 ? eps[0] : null
for (const ep of season.episodes ?? []) coveredIds.add(ep.id) toDownload.push({
} url : release.torrent.torrent_url ?? release.torrent.magnet,
} magnet : release.torrent.magnet ?? null,
infohash : hash,
for (const season of sd.seasons ?? []) { file_index: single?.pathEntry?.file_index ?? null,
for (const ep of season.episodes ?? []) { file_path : single?.pathEntry?.path ?? null,
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue label : `« ${title} » ${single ? single.name : `(${eps.length} épisodes)`}`,
})
for (const t of ep.torrents ?? []) {
if (!t.fankai) continue
const url = t.torrent_url ?? t.magnet
if (!url) continue
const hash = t.infohash?.toLowerCase()
if (hash && activeHashes.has(hash)) { skipped++; continue }
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
toDownload.push({
url,
infohash : hash ?? null,
file_index: pathEntry?.file_index ?? null,
file_path : pathEntry?.path ?? null,
label : `S${season.season_number}E${ep.episode_number} ${title}`,
})
coveredIds.add(ep.id)
break
}
}
} }
for (const dl of toDownload) { for (const dl of toDownload) {
@@ -250,6 +268,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
file_index: dl.file_index, file_index: dl.file_index,
file_path : dl.file_path, file_path : dl.file_path,
infohash : dl.infohash, infohash : dl.infohash,
magnet : dl.magnet,
}) })
if (results.some((r: any) => r.ok)) { if (results.some((r: any) => r.ok)) {
logger.info('rss-sync', `Envoyé au client : ${dl.label}`) logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
+85 -35
View File
@@ -1,27 +1,39 @@
import { Router } from 'express' import { Router } from 'express'
import { readSettings, writeSettings } from '../settings.js' import { readSettings, writeSettings, type Settings } from '../settings.js'
import { readUsers, createUser, safeUser } from '../users.js' import { readUsers, importJellyfinUser } from '../users.js'
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js' import { testJellyfinConnection, fetchJellyfinUsers, getJellyfinServerId, normalizeJellyfinId, type JellyfinUser } from '../lib/jellyfin.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
const router = Router() const router = Router()
function settingsView(s: Settings) {
return {
jellyfinUrl : s.jellyfinUrl,
hasToken : !!s.jellyfinAdminToken,
jellyfinLogin : s.jellyfinLogin,
jellyfinNewUserLogin: s.jellyfinNewUserLogin,
jellyfinAutoImport : s.jellyfinAutoImport,
}
}
router.get('/jellyfin/settings', (_req, res) => { router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings() res.json(settingsView(readSettings()))
res.json({
jellyfinUrl,
// Clé API jamais renvoyée : seulement sa présence
hasToken: !!jellyfinAdminToken,
})
}) })
router.post('/jellyfin/settings', (req, res) => { router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body const { jellyfinUrl, jellyfinAdminToken } = req.body ?? {}
const update: Record<string, string> = {} const current = readSettings()
const update: Partial<Settings> = {}
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim() if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim() if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
const updated = writeSettings(update) for (const key of ['jellyfinLogin', 'jellyfinNewUserLogin', 'jellyfinAutoImport'] as const) {
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken }) if (typeof req.body?.[key] === 'boolean') update[key] = req.body[key]
}
if ((update.jellyfinUrl !== undefined && update.jellyfinUrl !== current.jellyfinUrl) ||
(update.jellyfinAdminToken !== undefined && update.jellyfinAdminToken !== current.jellyfinAdminToken)) {
update.jellyfinServerId = ''
}
res.json(settingsView(writeSettings(update)))
}) })
router.post('/jellyfin/test', async (req, res) => { router.post('/jellyfin/test', async (req, res) => {
@@ -45,40 +57,78 @@ router.post('/jellyfin/test', async (req, res) => {
res.json(result) res.json(result)
}) })
export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> { // ── Import des utilisateurs ───────────────────────────────────
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] }
const jellyfinUsers = await fetchJellyfinUsers() export type JellyfinImportResult = { created: number; linked: number; skipped: number; users: string[] }
const fankarrUsers = readUsers()
const results = { created: 0, skipped: 0, users: [] as string[] } function importJellyfinUsers(jellyfinUsers: JellyfinUser[]): JellyfinImportResult {
const results: JellyfinImportResult = { created: 0, linked: 0, skipped: 0, users: [] }
for (const jUser of jellyfinUsers) { for (const jUser of jellyfinUsers) {
if (jUser.Policy?.IsDisabled) { results.skipped++; continue } if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
const imported = importJellyfinUser(jUser)
const exists = fankarrUsers.some(u => if (!imported || imported.action === 'existing') { results.skipped++; continue }
u.username.toLowerCase() === jUser.Name.toLowerCase() results[imported.action]++
) results.users.push(imported.user.username)
if (exists) { results.skipped++; continue }
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} » (synchronisation Jellyfin)`)
} }
logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`) logger.info('jellyfin', `Import Jellyfin : ${results.created} compte(s) créé(s), ${results.linked} lié(s), ${results.skipped} ignoré(s) (déjà importé, désactivé ou nom déjà pris)`)
return results return results
} }
export async function runJellyfinImport(): Promise<JellyfinImportResult> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, linked: 0, skipped: 0, users: [] }
await getJellyfinServerId()
return importJellyfinUsers(await fetchJellyfinUsers())
}
router.get('/jellyfin/users', async (_req, res) => {
try {
const jellyfinUsers = await fetchJellyfinUsers()
const users = readUsers()
res.json(jellyfinUsers.map(j => {
const id = normalizeJellyfinId(j.Id)
const linked = users.find(u => u.jellyfinId === id)
const sameName = users.find(u => u.username.toLowerCase() === j.Name.toLowerCase())
const status = linked ? 'imported'
: j.Policy?.IsDisabled ? 'disabled'
: sameName?.jellyfinId ? 'conflict'
: sameName ? 'match'
: 'new'
return {
id,
name : j.Name,
status,
fankarrUsername: (linked ?? sameName)?.username ?? null,
}
}))
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : 'Impossible de récupérer les utilisateurs Jellyfin' })
}
})
router.post('/jellyfin/import', async (req, res) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((id: unknown) => normalizeJellyfinId(String(id))) : []
if (ids.length === 0) {
res.status(400).json({ error: 'Aucun utilisateur sélectionné' }); return
}
try {
await getJellyfinServerId()
const selected = (await fetchJellyfinUsers()).filter(j => ids.includes(normalizeJellyfinId(j.Id)))
res.json(importJellyfinUsers(selected))
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
}
})
// Import de tous les comptes actifs (assistant d'installation)
router.post('/jellyfin/sync', async (_req, res) => { router.post('/jellyfin/sync', async (_req, res) => {
try { try {
const results = await runJellyfinSync() res.json(await runJellyfinImport())
res.json(results)
} catch (err) { } catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
} }
}) })
+7 -6
View File
@@ -1,8 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { findByApiToken } from '../users.js' import { findByApiToken, findByJellyfinId, importJellyfinUser, safeUser } from '../users.js'
import { validateJellyfinToken } from '../lib/jellyfin.js' import { validateJellyfinToken } from '../lib/jellyfin.js'
import { readUsers, safeUser } from '../users.js'
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js' import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
import { autoDownloadRequest } from './requests.js' import { autoDownloadRequest } from './requests.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
@@ -41,12 +40,14 @@ router.post('/v1/auth/jellyfin', async (req, res) => {
res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
} }
const users = readUsers() let fanUser = findByJellyfinId(info.userId!)
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase()) if (!fanUser && readSettings().jellyfinNewUserLogin) {
fanUser = importJellyfinUser({ Id: info.userId!, Name: info.username! })?.user
}
if (!fanUser) { if (!fanUser) {
logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`) logger.warn('public-api', `Connexion via Jellyfin : « ${info.username} » n'est pas encore importé dans FanKarr`)
res.status(404).json({ res.status(404).json({
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`, error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de l'importer depuis Paramètres › Jellyfin et API.`,
}); return }); return
} }
+11 -3
View File
@@ -15,8 +15,12 @@ export interface Settings {
deleteTorrentOnMove : boolean deleteTorrentOnMove : boolean
requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096) jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation jellyfinAdminToken : string // Clé API Jellyfin pour l'import des utilisateurs
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 ») jellyfinLogin : boolean // Connexion à l'interface web avec un compte Jellyfin importé
jellyfinNewUserLogin: boolean // Connexion des comptes Jellyfin pas encore importés (compte créé à la première connexion)
jellyfinAutoImport : boolean // Import horaire de tous les utilisateurs Jellyfin actifs
jellyfinServerId : string // Identifiant du serveur Jellyfin, comparé à chaque connexion
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null onboardingCompletedAt: string | null
@@ -34,7 +38,11 @@ const defaults: Settings = {
requestAutoDownloadUsers: [], requestAutoDownloadUsers: [],
jellyfinUrl : '', jellyfinUrl : '',
jellyfinAdminToken : '', jellyfinAdminToken : '',
englishDirectory : false, jellyfinLogin : true,
jellyfinNewUserLogin: false,
jellyfinAutoImport : true,
jellyfinServerId : '',
englishDirectory : false,
autoUnimportMissing: false, autoUnimportMissing: false,
onboardingStep : null, onboardingStep : null,
onboardingCompletedAt: null, onboardingCompletedAt: null,
+42 -4
View File
@@ -5,6 +5,7 @@ import crypto from 'crypto'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
import { logger } from './logger.js' import { logger } from './logger.js'
import { normalizeJellyfinId } from './lib/jellyfin.js'
export type UserRole = 'admin' | 'user' export type UserRole = 'admin' | 'user'
@@ -17,6 +18,7 @@ export interface User {
createdAt : string createdAt : string
tourSeenAt? : string | null tourSeenAt? : string | null
lastLoginAt?: string | null lastLoginAt?: string | null
jellyfinId? : string | null
} }
const USERS_PATH = path.join(DATA_DIR, 'users.json') const USERS_PATH = path.join(DATA_DIR, 'users.json')
@@ -69,15 +71,20 @@ export function hasAdmin(): boolean { return readUsers().some(u => u.role === '
export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) } 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 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) } export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
export function findByJellyfinId(id: string): User | undefined {
const jellyfinId = normalizeJellyfinId(id)
return readUsers().find(u => !!u.jellyfinId && u.jellyfinId === jellyfinId)
}
export function safeUser(user: User) { export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user const { passwordHash, ...safe } = user
return safe return { ...safe, hasPassword: !!passwordHash }
} }
// ── Gestion des comptes ─────────────────────────────────────── // ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User { // Mot de passe vide : compte sans mot de passe FanKarr (import Jellyfin)
export function createUser(username: string, password: string, role: UserRole = 'user', jellyfinId: string | null = null): User {
const users = readUsers() const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) { if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`) throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
@@ -85,17 +92,43 @@ export function createUser(username: string, password: string, role: UserRole =
const user: User = { const user: User = {
id : crypto.randomUUID(), id : crypto.randomUUID(),
username, username,
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), passwordHash: password ? bcrypt.hashSync(password, SALT_ROUNDS) : '',
role, role,
apiToken : crypto.randomBytes(32).toString('hex'), apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(), createdAt : new Date().toISOString(),
tourSeenAt : null, tourSeenAt : null,
jellyfinId : jellyfinId ? normalizeJellyfinId(jellyfinId) : null,
} }
writeUsers([...users, user]) writeUsers([...users, user])
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`) logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user return user
} }
export type JellyfinImportAction = 'existing' | 'linked' | 'created'
// null : nom déjà pris par un compte lié à un autre utilisateur Jellyfin
export function importJellyfinUser(jUser: { Id: string; Name: string }): { user: User; action: JellyfinImportAction } | null {
const users = readUsers()
const jellyfinId = normalizeJellyfinId(jUser.Id)
const linked = users.find(u => u.jellyfinId === jellyfinId)
if (linked) return { user: linked, action: 'existing' }
const sameName = users.find(u => u.username.toLowerCase() === jUser.Name.toLowerCase())
if (sameName?.jellyfinId) {
logger.warn('users', `Import Jellyfin de « ${jUser.Name} » impossible : le compte FanKarr de ce nom est lié à un autre utilisateur Jellyfin`)
return null
}
if (sameName) {
sameName.jellyfinId = jellyfinId
writeUsers(users)
logger.info('users', `Compte « ${sameName.username} » lié à l'utilisateur Jellyfin « ${jUser.Name} »`)
return { user: sameName, action: 'linked' }
}
return { user: createUser(jUser.Name, '', 'user', jellyfinId), action: 'created' }
}
export function updateUser( export function updateUser(
id : string, id : string,
changes: Partial<{ username: string; password: string; role: UserRole }>, changes: Partial<{ username: string; password: string; role: UserRole }>,
@@ -132,6 +165,11 @@ export function changePassword(id: string, currentPassword: string, newPassword:
const users = readUsers() const users = readUsers()
const user = users.find(u => u.id === id) const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable') if (!user) throw new Error('Utilisateur introuvable')
if (!user.passwordHash) {
throw new Error(user.jellyfinId
? 'Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr'
: 'Ce compte n\'a pas de mot de passe FanKarr')
}
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) { if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
throw new Error('Mot de passe actuel incorrect') throw new Error('Mot de passe actuel incorrect')
} }
+36
View File
@@ -0,0 +1,36 @@
// ── Chemin de base (reverse proxy en sous-dossier) ─────────────
// Injecté par le serveur dans index.html (cf. server/index.ts) : l'application
// suit le préfixe du reverse proxy sans rebuild. Vide en développement, où Vite
// sert l'application à la racine.
declare global {
interface Window { __BASE_URL__?: string }
}
export const BASE_URL: string = window.__BASE_URL__ ?? ''
// Préfixe un chemin absolu de l'application ('/api/...', '/invite/...')
export function withBase(path: string): string {
return path.startsWith('/') ? `${BASE_URL}${path}` : path
}
// URL absolue affichable (lien d'invitation, adresse de l'API publique)
export function absoluteUrl(path: string): string {
return `${window.location.origin}${withBase(path)}`
}
// Les quelque 140 appels `fetch('/api/…')` du code applicatif sont préfixés ici
// plutôt qu'un par un : un seul endroit à maintenir, et les nouveaux appels en
// bénéficient automatiquement. Les URL absolues (api.github.com, clients
// torrent distants) et les chemins ne visant pas l'API ne sont pas touchés.
export function installFetchBasePrefix(): void {
if (!BASE_URL) return
const nativeFetch = window.fetch.bind(window)
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
if (typeof input === 'string' && input.startsWith('/api/')) {
return nativeFetch(BASE_URL + input, init)
}
return nativeFetch(input, init)
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ function openSetup() {
async function logout() { async function logout() {
open.value = false open.value = false
await auth.logout() if (await auth.logout()) return
router.push('/auth') router.push('/auth')
} }
</script> </script>
+1 -1
View File
@@ -112,7 +112,7 @@ function openSetup() {
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck() const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
async function logout() { async function logout() {
await auth.logout() if (await auth.logout()) return
router.push('/auth') router.push('/auth')
} }
</script> </script>
@@ -0,0 +1,132 @@
<template>
<Teleport to="body">
<div class="modal-backdrop" @click.self="emit('close')">
<div class="modal max-w-md" role="dialog" aria-modal="true" aria-labelledby="jellyfin-import-title">
<div class="flex flex-col gap-1">
<h3 id="jellyfin-import-title" class="card-title">Importer des utilisateurs Jellyfin</h3>
<p class="text-meta text-muted">Chaque utilisateur importé reçoit un compte invité et se connecte avec ses identifiants Jellyfin.</p>
</div>
<p v-if="loading" class="text-body text-muted">Chargement…</p>
<p v-else-if="loadError" class="text-meta text-err">{{ loadError }}</p>
<div v-else class="flex flex-col gap-2">
<label v-if="importable.length > 1" class="flex items-center gap-3 min-h-9 cursor-pointer select-none">
<input type="checkbox" :checked="allSelected" @change="toggleAll" class="w-4 h-4 rounded" />
<span class="text-body text-secondary">Tout sélectionner</span>
</label>
<div class="flex flex-col max-h-[50vh] overflow-y-auto">
<label
v-for="u in users" :key="u.id"
class="flex items-center gap-3 min-h-10 select-none"
:class="isImportable(u) ? 'cursor-pointer' : 'opacity-60'"
:title="statusTitle(u)"
>
<input type="checkbox" :value="u.id" v-model="selected" :disabled="!isImportable(u)" class="w-4 h-4 rounded" />
<span class="flex-1 min-w-0 truncate text-body text-primary">{{ u.name }}</span>
<span v-if="STATUS_LABELS[u.status]" class="pill h-[22px] px-2 text-[10.5px] shrink-0" :class="u.status === 'imported' ? 'pill-ok' : u.status === 'match' ? 'pill-wait' : 'pill-muted'">
{{ STATUS_LABELS[u.status] }}
</span>
</label>
<p v-if="users.length === 0" class="text-body text-muted">Aucun utilisateur sur ce serveur Jellyfin.</p>
</div>
</div>
<p v-if="error" class="text-meta text-err">{{ error }}</p>
<div class="flex gap-2.5 justify-end">
<button @click="emit('close')" class="btn-ghost">Annuler</button>
<button @click="submit" :disabled="submitting || selected.length === 0" class="btn-primary">
{{ submitting ? 'Import…' : selected.length > 0 ? `Importer ${plural(selected.length, 'compte')}` : 'Importer' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { plural } from '@/utils/format'
type Status = 'imported' | 'match' | 'new' | 'disabled' | 'conflict'
interface JellyfinAccount {
id : string
name : string
status : Status
fankarrUsername: string | null
}
const STATUS_LABELS: Record<Status, string> = {
imported: 'Importé',
match : 'Compte existant',
new : '',
disabled: 'Désactivé dans Jellyfin',
conflict: 'Nom déjà pris',
}
const emit = defineEmits<{ close: []; imported: [] }>()
const toast = useToast()
const users = ref<JellyfinAccount[]>([])
const selected = ref<string[]>([])
const loading = ref(true)
const loadError = ref<string | null>(null)
const submitting = ref(false)
const error = ref<string | null>(null)
const importable = computed(() => users.value.filter(isImportable))
const allSelected = computed(() => importable.value.length > 0 && selected.value.length === importable.value.length)
function isImportable(u: JellyfinAccount) {
return u.status === 'new' || u.status === 'match'
}
function statusTitle(u: JellyfinAccount) {
if (u.status === 'match') return `Le compte FanKarr « ${u.fankarrUsername} » sera lié à cet utilisateur Jellyfin.`
if (u.status === 'conflict') return `Le compte FanKarr « ${u.fankarrUsername} » est déjà lié à un autre utilisateur Jellyfin.`
if (u.status === 'imported') return `Compte FanKarr : « ${u.fankarrUsername} »`
return undefined
}
function toggleAll() {
selected.value = allSelected.value ? [] : importable.value.map(u => u.id)
}
onMounted(async () => {
try {
const res = await fetch('/api/jellyfin/users', { credentials: 'include' })
const data = await res.json()
if (res.ok) users.value = data
else loadError.value = data.error ?? 'Impossible de récupérer les utilisateurs Jellyfin.'
} catch {
loadError.value = 'Impossible de contacter le serveur.'
}
loading.value = false
})
async function submit() {
submitting.value = true
error.value = null
try {
const res = await fetch('/api/jellyfin/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ ids: selected.value }),
})
const data = await res.json()
if (!res.ok) { error.value = data.error ?? 'Impossible d\'importer les utilisateurs.'; return }
const parts: string[] = []
if (data.created > 0) parts.push(plural(data.created, 'compte créé', 'comptes créés'))
if (data.linked > 0) parts.push(plural(data.linked, 'compte lié', 'comptes liés'))
toast.add(parts.length > 0 ? parts.join(', ') : 'Aucun compte importé', parts.length > 0 ? 'success' : 'info')
emit('imported')
emit('close')
} catch {
error.value = 'Impossible de contacter le serveur.'
} finally {
submitting.value = false
}
}
</script>
+18 -18
View File
@@ -12,7 +12,7 @@
<span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span> <span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
</p> </p>
<p class="text-meta text-muted mt-0.5 leading-relaxed"> <p class="text-meta text-muted mt-0.5 leading-relaxed">
Vos utilisateurs Jellyfin se connectent à FanKarr et font leurs demandes depuis le plugin FanKarr Search. Vos utilisateurs Jellyfin se connectent à FanKarr avec leur compte Jellyfin, depuis l'interface web ou le plugin FanKarr Search.
</p> </p>
</div> </div>
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0"> <button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
@@ -26,15 +26,15 @@
<div v-if="jellyfin?.test?.ok" class="flex flex-col gap-2"> <div v-if="jellyfin?.test?.ok" class="flex flex-col gap-2">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing" class="btn-secondary btn-sm"> <button @click="importUsers" :disabled="importing" class="btn-secondary btn-sm">
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }} {{ importing ? 'Import…' : 'Importer les utilisateurs' }}
</button> </button>
<span v-if="syncResult" class="text-meta text-ok"> <span v-if="importResult" class="text-meta text-ok">
{{ plural(syncResult.created, 'créé', 'créés') }} · {{ plural(syncResult.skipped, 'déjà existant', 'déjà existants') }} {{ plural(importResult.created, 'créé', 'créés') }}<template v-if="importResult.linked > 0"> · {{ plural(importResult.linked, 'lié', 'liés') }}</template> · {{ plural(importResult.skipped, 'ignoré', 'ignorés') }}
</span> </span>
<span v-if="syncError" class="text-meta text-err">{{ syncError }}</span> <span v-if="importError" class="text-meta text-err">{{ importError }}</span>
</div> </div>
<p class="text-meta text-muted">Crée un compte FanKarr pour chaque utilisateur Jellyfin. La synchronisation se relance ensuite toutes les heures.</p> <p class="text-meta text-muted">Crée un compte FanKarr pour chaque utilisateur Jellyfin actif. L'import se relance ensuite toutes les heures : vous pourrez choisir les comptes à la place dans Paramètres › Jellyfin et API.</p>
</div> </div>
</template> </template>
</section> </section>
@@ -82,9 +82,9 @@ const context = inject(setupContextKey)!
const jellyfinOpen = ref(false) const jellyfinOpen = ref(false)
const jellyfin = ref<{ jellyfinUrl: string; hasToken: boolean; test: { ok: boolean } | null } | null>(null) const jellyfin = ref<{ jellyfinUrl: string; hasToken: boolean; test: { ok: boolean } | null } | null>(null)
const syncing = ref(false) const importing = ref(false)
const syncResult = ref<{ created: number; skipped: number } | null>(null) const importResult = ref<{ created: number; linked: number; skipped: number } | null>(null)
const syncError = ref<string | null>(null) const importError = ref<string | null>(null)
const plexOpen = ref(false) const plexOpen = ref(false)
function openPlex() { function openPlex() {
@@ -92,19 +92,19 @@ function openPlex() {
context.plexOpened = true context.plexOpened = true
} }
async function syncUsers() { async function importUsers() {
syncing.value = true importing.value = true
syncError.value = null importError.value = null
syncResult.value = null importResult.value = null
try { try {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' }) const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json() const data = await res.json()
if (res.ok) syncResult.value = data if (res.ok) importResult.value = data
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin' else importError.value = data.error ?? 'Impossible d\'importer les utilisateurs Jellyfin'
} catch { } catch {
syncError.value = 'Impossible de contacter le serveur' importError.value = 'Impossible de contacter le serveur'
} finally { } finally {
syncing.value = false importing.value = false
} }
} }
</script> </script>
+2
View File
@@ -3,8 +3,10 @@ import { createPinia } from 'pinia'
import router from './router' import router from './router'
import App from '@/App.vue' import App from '@/App.vue'
import { initTheme } from '@/composables/useTheme' import { initTheme } from '@/composables/useTheme'
import { installFetchBasePrefix } from '@/base-url'
import '@/assets/main.css' import '@/assets/main.css'
installFetchBasePrefix()
initTheme() initTheme()
const app = createApp(App) const app = createApp(App)
+2 -1
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { BASE_URL } from '@/base-url'
declare module 'vue-router' { declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
@@ -10,7 +11,7 @@ import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/layouts/AppLayout.vue' import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(BASE_URL || '/'),
routes: [ routes: [
// ── Pages hors layout (connexion, invitation, assistant) ── // ── Pages hors layout (connexion, invitation, assistant) ──
{ {
+15 -2
View File
@@ -11,6 +11,9 @@ export const useAuthStore = defineStore('auth', () => {
const onboardingDone = ref(true) const onboardingDone = ref(true)
const tourSeen = ref(true) const tourSeen = ref(true)
const jellyfinLogin = ref(false)
const proxyAuth = ref(false)
const logoutUrl = ref<string | null>(null)
const isAdmin = computed(() => role.value === 'admin') const isAdmin = computed(() => role.value === 'admin')
@@ -25,6 +28,9 @@ export const useAuthStore = defineStore('auth', () => {
userId.value = data.userId ?? null userId.value = data.userId ?? null
onboardingDone.value = data.onboardingDone ?? true onboardingDone.value = data.onboardingDone ?? true
tourSeen.value = data.tourSeen ?? true tourSeen.value = data.tourSeen ?? true
jellyfinLogin.value = data.jellyfinLogin ?? false
proxyAuth.value = data.proxyAuth ?? false
logoutUrl.value = data.logoutUrl ?? null
} catch { } catch {
loggedIn.value = false loggedIn.value = false
} finally { } finally {
@@ -66,14 +72,21 @@ export const useAuthStore = defineStore('auth', () => {
return data.error ?? 'Impossible de créer le compte.' return data.error ?? 'Impossible de créer le compte.'
} }
async function logout() { // true : la page quitte FanKarr pour la déconnexion du proxy d'authentification,
// sans quoi le proxy rouvrirait la session à la requête suivante
async function logout(): Promise<boolean> {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch('/api/auth/logout', { method: 'POST' })
if (logoutUrl.value) {
window.location.href = logoutUrl.value
return true
}
loggedIn.value = false loggedIn.value = false
role.value = null role.value = null
username.value = null username.value = null
userId.value = null userId.value = null
onboardingDone.value = true onboardingDone.value = true
tourSeen.value = true tourSeen.value = true
return false
} }
async function markTourSeen() { async function markTourSeen() {
@@ -94,7 +107,7 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
loggedIn, setup, loading, role, username, userId, isAdmin, loggedIn, setup, loading, role, username, userId, isAdmin,
onboardingDone, tourSeen, onboardingDone, tourSeen, jellyfinLogin, proxyAuth, logoutUrl,
checkStatus, login, setupAccount, logout, markTourSeen, completeOnboarding, checkStatus, login, setupAccount, logout, markTourSeen, completeOnboarding,
} }
}) })
+3
View File
@@ -35,6 +35,9 @@
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1"> <button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }} {{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button> </button>
<p v-if="!isSetup && auth.jellyfinLogin" class="text-meta text-muted text-center">
Vous pouvez aussi vous connecter avec votre compte Jellyfin.
</p>
</div> </div>
</div> </div>
+82 -32
View File
@@ -2,31 +2,57 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions"> <Teleport defer to="#settings-actions">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'"> <button @click="importOpen = true" :disabled="!configured" class="btn-secondary pointer-fine:h-[38px]" :title="configured ? undefined : NOT_CONFIGURED">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" /> <UserPlus :size="15" /> Importer des utilisateurs
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button> </button>
</Teleport> </Teleport>
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search."> <SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à importer les comptes et à connecter leurs utilisateurs à FanKarr, depuis l'interface web ou le plugin FanKarr Search.">
<JellyfinConnectionForm @change="hasToken = $event.hasToken" /> <JellyfinConnectionForm @change="configured = !!$event.jellyfinUrl && $event.hasToken" />
</SettingsSection> </SettingsSection>
<SettingsSection <SettingsSection
title="Synchronisation des utilisateurs" title="Utilisateurs Jellyfin"
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures." description="Un utilisateur importé reçoit un compte invité. Si un compte FanKarr porte déjà son nom, ce compte est lié au lieu d'être recréé."
> >
<template #actions>
<span v-if="savedSection === 'users'" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
</template>
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]"> <button @click="importOpen = true" :disabled="!configured" class="btn-secondary pointer-fine:h-[38px]">
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }} Importer des utilisateurs
</button> </button>
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span> <span v-if="!configured" class="text-meta text-muted">{{ NOT_CONFIGURED }}</span>
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
<span v-else-if="syncResult" class="text-meta text-secondary">
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
</span>
</div> </div>
<SettingsToggle
:model-value="options.jellyfinAutoImport"
@update:model-value="save('jellyfinAutoImport', $event, 'users')"
label="Importer automatiquement tous les utilisateurs"
description="Toutes les heures, chaque utilisateur Jellyfin actif sans compte FanKarr est importé."
/>
</SettingsSection>
<SettingsSection title="Connexion avec Jellyfin" description="Jellyfin vérifie le mot de passe : FanKarr ne le conserve pas.">
<template #actions>
<span v-if="savedSection === 'login'" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
</template>
<SettingsToggle
:model-value="options.jellyfinLogin"
@update:model-value="save('jellyfinLogin', $event, 'login')"
label="Connexion à FanKarr avec un compte Jellyfin"
description="Les comptes importés se connectent à l'interface web avec leurs identifiants Jellyfin."
/>
<SettingsToggle
:model-value="options.jellyfinNewUserLogin"
@update:model-value="save('jellyfinNewUserLogin', $event, 'login')"
label="Autoriser les comptes pas encore importés"
description="Un compte invité est créé à la première connexion, depuis l'interface web ou le plugin FanKarr Search."
/>
<p class="text-xs text-muted">
Un mot de passe erroné saisi dans FanKarr compte aussi comme un échec dans Jellyfin. Si le verrouillage après plusieurs échecs est activé pour un utilisateur dans Jellyfin, son compte y est désactivé une fois la limite atteinte.
</p>
</SettingsSection> </SettingsSection>
<section class="bg-card rounded-card"> <section class="bg-card rounded-card">
@@ -46,22 +72,33 @@
</div> </div>
</section> </section>
<JellyfinImportModal v-if="importOpen" @close="importOpen = false" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { RefreshCw } from 'lucide-vue-next' import { Check, UserPlus } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue' import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import JellyfinImportModal from '@/components/settings/JellyfinImportModal.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue' import SettingsSection from '@/components/settings/SettingsSection.vue'
import { plural } from '@/utils/format' import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast'
import { absoluteUrl } from '@/base-url'
const hasToken = ref(false) type Option = 'jellyfinLogin' | 'jellyfinNewUserLogin' | 'jellyfinAutoImport'
const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
const apiBase = `${window.location.origin}/api/v1` const NOT_CONFIGURED = 'Renseignez d\'abord l\'adresse et la clé API Jellyfin.'
const toast = useToast()
const configured = ref(false)
const importOpen = ref(false)
const options = ref<Record<Option, boolean>>({ jellyfinLogin: true, jellyfinNewUserLogin: false, jellyfinAutoImport: true })
const savedSection = ref<'users' | 'login' | null>(null)
let savedTimer: ReturnType<typeof setTimeout> | null = null
const apiBase = absoluteUrl('/api/v1')
const apiEndpoints = [ const apiEndpoints = [
{ method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' }, { method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' },
@@ -72,14 +109,27 @@ const apiEndpoints = [
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' }, { method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
] ]
async function syncUsers() { onMounted(async () => {
syncing.value = true const res = await fetch('/api/jellyfin/settings', { credentials: 'include' }).catch(() => null)
syncError.value = null if (!res?.ok) return
syncResult.value = null const d = await res.json()
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' }) options.value = { jellyfinLogin: d.jellyfinLogin, jellyfinNewUserLogin: d.jellyfinNewUserLogin, jellyfinAutoImport: d.jellyfinAutoImport }
const data = await res.json() })
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin.' async function save(key: Option, value: boolean, section: 'users' | 'login') {
syncing.value = false const previous = options.value[key]
options.value[key] = value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ [key]: value }),
}).catch(() => null)
if (!res?.ok) {
options.value[key] = previous
toast.add('Impossible d\'enregistrer le réglage.', 'error')
return
}
savedSection.value = section
if (savedTimer) clearTimeout(savedTimer)
savedTimer = setTimeout(() => { savedSection.value = null }, 2000)
} }
</script> </script>
+11 -3
View File
@@ -20,7 +20,15 @@
</div> </div>
</SettingsSection> </SettingsSection>
<SettingsSection title="Mot de passe" description="Au moins 6 caractères."> <SettingsSection
v-if="me?.hasPassword === false"
title="Mot de passe"
:description="me.jellyfin
? 'Vous vous connectez avec votre compte Jellyfin. Le mot de passe se change dans Jellyfin.'
: 'Vous vous connectez par le proxy d\'authentification de votre serveur : ce compte n\'a pas de mot de passe FanKarr.'"
/>
<SettingsSection v-else title="Mot de passe" description="Au moins 6 caractères.">
<div class="grid sm:grid-cols-3 gap-4"> <div class="grid sm:grid-cols-3 gap-4">
<div class="flex flex-col gap-[7px]"> <div class="flex flex-col gap-[7px]">
<label for="pwd-current" class="field-label">Mot de passe actuel</label> <label for="pwd-current" class="field-label">Mot de passe actuel</label>
@@ -91,7 +99,7 @@ import SettingsSection from '@/components/settings/SettingsSection.vue
const auth = useAuthStore() const auth = useAuthStore()
const me = ref<{ createdAt?: string; lastLoginAt?: string | null } | null>(null) const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean; jellyfin?: boolean } | null>(null)
function formatDate(iso: string) { function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
@@ -153,7 +161,7 @@ async function loadMe() {
if (res.ok) { if (res.ok) {
const d = await res.json() const d = await res.json()
myToken.value = d.apiToken ?? '' myToken.value = d.apiToken ?? ''
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt } me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword, jellyfin: !!d.jellyfinId }
} }
} }
+1 -1
View File
@@ -63,7 +63,7 @@ const pages: Record<string, { title: string; description: string }> = {
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' }, '/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' }, '/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' }, '/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' }, '/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, import des comptes et API pour les applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' }, '/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' }, '/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
} }
+10 -4
View File
@@ -164,7 +164,7 @@
<div class="flex flex-col gap-[7px]"> <div class="flex flex-col gap-[7px]">
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label> <label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password" <input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" /> :placeholder="userModal.mode === 'create' ? '••••••••' : userModal.hasPassword ? 'Laissez vide pour le conserver' : userModal.jellyfin ? 'Aucun mot de passe FanKarr (connexion Jellyfin)' : 'Aucun mot de passe FanKarr'" />
</div> </div>
<div class="flex flex-col gap-[7px]"> <div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span> <span class="field-label">Rôle</span>
@@ -233,6 +233,7 @@ import { copyToClipboard } from '@/composables/useClipboard'
import { formatRelative, plural } from '@/utils/format' import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue' import SettingsSection from '@/components/settings/SettingsSection.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue' import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { absoluteUrl } from '@/base-url'
interface User { interface User {
id : string id : string
@@ -241,6 +242,8 @@ interface User {
apiToken : string apiToken : string
createdAt : string createdAt : string
lastLoginAt?: string | null lastLoginAt?: string | null
jellyfinId? : string | null
hasPassword : boolean
} }
interface Invite { interface Invite {
@@ -275,6 +278,8 @@ const userModal = ref<{
username: string username: string
password: string password: string
role : 'admin' | 'user' role : 'admin' | 'user'
hasPassword: boolean
jellyfin : boolean
error : string | null error : string | null
loading : boolean loading : boolean
} | null>(null) } | null>(null)
@@ -358,11 +363,11 @@ onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Utilisateurs ── // ── Utilisateurs ──
function openCreateUser() { function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false } userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, jellyfin: false, error: null, loading: false }
} }
function openEditUser(u: User) { function openEditUser(u: User) {
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, error: null, loading: false } userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, jellyfin: !!u.jellyfinId, error: null, loading: false }
} }
async function submitUserModal() { async function submitUserModal() {
@@ -399,6 +404,7 @@ function userSubtitle(u: User) {
if (u.id === currentUserId.value) parts.push('vous') if (u.id === currentUserId.value) parts.push('vous')
if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`) if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`)
if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`) if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`)
if (u.jellyfinId) parts.push('Jellyfin')
return parts.join(' · ') return parts.join(' · ')
} }
@@ -445,7 +451,7 @@ async function deleteInvite(code: string) {
} }
function inviteUrl(code: string) { function inviteUrl(code: string) {
return `${window.location.origin}/invite/${code}` return absoluteUrl(`/invite/${code}`)
} }
async function copyInvite(code: string) { async function copyInvite(code: string) {
+1 -1
View File
@@ -11,5 +11,5 @@
"sourceMap" : false "sourceMap" : false
}, },
"include": ["server/**/*"], "include": ["server/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "server/**/*.test.ts"]
} }
+1 -1
View File
@@ -1 +1 @@
v3.24.1 v3.25.0
+3
View File
@@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
// Chemins d'assets relatifs : le serveur injecte <base href> dans index.html,
// l'application fonctionne donc sous n'importe quel BASE_URL sans rebuild
base: './',
plugins: [vue(), tailwindcss()], plugins: [vue(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {