Compare commits
1
Commits
main
...
feat/base-url
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be97aa0dea |
No files matched your search
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
+9
-3
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -56,4 +56,21 @@ const isInContainer = (): boolean => {
|
||||
|
||||
export const DATA_DIR = isInContainer()
|
||||
? '/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)
|
||||
+54
-28
@@ -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 <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)) {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+2
-1
@@ -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) ──
|
||||
{
|
||||
|
||||
@@ -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<string | null>(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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <base href> dans index.html,
|
||||
// l'application fonctionne donc sous n'importe quel BASE_URL sans rebuild
|
||||
base: './',
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in new issue
Block a user