Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
194 lines
6.5 KiB
JavaScript
194 lines
6.5 KiB
JavaScript
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');
|
|
|
|
module.exports = function(ctx) {
|
|
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', ctx.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', ctx.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) {
|
|
return ctx.errorResponse(res, 400, '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 ctx.validateSecurePath(containerFullPath, allowedRoots, ctx.auditLogger);
|
|
} catch (error) {
|
|
if (error.constructor.name === 'ValidationError') {
|
|
ctx.auditLogger.logSecurityEvent('path_traversal_attempt', {
|
|
requestedPath, containerFullPath, allowedRoots,
|
|
error: error.message,
|
|
ip: req.ip,
|
|
userAgent: req.get('user-agent')
|
|
});
|
|
return ctx.errorResponse(res, 403, '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()) {
|
|
return ctx.errorResponse(res, 400, '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', ctx.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 ctx.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 = ctx.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;
|
|
};
|