diff --git a/README.md b/README.md index 782133c..6cbb861 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ docker run -d \ | `NODE_OPTIONS` | `--max-old-space-size=512` | Node.js options, used here to set memory limit (in MB) | | `WEB_PORT` | `3000` | Port for the web UI | | `WEB_HOST` | `127.0.0.1` | Host to bind the web UI to (`0.0.0.0` to expose externally) | +| `BASE_PATH` | `_(none)_` | Reverse-proxy subpath for the web UI (`/subsyncarr` for `https://example.com/subsyncarr/`) | | `TZ` | _(system)_ | Timezone for logging and cron scheduling (e.g., `America/New_York`) | | `PUID` | `1000` | User ID for file permissions (run `id -u` to find yours) | | `PGID` | `1000` | Group ID for file permissions (run `id -g` to find yours) | diff --git a/public/app.js b/public/app.js index f6215ea..2a4e615 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,5 @@ +const API_BASE = window.__BASE_PATH__ || ''; + class SubsyncarrPlusClient { constructor() { this.ws = null; @@ -14,7 +16,7 @@ class SubsyncarrPlusClient { initWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - this.ws = new WebSocket(`${protocol}//${location.host}/ws`); + this.ws = new WebSocket(`${protocol}//${location.host}${API_BASE}/ws`); this.ws.onopen = () => { console.log('WebSocket connected'); @@ -83,7 +85,7 @@ class SubsyncarrPlusClient { } async fetchInitialState() { - const response = await fetch('/api/status'); + const response = await fetch(`${API_BASE}/api/status`); const data = await response.json(); this.state = data; this.render(); @@ -91,14 +93,14 @@ class SubsyncarrPlusClient { } async fetchHistory() { - const response = await fetch('/api/history'); + const response = await fetch(`${API_BASE}/api/history`); const history = await response.json(); // Fetch file results for each run to calculate engine stats const historyWithStats = await Promise.all( history.map(async (run) => { try { - const filesResponse = await fetch(`/api/runs/${run.id}`); + const filesResponse = await fetch(`${API_BASE}/api/runs/${run.id}`); const data = await filesResponse.json(); const runWithFiles = { ...run, files: data.files || [] }; // Cache for file list lookups @@ -116,7 +118,7 @@ class SubsyncarrPlusClient { async fetchConfigStatus() { try { - const response = await fetch('/api/config'); + const response = await fetch(`${API_BASE}/api/config`); const config = await response.json(); this.renderConfigStatus(config); } catch (error) { @@ -332,7 +334,7 @@ class SubsyncarrPlusClient { } async fetchBrowse(path) { - const url = path ? `/api/browse?path=${encodeURIComponent(path)}` : '/api/browse'; + const url = path ? `${API_BASE}/api/browse?path=${encodeURIComponent(path)}` : `${API_BASE}/api/browse`; const res = await fetch(url); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -477,7 +479,7 @@ class SubsyncarrPlusClient { async startRun(paths = null) { try { - const response = await fetch('/api/run/start', { + const response = await fetch(`${API_BASE}/api/run/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }), @@ -498,7 +500,7 @@ class SubsyncarrPlusClient { } try { - const response = await fetch('/api/run/stop', { + const response = await fetch(`${API_BASE}/api/run/stop`, { method: 'POST', }); @@ -513,7 +515,7 @@ class SubsyncarrPlusClient { async skipFile(filePath) { try { - await fetch('/api/file/skip', { + await fetch(`${API_BASE}/api/file/skip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath }), @@ -525,7 +527,7 @@ class SubsyncarrPlusClient { async viewLogs(runId) { try { - const response = await fetch(`/api/runs/${runId}/logs`); + const response = await fetch(`${API_BASE}/api/runs/${runId}/logs`); const data = await response.json(); document.getElementById('logsContent').textContent = data.logs || 'No logs available'; @@ -542,7 +544,7 @@ class SubsyncarrPlusClient { } try { - const response = await fetch('/api/files/clear', { + const response = await fetch(`${API_BASE}/api/files/clear`, { method: 'POST', }); diff --git a/src/server.ts b/src/server.ts index c2e0188..989eb37 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,10 +9,17 @@ import { getScanConfig } from './config'; import cronstrue from 'cronstrue'; import parseExpression from 'cron-parser'; +// Optional BASE_PATH env var for reverse-proxy subpath support. +// e.g. https://example.com/subsyncarr desires BASE_PATH=/subsyncarr to work. +// Not configuring BASE_PATH assumes https://example.com/ +// or https://subsyncarr.example.com/ or just http://127.0.0.1/ etc. for path. +// .replace(/\/$/, '') strips trailing / from user misconfiguration +const basePath = (process.env.BASE_PATH || '').replace(/\/$/, ''); + export class SubsyncarrPlusServer { private app = express(); private httpServer = createServer(this.app); - private wss = new WebSocketServer({ server: this.httpServer, path: '/ws' }); + private wss = new WebSocketServer({ server: this.httpServer, path: `${basePath}/ws` }); private clients: Set = new Set(); constructor( @@ -26,12 +33,28 @@ export class SubsyncarrPlusServer { private setupMiddleware() { this.app.use(express.json()); - this.app.use(express.static(join(__dirname, '../public'))); + + // Serve index.html ourselves (instead of via express.static) so we can + // inject the base path for app.js to read, and fix the asset URLs. + this.app.get(`${basePath}/`, (req, res) => { + const indexPath = join(__dirname, '../public/index.html'); + let html = fs.readFileSync(indexPath, 'utf-8'); + html = html + .replace('href="/styles.css"', `href="${basePath}/styles.css"`) + .replace('src="/app.js"', `src="${basePath}/app.js"`) + .replace('', ``); + res.type('html').send(html); + }); + + this.app.use(basePath, express.static(join(__dirname, '../public'))); } private setupRoutes() { + const router = express.Router(); + this.app.use(basePath, router); + // Get configuration status - this.app.get('/api/config', (req, res) => { + router.get('/api/config', (req, res) => { console.log(`[${new Date().toISOString()}] GET /api/config`); const config = getScanConfig(); const isDefaultPath = config.includePaths.length === 1 && config.includePaths[0] === '/scan_dir'; @@ -69,7 +92,7 @@ export class SubsyncarrPlusServer { // No `path` query → returns the configured roots as virtual top-level entries. // Otherwise enumerates immediate subdirectories of `path`, but only if `path` // resolves inside one of the configured roots. - this.app.get('/api/browse', (req, res) => { + router.get('/api/browse', (req, res) => { const requestedPath = typeof req.query.path === 'string' ? req.query.path : ''; console.log(`[${new Date().toISOString()}] GET /api/browse${requestedPath ? ` path=${requestedPath}` : ''}`); @@ -126,7 +149,7 @@ export class SubsyncarrPlusServer { }); // Get current status - this.app.get('/api/status', (req, res) => { + router.get('/api/status', (req, res) => { console.log(`[${new Date().toISOString()}] GET /api/status`); const currentRun = this.stateManager.getCurrentRun(); res.json({ @@ -137,14 +160,14 @@ export class SubsyncarrPlusServer { }); // Get run history - this.app.get('/api/history', (req, res) => { + router.get('/api/history', (req, res) => { const limit = parseInt(req.query.limit as string, 10) || 50; console.log(`[${new Date().toISOString()}] GET /api/history (limit: ${limit})`); res.json(this.stateManager.getRunHistory(limit)); }); // Get specific run details - this.app.get('/api/runs/:id', (req, res) => { + router.get('/api/runs/:id', (req, res) => { console.log(`[${new Date().toISOString()}] GET /api/runs/${req.params.id}`); const currentRun = this.stateManager.getCurrentRun(); const requestedId = req.params.id; @@ -172,7 +195,7 @@ export class SubsyncarrPlusServer { }); // Get logs for a specific run - this.app.get('/api/runs/:id/logs', (req, res) => { + router.get('/api/runs/:id/logs', (req, res) => { console.log(`[${new Date().toISOString()}] GET /api/runs/${req.params.id}/logs`); const requestedId = req.params.id; @@ -191,7 +214,7 @@ export class SubsyncarrPlusServer { }); // Start a new run - this.app.post('/api/run/start', async (req, res) => { + router.post('/api/run/start', async (req, res) => { const { paths } = req.body; console.log( `[${new Date().toISOString()}] POST /api/run/start${paths ? ` (custom paths: ${paths.join(', ')})` : ' (default paths)'}`, @@ -218,7 +241,7 @@ export class SubsyncarrPlusServer { }); // Stop current run - this.app.post('/api/run/stop', (_req, res) => { + router.post('/api/run/stop', (_req, res) => { console.log(`[${new Date().toISOString()}] POST /api/run/stop`); try { this.coordinator.stopRun(); @@ -234,7 +257,7 @@ export class SubsyncarrPlusServer { }); // Skip a file - this.app.post('/api/file/skip', (req, res) => { + router.post('/api/file/skip', (req, res) => { const { filePath } = req.body; if (!filePath) { @@ -248,7 +271,7 @@ export class SubsyncarrPlusServer { }); // Clear completed files - this.app.post('/api/files/clear', (req, res) => { + router.post('/api/files/clear', (req, res) => { console.log(`[${new Date().toISOString()}] POST /api/files/clear`); this.stateManager.clearCompletedFiles(); @@ -268,14 +291,14 @@ export class SubsyncarrPlusServer { }); // Get skip status statistics - this.app.get('/api/skip-status', (_req, res) => { + router.get('/api/skip-status', (_req, res) => { console.log(`[${new Date().toISOString()}] GET /api/skip-status`); const stats = this.stateManager.getFailureStats(); res.json(stats); }); // Get skip status for specific file - this.app.get('/api/skip-status/:filePath(*)', (req, res) => { + router.get('/api/skip-status/:filePath(*)', (req, res) => { const filePath = decodeURIComponent(req.params.filePath); console.log(`[${new Date().toISOString()}] GET /api/skip-status/${filePath.split('/').pop()}`); @@ -284,7 +307,7 @@ export class SubsyncarrPlusServer { }); // Reset skip status for a file - this.app.post('/api/skip-status/reset', (req, res) => { + router.post('/api/skip-status/reset', (req, res) => { const { filePath, engine } = req.body; if (!filePath) {