From be97aa0dea9969be1eefd517e086eeaa985a0df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Sun, 20 Sep 2026 02:35:28 +0200 Subject: [PATCH] feat: support d'un chemin de base (BASE_URL) pour reverse proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 ) Comportement inchangé lorsque BASE_URL n'est pas défini. Co-Authored-By: Claude Opus 5 --- .env.example | 4 ++ README.md | 30 +++++++++++ docker-compose.yml | 1 + server/auth.ts | 12 +++-- server/config.ts | 19 ++++++- server/index.ts | 82 +++++++++++++++++++---------- src/base-url.ts | 36 +++++++++++++ src/main.ts | 2 + src/router/index.ts | 3 +- src/views/settings/JellyfinView.vue | 3 +- src/views/settings/UsersView.vue | 3 +- vite.config.ts | 3 ++ 12 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 src/base-url.ts diff --git a/.env.example b/.env.example index 6ee95b3..2e01569 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ # Port d'écoute (défaut : 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 diff --git a/README.md b/README.md index 0e9dbcf..d897100 100644 --- a/README.md +++ b/README.md @@ -198,11 +198,41 @@ 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 | | `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 | +| `BASE_URL` | non défini | Sous-dossier derrière un reverse proxy (ex. `/fankarr`) | 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`). + +--- + ### Premier lancement 1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur). diff --git a/docker-compose.yml b/docker-compose.yml index 457d354..4972c49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - PUID=1000 # UID de votre utilisateur (id -u) - PGID=1000 # GID de votre utilisateur (id -g) - TZ=Europe/Paris # Fuseau horaire + # - BASE_URL=/fankarr # Sous-dossier derrière un reverse proxy (facultatif) volumes: - ./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) diff --git a/server/auth.ts b/server/auth.ts index 33d581f..fd47205 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -4,7 +4,7 @@ import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import type { Request, Response, NextFunction } from 'express' 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 { findByUsername, findById, findByApiToken, @@ -26,6 +26,10 @@ const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d' const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY +// 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 { const payload = { id: user.id, username: user.username, role: user.role } return NO_EXPIRY @@ -36,7 +40,7 @@ function signToken(user: User): string { function setCookieAndRespond(res: Response, user: User): void { user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt 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 }) res.json({ success: true, user: safeUser(user) }) } @@ -118,7 +122,9 @@ export function authLogout(req: Request, res: Response): void { logger.info('auth', `Déconnexion de « ${p.username} »`) } 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 }) } diff --git a/server/config.ts b/server/config.ts index 446ec9c..f38ce2b 100644 --- a/server/config.ts +++ b/server/config.ts @@ -56,4 +56,21 @@ const isInContainer = (): boolean => { export const DATA_DIR = isInContainer() ? '/config' - : path.join(BASE_DIR, 'config'); \ No newline at end of file + : 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) diff --git a/server/index.ts b/server/index.ts index d5ab4bc..00edb38 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,7 +17,7 @@ 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 } from './config.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' @@ -56,6 +56,9 @@ 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()) @@ -68,7 +71,8 @@ app.use(cors({ const PUBLIC_PATH = path.join(BASE_DIR, 'public') 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 ─────────────────────── @@ -76,17 +80,17 @@ migrateIfNeeded() migrateOnboarding() // ── Authentification ─────────────────────────────────────────── -app.get ('/api/auth/status', authStatus) -app.post('/api/auth/setup', authSetup) -app.post('/api/auth/login', authLogin) -app.post('/api/auth/logout', authLogout) -app.get ('/api/auth/me', requireAuth, authMe) -app.post('/api/auth/change-password', requireAuth, authChangePassword) -app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken) -app.post('/api/auth/tour-seen', requireAuth, authTourSeen) +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) ────────────── -app.use('/api', publicApiRouter) +site.use('/api', publicApiRouter) // ── Garde admin par préfixe ─────────────────────────────────── // requireAdmin sur app.use('/api') bloquerait tous les utilisateurs @@ -98,7 +102,7 @@ const ADMIN_PREFIXES = [ '/rename-episode', '/rename-all', '/purge-nfo', '/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 + '/'))) { return requireAdmin(req, res, next) } @@ -106,31 +110,53 @@ app.use('/api', (req, res, next) => { }) // ── Routes (admins et utilisateurs connectés) ────────────────── -app.use('/api', usersRouter) -app.use('/api', jellyfinRouter) -app.use('/api', settingsRouter) -app.use('/api', torrentClientsRouter) -app.use('/api', downloadsRouter) -app.use('/api', organizeRouter) -app.use('/api', importRouter) -app.use('/api', systemRouter) -app.use('/api', nfoUpdatesRouter) -app.use('/api', plexRouter) -app.use('/api', rssSyncRouter) -app.use('/api', invitesRouter) -app.use('/api', requireAuth, seriesRouter) -app.use('/api', requireAuth, requestsRouter) +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 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 = + `\n` + + ` ` + indexHtmlCache = raw.replace('', `\n ${injected}`) + return indexHtmlCache +} + 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 ────────────────────────────────────────────────── const server = http.createServer({ maxHeaderSize: 32768 }, app) 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`) logger.info('api', `Serveur démarré sur ${url}`) try { diff --git a/src/base-url.ts b/src/base-url.ts new file mode 100644 index 0000000..05f9853 --- /dev/null +++ b/src/base-url.ts @@ -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) + } +} diff --git a/src/main.ts b/src/main.ts index 9ff4974..79732ad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,10 @@ import { createPinia } from 'pinia' import router from './router' import App from '@/App.vue' import { initTheme } from '@/composables/useTheme' +import { installFetchBasePrefix } from '@/base-url' import '@/assets/main.css' +installFetchBasePrefix() initTheme() const app = createApp(App) diff --git a/src/router/index.ts b/src/router/index.ts index ed77350..ab1f5e2 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { BASE_URL } from '@/base-url' declare module 'vue-router' { interface RouteMeta { @@ -10,7 +11,7 @@ import { useAuthStore } from '@/stores/auth' import AppLayout from '@/layouts/AppLayout.vue' const router = createRouter({ - history: createWebHistory(), + history: createWebHistory(BASE_URL || '/'), routes: [ // ── Pages hors layout (connexion, invitation, assistant) ── { diff --git a/src/views/settings/JellyfinView.vue b/src/views/settings/JellyfinView.vue index 3818be8..b823fb4 100644 --- a/src/views/settings/JellyfinView.vue +++ b/src/views/settings/JellyfinView.vue @@ -55,13 +55,14 @@ import { RefreshCw } from 'lucide-vue-next' import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue' import SettingsSection from '@/components/settings/SettingsSection.vue' import { plural } from '@/utils/format' +import { absoluteUrl } from '@/base-url' const hasToken = ref(false) const syncing = ref(false) const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null) const syncError = ref(null) -const apiBase = `${window.location.origin}/api/v1` +const apiBase = absoluteUrl('/api/v1') const apiEndpoints = [ { method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' }, diff --git a/src/views/settings/UsersView.vue b/src/views/settings/UsersView.vue index cd9f3da..3c2d933 100644 --- a/src/views/settings/UsersView.vue +++ b/src/views/settings/UsersView.vue @@ -233,6 +233,7 @@ import { copyToClipboard } from '@/composables/useClipboard' import { formatRelative, plural } from '@/utils/format' import SettingsSection from '@/components/settings/SettingsSection.vue' import SettingsToggle from '@/components/settings/SettingsToggle.vue' +import { absoluteUrl } from '@/base-url' interface User { id : string @@ -445,7 +446,7 @@ async function deleteInvite(code: string) { } function inviteUrl(code: string) { - return `${window.location.origin}/invite/${code}` + return absoluteUrl(`/invite/${code}`) } async function copyInvite(code: string) { diff --git a/vite.config.ts b/vite.config.ts index 3ed4d6a..f0aec5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,9 @@ import tailwindcss from '@tailwindcss/vite' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ + // Chemins d'assets relatifs : le serveur injecte dans index.html, + // l'application fonctionne donc sous n'importe quel BASE_URL sans rebuild + base: './', plugins: [vue(), tailwindcss()], resolve: { alias: {