File browser as widget

This commit is contained in:
2026-02-17 19:23:01 +00:00
parent 0746844d6f
commit 21213c281d
27 changed files with 23 additions and 34 deletions
+413
View File
@@ -0,0 +1,413 @@
import { createRouter } from '@@/create-router';
import { resolve, dirname } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { homedir } from 'node:os';
import { getHomeDir } from '@@/data-path';
import * as errors from '@@/custom-errors';
export const router = createRouter();
type UserCtx = { email: string; role: string | null };
function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getHomeDir(user.email);
if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root');
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
}
function resolveUserPath(rootDir: string, relPath: string): string {
const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
if (!resolved.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside root directory');
return resolved;
}
// List directory entries
router.get('/ls', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
// Auto-create dir if missing (only for user home root)
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
await mkdir(absPath, { recursive: true });
}
let names: string[];
try {
names = await readdir(absPath);
} catch {
return ctx.json({ path: '/', entries: [], reset: true });
}
const entries = await Promise.all(
names.map(async (name) => {
const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe)
if (!fullPath.startsWith(rootDir)) return null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
return {
name,
type: s.isDirectory() ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
};
}),
);
const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ path, entries: entries.filter(Boolean) });
});
// Read file contents
router.get('/read', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read a directory');
const MAX_TEXT_SIZE = 5 * 1024 * 1024; // 5 MB
if (s.size > MAX_TEXT_SIZE) throw errors.BAD_REQUEST('File too large to read (max 5 MB)');
const content = await readFile(absPath, 'utf-8');
return ctx.json({ content, size: s.size });
});
// Create directory
router.post('/mkdir', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path } = ctx.get('body') as { path: string };
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
return ctx.json({ ok: true });
});
// Upload files
router.post('/upload', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const targetDir = resolveUserPath(rootDir, relPath);
await mkdir(targetDir, { recursive: true });
const body = ctx.get('body') as Record<string, unknown>;
const raw = body['file'];
const files = Array.isArray(raw) ? raw : raw ? [raw] : [];
for (const file of files) {
if (!(file instanceof File)) continue;
const filePath = resolve(targetDir, file.name);
if (!filePath.startsWith(rootDir)) continue;
await mkdir(dirname(filePath), { recursive: true });
await Bun.write(filePath, file);
}
return ctx.json({ ok: true });
});
// Rename file or directory
router.post('/rename', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path: filePath, newName } = ctx.get('body') as { path: string; newName: string };
if (!filePath || !newName) throw errors.BAD_REQUEST('path and newName are required');
if (newName.includes('/')) throw errors.BAD_REQUEST('newName must not contain /');
const absPath = resolveUserPath(rootDir, filePath);
if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory');
const newPath = resolve(dirname(absPath), newName);
if (!newPath.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside home directory');
await rename(absPath, newPath);
return ctx.json({ ok: true });
});
// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory');
const file = Bun.file(absPath);
const contentType = file.type || 'application/octet-stream';
const total = s.size;
const rangeHeader = ctx.req.header('range');
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : total - 1;
const chunkSize = end - start + 1;
const slice = file.slice(start, end + 1);
return new Response(slice, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(chunkSize),
'Accept-Ranges': 'bytes',
},
});
}
}
return new Response(file, {
headers: {
'Content-Type': contentType,
'Content-Length': String(total),
'Accept-Ranges': 'bytes',
},
});
});
// Transcode video via ffmpeg for non-native browser formats (mkv, avi, wmv, etc.)
// Outputs fragmented MP4 streamed to the client
router.get('/transcode', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const startTime = ctx.req.query('t') || '0';
const proc = Bun.spawn(
[
'ffmpeg',
'-ss',
startTime,
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'frag_mp4+empty_moov+default_base_moof',
'-f',
'mp4',
'pipe:1',
],
{ stdout: 'pipe', stderr: 'ignore' },
);
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'video/mp4',
'Transfer-Encoding': 'chunked',
},
});
});
// Search files by name
router.get('/search', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const query = (ctx.req.query('q') || '').trim().toLowerCase();
if (!query) throw errors.BAD_REQUEST('q is required');
const MAX_RESULTS = 50;
const results: { path: string; name: string; type: string; size: number; modifiedAt: number }[] = [];
async function walk(dir: string) {
if (results.length >= MAX_RESULTS) return;
const names = await readdir(dir).catch(() => [] as string[]);
for (const name of names) {
if (results.length >= MAX_RESULTS) break;
const fullPath = resolve(dir, name);
if (!fullPath.startsWith(rootDir)) continue;
const s = await stat(fullPath).catch(() => null);
if (!s) continue;
if (name.toLowerCase().includes(query)) {
const relPath = '/' + fullPath.slice(rootDir.length).replace(/^\/+/, '');
results.push({
path: relPath,
name,
type: s.isDirectory() ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
});
}
if (s.isDirectory()) await walk(fullPath);
}
}
await walk(rootDir);
return ctx.json({ results });
});
// Resolve a destination path, appending " (copy)", " (copy 2)", etc. if it already exists
async function resolveCollision(destPath: string): Promise<string> {
try {
await stat(destPath);
} catch {
return destPath;
}
const dir = dirname(destPath);
const base = destPath.split('/').pop()!;
const dotIdx = base.lastIndexOf('.');
const name = dotIdx > 0 ? base.slice(0, dotIdx) : base;
const ext = dotIdx > 0 ? base.slice(dotIdx) : '';
let n = 1;
while (true) {
const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`;
const candidate = resolve(dir, `${name}${suffix}${ext}`);
try {
await stat(candidate);
n++;
} catch {
return candidate;
}
}
}
type CopyMoveItem = { source: string; destination: string };
type CopyMoveBody = CopyMoveItem | { items: CopyMoveItem[] };
function parseCopyMoveBody(body: CopyMoveBody): CopyMoveItem[] {
if ('items' in body && Array.isArray(body.items)) return body.items;
return [body as CopyMoveItem];
}
// Copy file or directory
router.post('/copy', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = ctx.get('body') as CopyMoveBody;
const items = parseCopyMoveBody(body);
if (items.length === 0) throw errors.BAD_REQUEST('No items provided');
const results: { source: string; destination: string; error?: string }[] = [];
for (const item of items) {
if (!item.source || !item.destination) {
results.push({
source: item.source,
destination: item.destination,
error: 'source and destination are required',
});
continue;
}
try {
const srcAbs = resolveUserPath(rootDir, item.source);
const destAbs = resolveUserPath(rootDir, item.destination);
await mkdir(dirname(destAbs), { recursive: true });
const finalDest = await resolveCollision(destAbs);
await cp(srcAbs, finalDest, { recursive: true });
const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
results.push({ source: item.source, destination: relDest });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.push({ source: item.source, destination: item.destination, error: msg });
}
}
return ctx.json({ ok: true, results });
});
// Move file or directory
router.post('/move', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = ctx.get('body') as CopyMoveBody;
const items = parseCopyMoveBody(body);
if (items.length === 0) throw errors.BAD_REQUEST('No items provided');
const results: { source: string; destination: string; error?: string }[] = [];
for (const item of items) {
if (!item.source || !item.destination) {
results.push({
source: item.source,
destination: item.destination,
error: 'source and destination are required',
});
continue;
}
try {
const srcAbs = resolveUserPath(rootDir, item.source);
const destAbs = resolveUserPath(rootDir, item.destination);
// Prevent moving a directory into itself
if (destAbs.startsWith(srcAbs + '/')) {
throw new Error('Cannot move a directory into itself');
}
await mkdir(dirname(destAbs), { recursive: true });
const finalDest = await resolveCollision(destAbs);
await rename(srcAbs, finalDest);
const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
results.push({ source: item.source, destination: relDest });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.push({ source: item.source, destination: item.destination, error: msg });
}
}
return ctx.json({ ok: true, results });
});
// Git clone a repository into a directory
router.post('/git-clone', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { url, path } = ctx.get('body') as { url: string; path: string };
if (!url) throw errors.BAD_REQUEST('url is required');
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed');
}
return ctx.json({ ok: true });
});
// Delete file or directory
router.delete('/rm', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = await ctx.req.json<{ path: string }>();
const { path } = body;
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
// Prevent deleting the home dir itself
if (absPath === rootDir) throw errors.FORBIDDEN('Cannot delete home directory');
await rm(absPath, { recursive: true, force: true });
return ctx.json({ ok: true });
});