Initial U-Navigator prototype
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user