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); }); }