File browser as widget
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user