Files
Unraid-Navigator/server/server.js
T
2026-06-22 22:15:33 +02:00

239 lines
7.6 KiB
JavaScript

import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { JobStore, copyPath, movePath } from './jobs.js';
import { parseMultipart, readRequestBody } from './multipart.js';
import { Sandbox, rootsFromEnv } from './sandbox.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const publicDir = path.join(projectRoot, 'public');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png'
};
export async function createApp(options = {}) {
const sandbox = await new Sandbox(options.roots || rootsFromEnv(options.env), {
readOnly: options.readOnly ?? options.env?.U_NAV_READ_ONLY === '1'
}).init();
const jobs = new JobStore();
const uploadLimit = Number(options.uploadLimit || options.env?.U_NAV_UPLOAD_LIMIT || 1024 * 1024 * 100);
return async function app(req, res) {
try {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (url.pathname.startsWith('/api/')) {
await routeApi(req, res, url, sandbox, jobs, uploadLimit);
return;
}
await serveStatic(req, res, url);
} catch (error) {
sendJson(res, error.status || 500, {
error: error.message || 'Internal server error'
});
}
};
}
async function routeApi(req, res, url, sandbox, jobs, uploadLimit) {
if (req.method === 'GET' && url.pathname === '/api/config') {
sendJson(res, 200, sandbox.publicConfig());
return;
}
if (req.method === 'GET' && url.pathname === '/api/list') {
const resolved = await sandbox.resolveExisting(url.searchParams.get('path') || sandbox.roots[0].real);
const entries = await listDirectory(resolved.real);
sendJson(res, 200, { path: resolved.real, entries });
return;
}
if (req.method === 'POST' && url.pathname === '/api/upload') {
sandbox.assertWritable();
const target = url.searchParams.get('path');
const resolved = await sandbox.resolveExisting(target);
const stat = await fs.stat(resolved.real);
if (!stat.isDirectory()) {
throw statusError(400, 'Upload target must be a directory');
}
const body = await readRequestBody(req, uploadLimit);
const parts = parseMultipart(body, req.headers['content-type']);
const saved = [];
for (const part of parts.filter((item) => item.name === 'files' && item.filename)) {
const safeName = path.basename(part.filename);
const destination = path.join(resolved.real, safeName);
await sandbox.resolveDestination(destination);
await fs.writeFile(destination, part.data, { flag: 'wx' });
saved.push({ name: safeName, size: part.data.length });
}
sendJson(res, 201, { uploaded: saved });
return;
}
if (req.method === 'POST' && (url.pathname === '/api/jobs/move' || url.pathname === '/api/jobs/copy')) {
sandbox.assertWritable();
const body = await jsonBody(req);
const source = await sandbox.resolveExisting(body.source);
const destination = await sandbox.resolveDestination(body.destination);
const type = url.pathname.endsWith('/copy') ? 'copy' : 'move';
await ensureDestinationAvailable(destination.path);
ensureNotNested(source.real, destination.path);
const job = jobs.create(type, async (ctx) => {
if (type === 'copy') {
await copyPath(source.real, destination.path, ctx);
} else {
await movePath(source.real, destination.path, ctx);
}
});
sendJson(res, 202, job);
return;
}
const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
if (req.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) {
throw statusError(404, 'Job not found');
}
sendJson(res, 200, job);
return;
}
if (req.method === 'POST' && /^\/api\/jobs\/([^/]+)\/cancel$/.test(url.pathname)) {
const id = /^\/api\/jobs\/([^/]+)\/cancel$/.exec(url.pathname)[1];
const job = jobs.cancel(id);
if (!job) {
throw statusError(404, 'Job not found');
}
sendJson(res, 200, job);
return;
}
throw statusError(404, 'API route not found');
}
async function listDirectory(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const result = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const stat = await fs.lstat(fullPath);
result.push({
name: entry.name,
path: fullPath,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other',
size: stat.size,
modifiedAt: stat.mtime.toISOString()
});
}
result.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
return result;
}
async function serveStatic(req, res, url) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
throw statusError(405, 'Method not allowed');
}
const pathname = url.pathname === '/' ? '/index.html' : url.pathname;
const requested = path.resolve(publicDir, `.${decodeURIComponent(pathname)}`);
const relative = path.relative(publicDir, requested);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw statusError(403, 'Static path outside public directory');
}
try {
const stat = await fs.stat(requested);
if (!stat.isFile()) {
throw statusError(404, 'Not found');
}
res.writeHead(200, {
'Content-Type': MIME[path.extname(requested)] || 'application/octet-stream',
'Content-Length': stat.size
});
if (req.method === 'HEAD') {
res.end();
} else {
createReadStream(requested).pipe(res);
}
} catch (error) {
if (error.code === 'ENOENT') {
throw statusError(404, 'Not found');
}
throw error;
}
}
async function ensureDestinationAvailable(destination) {
try {
await fs.lstat(destination);
throw statusError(409, 'Destination already exists');
} catch (error) {
if (error.code === 'ENOENT') {
return;
}
throw error;
}
}
function ensureNotNested(source, destination) {
const relative = path.relative(source, destination);
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
throw statusError(400, 'Destination cannot be inside source');
}
}
async function jsonBody(req) {
const body = await readRequestBody(req, 1024 * 1024);
try {
return JSON.parse(body.toString('utf8') || '{}');
} catch {
throw statusError(400, 'Invalid JSON body');
}
}
function sendJson(res, status, payload) {
const json = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(json)
});
res.end(json);
}
function statusError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.env.PORT || 8088);
const host = process.env.U_NAV_HOST || process.env.HOST || '127.0.0.1';
createApp({ env: process.env }).then((app) => {
http.createServer(app).listen(port, host, () => {
console.log(`U-Navigator listening on http://${host}:${port}`);
});
}).catch((error) => {
console.error(error.message);
process.exit(1);
});
}