Files
platform/src/servers/api/file-browser/router.ts
T
2026-02-25 00:16:52 +00:00

963 lines
35 KiB
TypeScript

import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
import { readOcrConfig } from '@@/api/server-settings/ocr';
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
async function cleanOldCacheDirs(userDataDir: string) {
for (const dir of OLD_CACHE_DIRS) {
const target = join(userDataDir, dir);
if (existsSync(target)) await rm(target, { recursive: true, force: true });
}
}
async function seedHomeDir(homeDir: string) {
for (const dir of DEFAULT_HOME_DIRS) {
const target = join(homeDir, dir);
if (existsSync(target)) continue;
if (dir === 'Onboarding' && existsSync(ONBOARDING_SEED)) {
await cp(ONBOARDING_SEED, target, { recursive: true });
} else {
await mkdir(target, { recursive: true });
}
}
}
export const router = createRouter();
type UserCtx = { email: string; role: string | null };
function getUserDataDir(email: string): string {
return join(DATA_PATH, email);
}
function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getHomeDir(user.email);
if (root === 'user-data') return getUserDataDir(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 seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true });
}
// Remove old top-level cache dirs (migrated to cache/ prefix)
if (ctx.req.query('root') === 'user-data') {
await cleanOldCacheDirs(rootDir);
}
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, rootDir, 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 });
});
// Write file contents
router.post('/write', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path, content } = ctx.get('body') as { path: string; content: string };
if (!path) throw errors.BAD_REQUEST('path is required');
if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string');
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)');
const absPath = resolveUserPath(rootDir, path);
await mkdir(dirname(absPath), { recursive: true });
await Bun.write(absPath, content);
return ctx.json({ ok: true });
});
// 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 with caching — outputs a seekable MP4 file
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 userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(relPath);
const cacheRel = dir ? `cache/video/${dir}/${name}.mp4` : `cache/video/${name}.mp4`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (!existsSync(cacheAbs)) {
await mkdir(dirname(cacheAbs), { recursive: true });
const tmpPath = cacheAbs + '.tmp';
const proc = Bun.spawn(
[
'ffmpeg', '-i', absPath,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
'-y', tmpPath,
],
{ stdout: 'ignore', stderr: 'pipe' },
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
await unlink(tmpPath).catch(() => {});
throw errors.BAD_REQUEST(stderr.trim() || 'Video transcoding failed');
}
await rename(tmpPath, cacheAbs);
}
const file = Bun.file(cacheAbs);
const total = file.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;
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(chunkSize),
'Accept-Ranges': 'bytes',
},
});
}
}
return new Response(file, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(total),
'Accept-Ranges': 'bytes',
},
});
});
// Transcode audio via ffmpeg for universal playback (outputs MP3)
router.get('/transcode-audio', 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 proc = Bun.spawn(
['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'],
{ stdout: 'pipe', stderr: 'ignore' },
);
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'audio/mpeg',
'Transfer-Encoding': 'chunked',
},
});
});
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/', 'cache/video/'];
router.post('/save-result', async (ctx) => {
const user = ctx.get('user');
const { path: cachedPath } = ctx.get('body') as { path: string };
if (!cachedPath) throw errors.BAD_REQUEST('path is required');
const prefix = CACHE_PREFIXES.find((p) => cachedPath.startsWith(p));
if (!prefix) throw errors.BAD_REQUEST('Not a cached result path');
let relativePath = cachedPath.slice(prefix.length);
// Strip any nested cache prefixes (e.g. tts/ocr/photos/file.mp3 → photos/file.mp3)
let nested: string | undefined;
while ((nested = CACHE_PREFIXES.find((p) => relativePath.startsWith(p)))) {
relativePath = relativePath.slice(nested.length);
}
const userDataDir = getUserDataDir(user.email);
const srcAbs = resolve(userDataDir, cachedPath);
if (!existsSync(srcAbs)) throw errors.BAD_REQUEST('Cached file not found');
const homeDir = getHomeDir(user.email);
const destAbs = resolve(homeDir, relativePath);
if (!destAbs.startsWith(homeDir)) throw errors.FORBIDDEN('Path outside home directory');
await mkdir(dirname(destAbs), { recursive: true });
await cp(srcAbs, destAbs);
const destPath = '/' + destAbs.slice(homeDir.length).replace(/^\/+/, '');
return ctx.json({ savedPath: destPath });
});
// Text-to-speech with caching
router.post('/tts', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `cache/tts/${dir}/${name}.mp3` : `cache/tts/${name}.mp3`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
}
const ttsConfig = await readTtsConfig();
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
const content = await readFile(absPath, 'utf-8');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
let res: Response;
if (ttsConfig.provider === 'elevenlabs') {
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
});
} else {
if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`;
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
method: 'POST',
headers,
body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }),
});
}
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
await mkdir(dirname(cacheAbs), { recursive: true });
const buffer = await res.arrayBuffer();
await Bun.write(cacheAbs, buffer);
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
});
// OCR image via vision model with caching
router.post('/ocr', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `cache/ocr/${dir}/${name}.md` : `cache/ocr/${name}.md`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
const text = await readFile(cacheAbs, 'utf-8');
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true });
}
const ocrConfig = await readOcrConfig();
if (!ocrConfig) throw errors.BAD_REQUEST('OCR not configured — set it up in Settings → OCR');
const imageBytes = await Bun.file(absPath).arrayBuffer();
const base64 = Buffer.from(imageBytes).toString('base64');
const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png';
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`;
const res = await fetch(`${ocrConfig.url.replace(/\/+$/, '')}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ocrConfig.model,
messages: [
{
role: 'system',
content: [
'You are an OCR assistant. Extract meaningful text content from images.',
'Rules:',
'- Output ONLY the extracted text, no commentary or explanations.',
'- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.',
'- For tables: use markdown table format.',
'- For code/terminal screenshots: use fenced code blocks.',
'- For social media posts/threads: extract as clean conversation. Format as:',
' **username** says: "their text"',
' **replier** replies: "their text"',
' Strip all UI chrome (follow buttons, timestamps, like counts, avatars, "Everybody can reply", etc).',
' Keep only usernames and what they actually wrote.',
'- For memes/image macros: describe the image briefly, then extract any text.',
'- For handwriting: transcribe as accurately as possible.',
'- For receipts/invoices: extract as structured text with line items.',
'- Strip all UI elements, navigation, ads, watermarks, and other noise.',
'- Preserve the original language of the text.',
].join('\n'),
},
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } },
{ type: 'text', text: 'Extract the text from this image.' },
],
},
],
}),
});
if (!res.ok) throw errors.BAD_REQUEST('OCR request failed');
const json = (await res.json()) as { choices: { message: { content: string } }[] };
const text = json.choices[0]?.message?.content ?? '';
await mkdir(dirname(cacheAbs), { recursive: true });
await Bun.write(cacheAbs, text);
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false });
});
// Extract audio from video via ffmpeg with caching
router.post('/extract-audio', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `cache/audio/${dir}/${name}.mp3` : `cache/audio/${name}.mp3`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: true });
}
await mkdir(dirname(cacheAbs), { recursive: true });
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], {
stdout: 'ignore',
stderr: 'pipe',
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed');
}
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: false });
});
// Extract archive (zip, tar, 7z, rar) into a sibling folder
router.post('/extract', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory');
const fileName = absPath.split('/').pop()!;
const lower = fileName.toLowerCase();
// Determine archive type and build command
type ArchiveType = 'tar' | 'zip' | '7z' | 'rar';
let archiveType: ArchiveType;
if (
lower.endsWith('.tar') ||
lower.endsWith('.tar.gz') ||
lower.endsWith('.tgz') ||
lower.endsWith('.tar.bz2') ||
lower.endsWith('.tbz2') ||
lower.endsWith('.tar.xz') ||
lower.endsWith('.txz') ||
lower.endsWith('.tar.zst') ||
lower.endsWith('.gz') ||
lower.endsWith('.bz2') ||
lower.endsWith('.xz') ||
lower.endsWith('.zst')
) {
archiveType = 'tar';
} else if (lower.endsWith('.zip')) {
archiveType = 'zip';
} else if (lower.endsWith('.7z')) {
archiveType = '7z';
} else if (lower.endsWith('.rar')) {
archiveType = 'rar';
} else {
throw errors.BAD_REQUEST('Unsupported archive format');
}
// Compute destination folder name (strip archive extension)
const stripArchiveExt = (name: string): string => {
const l = name.toLowerCase();
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
if (l.endsWith(compound)) return name.slice(0, -compound.length);
}
const dotIdx = name.lastIndexOf('.');
return dotIdx > 0 ? name.slice(0, dotIdx) : name;
};
const baseName = stripArchiveExt(fileName);
const destPath = resolve(dirname(absPath), baseName);
const finalDest = await resolveCollision(destPath);
await mkdir(finalDest, { recursive: true });
let cmd: string[];
switch (archiveType) {
case 'tar':
cmd = ['tar', 'xf', absPath, '-C', finalDest];
break;
case 'zip':
cmd = ['unzip', '-q', absPath, '-d', finalDest];
break;
case '7z':
cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y'];
break;
case 'rar':
cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`];
break;
}
const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
// Clean up the empty directory on failure
await rm(finalDest, { recursive: true, force: true }).catch(() => {});
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
}
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ extractedPath });
});
// Transcribe audio via Whisper with caching
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
router.post('/transcribe', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `cache/transcriptions/${dir}/${name}.md` : `cache/transcriptions/${name}.md`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true });
}
const sttConfig = await readSttConfig();
const whisperUrl = sttConfig?.url;
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text');
const audioFile = Bun.file(absPath);
// Step 1: Detect language
const detectForm = new FormData();
detectForm.append('file', audioFile);
detectForm.append('temperature', '0.0');
detectForm.append('response_format', 'verbose_json');
detectForm.append('detect_language', 'true');
const detectRes = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: detectForm });
if (!detectRes.ok) throw errors.BAD_REQUEST('Language detection failed');
const detectJson = (await detectRes.json()) as { language?: string };
const detectedLang = detectJson.language ?? 'en';
// Step 2: Check user's spoken languages to decide if translation is needed
let shouldTranslate = false;
const settingsFile = Bun.file(getUserSettingsFile(user.email));
if (await settingsFile.exists()) {
const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } };
const spokenLanguages = settings.languages?.spoken ?? [];
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
shouldTranslate = true;
}
}
// Step 3: Full transcription
const transcribeForm = new FormData();
transcribeForm.append('file', audioFile);
transcribeForm.append('temperature', '0.0');
transcribeForm.append('temperature_inc', '0.2');
transcribeForm.append('response_format', 'text');
transcribeForm.append('language', detectedLang);
if (shouldTranslate) {
transcribeForm.append('translate', 'true');
}
const res = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: transcribeForm });
if (!res.ok) throw errors.BAD_REQUEST('Transcription request failed');
const text = (await res.text()).trim();
await mkdir(dirname(cacheAbs), { recursive: true });
await Bun.write(cacheAbs, text);
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false });
});
// 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 });
});
// Download video via yt-dlp
router.post('/download-video', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { url, path, audioOnly } = ctx.get('body') as { url: string; path: string; audioOnly?: boolean };
if (!url) throw errors.BAD_REQUEST('url is required');
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
const args = ['yt-dlp', '-o', '%(title)s.%(ext)s'];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
const proc = Bun.spawn(args, { 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() || 'yt-dlp download failed');
}
return ctx.json({ ok: true });
});
// 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 });
});
// Download file or directory (directories are zipped on-the-fly)
router.get('/download', 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);
const name = absPath.split('/').pop()!;
if (s.isDirectory()) {
const proc = Bun.spawn(['zip', '-r', '-', name], {
cwd: dirname(absPath),
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${name}.zip"`,
},
});
}
const file = Bun.file(absPath);
return new Response(file, {
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${name}"`,
'Content-Length': String(s.size),
},
});
});
// Download multiple items as a single zip
router.post('/download', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { paths } = ctx.get('body') as { paths: string[] };
if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required');
const items: string[] = [];
for (const p of paths) {
const relPath = p.replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
await stat(absPath); // throws if not found
items.push(relPath);
}
const proc = Bun.spawn(['zip', '-r', '-', ...items], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="download.zip"',
},
});
});
// 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 });
});