const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const { exists, isAccessible } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const { ValidationError, ForbiddenError } = require('../errors'); /** * Browse route factory * @param {Object} deps - Explicit dependencies * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.validateSecurePath - Path traversal validator * @param {Object} deps.auditLogger - Audit logger * @param {Object} deps.docker - Docker client * @returns {express.Router} */ module.exports = function({ asyncHandler, validateSecurePath, auditLogger, docker }) { const router = express.Router(); // Parse browse roots from environment const BROWSE_ROOTS = (process.env.MEDIA_BROWSE_ROOTS || '') .split(',') .filter(r => r.includes('=')) .map(r => { const eqIndex = r.indexOf('='); const containerPath = r.slice(0, eqIndex).trim(); const hostPath = r.slice(eqIndex + 1).trim(); return { containerPath, hostPath }; }); // Get available browse roots router.get('/browse/roots', asyncHandler(async (req, res) => { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, containerPath: r.containerPath })); const roots = []; for (const r of allRoots) { if (await isAccessible(r.containerPath, fs.constants.R_OK)) { roots.push(r); } } res.json({ success: true, roots }); }, 'browse-roots')); // Browse directory contents router.get('/browse/directories', asyncHandler(async (req, res) => { const requestedPath = req.query.path || ''; if (!requestedPath) { const allRoots = BROWSE_ROOTS.map(r => ({ name: r.hostPath, path: r.hostPath, type: 'drive' })); const roots = []; for (const r of allRoots) { const br = BROWSE_ROOTS.find(br => br.hostPath === r.path); if (await isAccessible(br.containerPath, fs.constants.R_OK)) { roots.push(r); } } return res.json({ success: true, path: '', items: roots }); } const matchingRoot = BROWSE_ROOTS.find(r => requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '') ); if (!matchingRoot) { throw new ValidationError('Path not in browseable roots', { availableRoots: BROWSE_ROOTS.map(r => r.hostPath) }); } const relativePath = requestedPath.slice(matchingRoot.hostPath.length); const containerFullPath = path.join(matchingRoot.containerPath, relativePath); const allowedRoots = BROWSE_ROOTS.map(r => r.containerPath); let resolvedPath; try { resolvedPath = await validateSecurePath(containerFullPath, allowedRoots, auditLogger); } catch (error) { if (error.constructor.name === 'ValidationError') { auditLogger.logSecurityEvent('path_traversal_attempt', { requestedPath, containerFullPath, allowedRoots, error: error.message, ip: req.ip, userAgent: req.get('user-agent') }); throw new ForbiddenError('Access denied - path traversal detected'); } throw error; } if (!await exists(resolvedPath)) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Path'); } const stats = await fsp.stat(resolvedPath); if (!stats.isDirectory()) { throw new ValidationError('Path is not a directory'); } const entries = await fsp.readdir(resolvedPath, { withFileTypes: true }); const folders = entries .filter(entry => { if (!entry.isDirectory()) return false; if (entry.name.startsWith('.')) return false; if (entry.name === '$RECYCLE.BIN' || entry.name === 'System Volume Information') return false; return true; }) .map(entry => ({ name: entry.name, path: path.join(requestedPath, entry.name).replace(/\\/g, '/'), type: 'folder' })) .sort((a, b) => a.name.localeCompare(b.name)); const paginationParams = parsePaginationParams(req.query); const result = paginate(folders, paginationParams); res.json({ success: true, path: requestedPath, parent: path.dirname(requestedPath).replace(/\\/g, '/') || null, items: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'browse-dir')); // Detect media mounts from existing media server containers router.get('/media/detected-mounts', asyncHandler(async (req, res) => { const mediaServerPatterns = [ 'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic', 'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr', 'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli' ]; const excludePatterns = [ '/config', '/cache', '/transcode', '/data/config', '/app', '/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile' ]; const containers = await docker.client.listContainers({ all: false }); const detectedMounts = []; const seenPaths = new Set(); for (const containerInfo of containers) { const imageName = containerInfo.Image.toLowerCase(); const isMediaServer = mediaServerPatterns.some(p => imageName.includes(p)); if (!isMediaServer) continue; const container = docker.client.getContainer(containerInfo.Id); const details = await container.inspect(); const binds = details.HostConfig?.Binds || []; for (const bind of binds) { const parts = bind.split(':'); if (parts.length < 2) continue; let hostPath, containerPath; if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) { hostPath = parts[0] + ':' + parts[1]; containerPath = parts[2] || ''; } else { hostPath = parts[0]; containerPath = parts[1]; } const isExcluded = excludePatterns.some(p => containerPath.toLowerCase().includes(p.toLowerCase()) || hostPath.toLowerCase().includes(p.toLowerCase()) ); if (isExcluded) continue; if (seenPaths.has(hostPath)) continue; seenPaths.add(hostPath); const folderName = hostPath.split(/[/\\]/).filter(p => p && p !== ':').pop() || hostPath; detectedMounts.push({ hostPath, containerPath, folderName, sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12), sourceImage: containerInfo.Image.split('/').pop().split(':')[0] }); } } res.json({ success: true, mounts: detectedMounts, message: detectedMounts.length > 0 ? `Found ${detectedMounts.length} media mount(s) from existing containers` : 'No existing media mounts detected' }); }, 'detect-media-mounts')); return router; };