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