Initial U-Navigator prototype

This commit is contained in:
Mikei386
2026-06-22 22:11:54 +02:00
commit 37ddea08f4
11 changed files with 1841 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export class JobStore {
constructor() {
this.jobs = new Map();
this.nextId = 1;
}
create(type, task) {
const id = String(this.nextId++);
const job = {
id,
type,
status: 'queued',
progress: 0,
message: '',
error: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
cancelRequested: false
};
this.jobs.set(id, job);
queueMicrotask(async () => {
await this.run(job, task);
});
return this.publicJob(job);
}
get(id) {
const job = this.jobs.get(String(id));
return job ? this.publicJob(job) : null;
}
cancel(id) {
const job = this.jobs.get(String(id));
if (!job) {
return null;
}
job.cancelRequested = true;
this.touch(job, 'cancel_requested', job.progress, 'Cancel requested');
return this.publicJob(job);
}
async run(job, task) {
try {
this.touch(job, 'running', 1, 'Running');
await task({
isCanceled: () => job.cancelRequested,
progress: (progress, message = job.message) => this.touch(job, 'running', progress, message)
});
if (job.cancelRequested) {
this.touch(job, 'canceled', job.progress, 'Canceled');
} else {
this.touch(job, 'completed', 100, 'Completed');
}
} catch (error) {
this.touch(job, job.cancelRequested ? 'canceled' : 'failed', job.progress, error.message);
job.error = error.message;
}
}
touch(job, status, progress, message) {
job.status = status;
job.progress = Math.max(0, Math.min(100, Math.round(progress)));
job.message = message;
job.updatedAt = new Date().toISOString();
}
publicJob(job) {
const { cancelRequested, ...publicFields } = job;
return publicFields;
}
}
export async function movePath(source, destination, ctx) {
if (ctx.isCanceled()) {
throw new Error('Canceled');
}
ctx.progress(10, 'Moving');
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.rename(source, destination);
ctx.progress(95, 'Move finished');
}
export async function copyPath(source, destination, ctx) {
if (ctx.isCanceled()) {
throw new Error('Canceled');
}
ctx.progress(5, 'Preparing copy');
await fs.mkdir(path.dirname(destination), { recursive: true });
await copyRecursive(source, destination, ctx);
ctx.progress(95, 'Copy finished');
}
async function copyRecursive(source, destination, ctx) {
if (ctx.isCanceled()) {
throw new Error('Canceled');
}
const stat = await fs.lstat(source);
if (stat.isSymbolicLink()) {
throw new Error('Copying symlinks is not allowed');
}
if (stat.isDirectory()) {
await fs.mkdir(destination, { recursive: true, mode: stat.mode });
const entries = await fs.readdir(source);
let done = 0;
for (const entry of entries) {
await copyRecursive(path.join(source, entry), path.join(destination, entry), ctx);
done += 1;
ctx.progress(10 + (done / Math.max(entries.length, 1)) * 80, 'Copying directory');
}
return;
}
if (!stat.isFile()) {
throw new Error('Only files and directories can be copied');
}
await fs.copyFile(source, destination);
ctx.progress(85, 'Copying file');
}
+59
View File
@@ -0,0 +1,59 @@
export async function readRequestBody(req, limitBytes) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limitBytes) {
const error = new Error('Request body is too large');
error.status = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
export function parseMultipart(buffer, contentType) {
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
if (!match) {
const error = new Error('Missing multipart boundary');
error.status = 400;
throw error;
}
const boundary = `--${match[1] || match[2]}`;
const binary = buffer.toString('binary');
const parts = [];
for (const raw of binary.split(boundary)) {
if (!raw || raw === '--\r\n' || raw === '--') {
continue;
}
const trimmed = raw.replace(/^\r\n/, '').replace(/\r\n--$/, '');
const separator = trimmed.indexOf('\r\n\r\n');
if (separator === -1) {
continue;
}
const headerText = trimmed.slice(0, separator);
let bodyText = trimmed.slice(separator + 4);
if (bodyText.endsWith('\r\n')) {
bodyText = bodyText.slice(0, -2);
}
const disposition = /content-disposition:\s*form-data;\s*([^\r\n]+)/i.exec(headerText)?.[1] || '';
const name = /name="([^"]+)"/i.exec(disposition)?.[1] || '';
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1] || '';
parts.push({
name,
filename,
data: Buffer.from(bodyText, 'binary')
});
}
return parts;
}
+114
View File
@@ -0,0 +1,114 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export class Sandbox {
constructor(roots, options = {}) {
if (!roots.length) {
throw new Error('At least one allowed root is required');
}
this.readOnly = Boolean(options.readOnly);
this.roots = roots.map((root, index) => ({
id: `root${index + 1}`,
label: root,
path: path.resolve(root)
}));
}
async init() {
const usable = [];
for (const root of this.roots) {
const real = await fs.realpath(root.path);
const stat = await fs.stat(real);
if (!stat.isDirectory()) {
throw new Error(`Allowed root is not a directory: ${root.path}`);
}
usable.push({ ...root, real });
}
this.roots = usable;
return this;
}
publicConfig() {
return {
readOnly: this.readOnly,
roots: this.roots.map((root) => ({
id: root.id,
label: root.label,
path: root.real
}))
};
}
assertWritable() {
if (this.readOnly) {
const error = new Error('Read-only mode is enabled');
error.status = 403;
throw error;
}
}
rootFor(inputPath = '/') {
const normalized = String(inputPath || '/');
if (normalized === '/' || normalized === '') {
return this.roots[0];
}
for (const root of this.roots) {
if (normalized === root.real || normalized.startsWith(`${root.real}${path.sep}`)) {
return root;
}
}
const error = new Error('Path is outside allowed roots');
error.status = 403;
throw error;
}
rootForReal(realPath) {
for (const root of this.roots) {
if (this.isInside(realPath, root.real)) {
return root;
}
}
const error = new Error('Path is outside allowed roots');
error.status = 403;
throw error;
}
async resolveExisting(inputPath = '/') {
const candidate = path.resolve(String(inputPath || this.roots[0].real));
const real = await fs.realpath(candidate);
const root = this.rootForReal(real);
return { root, path: candidate, real };
}
async resolveDestination(inputPath) {
const candidate = path.resolve(String(inputPath || ''));
const parent = path.dirname(candidate);
const parentReal = await fs.realpath(parent);
const root = this.rootForReal(parentReal);
return { root, path: candidate, parentReal };
}
isInside(candidate, root) {
const relative = path.relative(root, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
}
export function rootsFromEnv(env = process.env) {
if (env.U_NAV_ROOTS) {
return env.U_NAV_ROOTS.split(',').map((item) => item.trim()).filter(Boolean);
}
if (env.U_NAV_ROOT) {
return [env.U_NAV_ROOT];
}
return ['/mnt/user'];
}
+237
View File
@@ -0,0 +1,237 @@
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);
createApp({ env: process.env }).then((app) => {
http.createServer(app).listen(port, '127.0.0.1', () => {
console.log(`U-Navigator listening on http://127.0.0.1:${port}`);
});
}).catch((error) => {
console.error(error.message);
process.exit(1);
});
}
+109
View File
@@ -0,0 +1,109 @@
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './server.js';
let root;
let outside;
let baseUrl;
let server;
describe('U-Navigator API', () => {
before(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-root-'));
outside = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-outside-'));
await fs.mkdir(path.join(root, 'share'));
await fs.writeFile(path.join(root, 'share', 'alpha.txt'), 'alpha');
await fs.writeFile(path.join(outside, 'secret.txt'), 'secret');
await fs.symlink(outside, path.join(root, 'share', 'escape'));
const app = await createApp({ roots: [root] });
server = http.createServer(app);
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
it('lists files inside the configured root', async () => {
const result = await json(`/api/list?path=${encodeURIComponent(path.join(root, 'share'))}`);
assert.equal(result.entries.some((entry) => entry.name === 'alpha.txt'), true);
});
it('blocks path traversal outside the root', async () => {
const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(outside)}`);
assert.equal(response.status, 403);
});
it('blocks symlink escapes outside the root', async () => {
const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(path.join(root, 'share', 'escape'))}`);
assert.equal(response.status, 403);
});
it('uploads multipart files into an allowed directory', async () => {
const form = new FormData();
form.append('files', new Blob(['hello']), 'upload.txt');
const response = await fetch(`${baseUrl}/api/upload?path=${encodeURIComponent(path.join(root, 'share'))}`, {
method: 'POST',
body: form
});
assert.equal(response.status, 201);
assert.equal(await fs.readFile(path.join(root, 'share', 'upload.txt'), 'utf8'), 'hello');
});
it('creates move jobs for internal drag and drop operations', async () => {
await fs.writeFile(path.join(root, 'share', 'move-me.txt'), 'move');
await fs.mkdir(path.join(root, 'target'));
const response = await fetch(`${baseUrl}/api/jobs/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: path.join(root, 'share', 'move-me.txt'),
destination: path.join(root, 'target', 'move-me.txt')
})
});
assert.equal(response.status, 202);
const job = await response.json();
await waitForJob(job.id);
assert.equal(await fs.readFile(path.join(root, 'target', 'move-me.txt'), 'utf8'), 'move');
});
it('rejects destinations that already exist', async () => {
const response = await fetch(`${baseUrl}/api/jobs/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: path.join(root, 'share', 'alpha.txt'),
destination: path.join(root, 'share', 'upload.txt')
})
});
assert.equal(response.status, 409);
});
});
async function json(pathname) {
const response = await fetch(`${baseUrl}${pathname}`);
assert.equal(response.ok, true);
return response.json();
}
async function waitForJob(id) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const job = await json(`/api/jobs/${id}`);
if (job.status === 'completed') return job;
if (job.status === 'failed') throw new Error(job.error);
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('Timed out waiting for job');
}