963 lines
35 KiB
TypeScript
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 });
|
|
});
|