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>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
import express from 'express'
|
|
import cookieParser from 'cookie-parser'
|
|
import http from 'http'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin, proxyAuth } from './auth.js'
|
|
import { migrateIfNeeded } from './users.js'
|
|
import { migrateOnboarding } from './onboarding.js'
|
|
import { readSettings } from './settings.js'
|
|
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
|
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
|
import transmissionDriver from './torrent-clients/transmission.js'
|
|
import synologyDsDriver from './torrent-clients/synology-ds.js'
|
|
import utorrentDriver from './torrent-clients/utorrent.js'
|
|
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
|
import realDebridDriver from './torrent-clients/real-debrid.js'
|
|
import delugeDriver from './torrent-clients/deluge.js'
|
|
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
|
|
import { logger } from './logger.js'
|
|
import { DATA_DIR, BASE_DIR, BASE_URL } from './config.js'
|
|
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
|
|
import { loadCatalog } from './lib/serie-helpers.js'
|
|
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
|
|
import { pushNotif } from './lib/notifs.js'
|
|
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
|
|
import { readRequests, completeRequest } from './requests.js'
|
|
import { checkNfoUpdates } from './lib/nfo.js'
|
|
import { runJellyfinImport } from './routes/jellyfin.js'
|
|
import cors from 'cors';
|
|
|
|
import usersRouter from './routes/users.js'
|
|
import invitesRouter from './routes/invites.js'
|
|
import requestsRouter from './routes/requests.js'
|
|
import jellyfinRouter from './routes/jellyfin.js'
|
|
import publicApiRouter from './routes/public-api.js'
|
|
import settingsRouter from './routes/settings.js'
|
|
import torrentClientsRouter from './routes/torrent-clients.js'
|
|
import seriesRouter from './routes/series.js'
|
|
import downloadsRouter from './routes/downloads.js'
|
|
import organizeRouter from './routes/organize.js'
|
|
import importRouter from './routes/import.js'
|
|
import systemRouter from './routes/system.js'
|
|
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
|
import plexRouter from './routes/plex.js'
|
|
import rssSyncRouter from './routes/rss-sync.js'
|
|
import { runRssSync } from './lib/rss-sync.js'
|
|
|
|
registerDriver(qbittorrentDriver)
|
|
registerDriver(transmissionDriver)
|
|
registerDriver(synologyDsDriver)
|
|
registerDriver(utorrentDriver)
|
|
registerDriver(rtorrentDriver)
|
|
registerDriver(realDebridDriver)
|
|
registerDriver(delugeDriver)
|
|
|
|
const app = express()
|
|
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(cookieParser())
|
|
|
|
|
|
app.use(cors({
|
|
origin: '*',
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
}));
|
|
|
|
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
|
|
if (fs.existsSync(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 ───────────────────────
|
|
migrateIfNeeded()
|
|
migrateOnboarding()
|
|
|
|
// ── Authentification ───────────────────────────────────────────
|
|
// Session ouverte d'après l'en-tête du proxy d'authentification (PROXY_AUTH_HEADER)
|
|
site.use(proxyAuth)
|
|
site.get ('/api/auth/status', authStatus)
|
|
site.post('/api/auth/setup', authSetup)
|
|
site.post('/api/auth/login', authLogin)
|
|
site.post('/api/auth/logout', authLogout)
|
|
site.get ('/api/auth/me', requireAuth, authMe)
|
|
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) ──────────────
|
|
site.use('/api', publicApiRouter)
|
|
|
|
// ── Garde admin par préfixe ───────────────────────────────────
|
|
// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
|
|
const ADMIN_PREFIXES = [
|
|
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
|
'/downloads', '/download', '/organize', '/import',
|
|
'/system', '/nfo-updates', '/plex', '/rss-sync',
|
|
'/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders',
|
|
'/rename-episode', '/rename-all', '/purge-nfo',
|
|
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
|
|
]
|
|
site.use('/api', (req, res, next) => {
|
|
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
|
|
return requireAdmin(req, res, next)
|
|
}
|
|
next()
|
|
})
|
|
|
|
// ── Routes (admins et utilisateurs connectés) ──────────────────
|
|
site.use('/api', usersRouter)
|
|
site.use('/api', jellyfinRouter)
|
|
site.use('/api', settingsRouter)
|
|
site.use('/api', torrentClientsRouter)
|
|
site.use('/api', downloadsRouter)
|
|
site.use('/api', organizeRouter)
|
|
site.use('/api', importRouter)
|
|
site.use('/api', systemRouter)
|
|
site.use('/api', nfoUpdatesRouter)
|
|
site.use('/api', plexRouter)
|
|
site.use('/api', rssSyncRouter)
|
|
site.use('/api', invitesRouter)
|
|
site.use('/api', requireAuth, seriesRouter)
|
|
site.use('/api', requireAuth, requestsRouter)
|
|
|
|
// ── 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)) {
|
|
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 ──────────────────────────────────────────────────
|
|
const server = http.createServer({ maxHeaderSize: 32768 }, app)
|
|
|
|
server.listen(PORT, async () => {
|
|
const url = `http://localhost:${PORT}${BASE_URL}`
|
|
console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
|
|
logger.info('api', `Serveur démarré sur ${url}`)
|
|
try {
|
|
const available = await readAvailable()
|
|
logger.info('api', `Catalogue chargé : ${available.length} séries`)
|
|
} catch (err) {
|
|
logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
|
|
const { mediaPath } = readSettings()
|
|
|
|
// Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
|
|
try {
|
|
const raw = readOrganized()
|
|
const isOldFormat = Object.values(raw).some((entries: any) =>
|
|
Object.values(entries).some(v => typeof v === 'string')
|
|
)
|
|
if (isOldFormat) {
|
|
logger.info('api', 'Migration organized.json : ancien format détecté, fichier réinitialisé')
|
|
writeOrganized({})
|
|
}
|
|
} catch (err) {
|
|
logger.error('api', `Migration organized.json annulée : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
|
|
try {
|
|
const raw = readOrganized()
|
|
let patched = 0
|
|
for (const episodes of Object.values(raw)) {
|
|
for (const entry of Object.values(episodes)) {
|
|
if (!entry.dest_dir && entry.dest_path) {
|
|
entry.dest_dir = path.dirname(entry.dest_path)
|
|
patched++
|
|
}
|
|
}
|
|
}
|
|
if (patched > 0) {
|
|
writeOrganized(raw)
|
|
logger.info('api', `Migration organized.json : ${patched} entrée(s) complétée(s) (dest_dir)`)
|
|
}
|
|
} catch (err) {
|
|
logger.warn('api', `Échec de la migration dest_dir : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
|
|
loadCatalog()
|
|
.then(async seriesData => {
|
|
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
|
dedupeOrganizedEpisodes(ORGANIZED_PATH)
|
|
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
|
})
|
|
.catch(err => logger.error('api', `Échec de l'analyse initiale de la médiathèque : ${err instanceof Error ? err.message : err}`))
|
|
|
|
const autoOrganize = async () => {
|
|
autoImportSchedule.lastRunAt = new Date().toISOString()
|
|
planNextAutoImport()
|
|
try {
|
|
const { category } = readSettings()
|
|
const infohashMap = await readInfohashMap()
|
|
const seriesData = await loadCatalog()
|
|
await autoOrganizeAll(
|
|
() => dispatchList(category ?? 'fankai', infohashMap),
|
|
seriesData,
|
|
async (result) => {
|
|
if (result.done > 0 || result.errors > 0) {
|
|
pushNotif({ ...result, at: new Date().toISOString() })
|
|
}
|
|
// Demandes actives passées en « disponible » dès qu'un fichier est importé
|
|
if (result.done > 0 && result.serieId != null) {
|
|
try {
|
|
const toComplete = readRequests().filter(r =>
|
|
r.serieId === result.serieId &&
|
|
(r.status === 'pending' || r.status === 'approved')
|
|
)
|
|
for (const r of toComplete) {
|
|
completeRequest(r.id)
|
|
logger.info('requests', `Demande « ${r.serieName} » passée en disponible (import automatique)`)
|
|
}
|
|
} catch (err) {
|
|
logger.warn('api', `Impossible de passer en « disponible » les demandes de la série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
|
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
|
const removeResult = await dispatchRemove(result.hash, false)
|
|
if (removeResult.ok) {
|
|
logger.info('api', `Torrent « ${result.name} » retiré du client après déplacement`)
|
|
} else {
|
|
logger.warn('api', `Échec du retrait de « ${result.name} » après déplacement : ${removeResult.error ?? 'aucun client n\'a pu le supprimer'}`)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
} catch (err) {
|
|
logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
|
|
planNextAutoImport(10_000)
|
|
setTimeout(() => {
|
|
autoOrganize()
|
|
setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
|
|
}, 10_000)
|
|
|
|
setTimeout(() => {
|
|
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
|
|
setInterval(() => {
|
|
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
|
|
}, 60 * 60_000)
|
|
}, 30_000)
|
|
|
|
setInterval(async () => {
|
|
try {
|
|
const seriesData = await loadCatalog()
|
|
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
|
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
|
if (updated > 0)
|
|
logger.info('api', `Migration automatique des IDs : ${updated} ID(s) mis à jour`)
|
|
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
|
if (removed > 0)
|
|
logger.info('api', `Dédoublonnage automatique : ${removed} entrée(s) redondante(s) supprimée(s)`)
|
|
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
|
if (renamed > 0)
|
|
logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
|
|
} catch (err) {
|
|
logger.error('api', `Échec de la mise à jour horaire des imports : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}, 60 * 60_000)
|
|
|
|
setTimeout(() => {
|
|
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
|
|
setInterval(() => {
|
|
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
|
|
}, 6 * 60 * 60_000)
|
|
}, 60_000)
|
|
|
|
// 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(() => {
|
|
autoImportJellyfin()
|
|
setInterval(autoImportJellyfin, 60 * 60_000)
|
|
}, 2 * 60_000)
|
|
})
|