馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 createSession, 6 createUser, 7 deleteSession, 8 deleteUser, 9 getSession, 10 getSessionFromRequest, 11 getUserBySession, 12 getUserSessionsForUser, 13 updateUserAvatar, 14 updateUserEmail, 15 updateUserName, 16 updateUserPassword, 17} from "./lib/auth"; 18import { handleError, ValidationErrors } from "./lib/errors"; 19import { requireAuth } from "./lib/middleware"; 20import { 21 MAX_FILE_SIZE, 22 TranscriptionEventEmitter, 23 type TranscriptionUpdate, 24 WhisperServiceManager, 25} from "./lib/transcription"; 26import indexHTML from "./pages/index.html"; 27import settingsHTML from "./pages/settings.html"; 28import transcribeHTML from "./pages/transcribe.html"; 29 30// Environment variables 31const WHISPER_SERVICE_URL = 32 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 33 34// Create uploads directory if it doesn't exist 35await Bun.write("./uploads/.gitkeep", ""); 36 37// Initialize transcription system 38const transcriptionEvents = new TranscriptionEventEmitter(); 39const whisperService = new WhisperServiceManager( 40 WHISPER_SERVICE_URL, 41 db, 42 transcriptionEvents, 43); 44 45// Clean up expired sessions every hour 46setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 47 48// Sync with Whisper DB on startup 49await whisperService.syncWithWhisper(); 50 51// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 52setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000); 53 54// Clean up stale files daily 55setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); 56 57const server = Bun.serve({ 58 port: 3000, 59 idleTimeout: 120, // 120 seconds for SSE connections 60 routes: { 61 "/": indexHTML, 62 "/settings": settingsHTML, 63 "/transcribe": transcribeHTML, 64 "/api/auth/register": { 65 POST: async (req) => { 66 try { 67 const body = await req.json(); 68 const { email, password, name } = body; 69 if (!email || !password) { 70 return Response.json( 71 { error: "Email and password required" }, 72 { status: 400 }, 73 ); 74 } 75 if (password.length < 8) { 76 return Response.json( 77 { error: "Password must be at least 8 characters" }, 78 { status: 400 }, 79 ); 80 } 81 const user = await createUser(email, password, name); 82 const ipAddress = 83 req.headers.get("x-forwarded-for") ?? 84 req.headers.get("x-real-ip") ?? 85 "unknown"; 86 const userAgent = req.headers.get("user-agent") ?? "unknown"; 87 const sessionId = createSession(user.id, ipAddress, userAgent); 88 return Response.json( 89 { user: { id: user.id, email: user.email } }, 90 { 91 headers: { 92 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 93 }, 94 }, 95 ); 96 } catch (err: unknown) { 97 const error = err as { message?: string }; 98 if (error.message?.includes("UNIQUE constraint failed")) { 99 return Response.json( 100 { error: "Email already registered" }, 101 { status: 400 }, 102 ); 103 } 104 return Response.json( 105 { error: "Registration failed" }, 106 { status: 500 }, 107 ); 108 } 109 }, 110 }, 111 "/api/auth/login": { 112 POST: async (req) => { 113 try { 114 const body = await req.json(); 115 const { email, password } = body; 116 if (!email || !password) { 117 return Response.json( 118 { error: "Email and password required" }, 119 { status: 400 }, 120 ); 121 } 122 const user = await authenticateUser(email, password); 123 if (!user) { 124 return Response.json( 125 { error: "Invalid email or password" }, 126 { status: 401 }, 127 ); 128 } 129 const ipAddress = 130 req.headers.get("x-forwarded-for") ?? 131 req.headers.get("x-real-ip") ?? 132 "unknown"; 133 const userAgent = req.headers.get("user-agent") ?? "unknown"; 134 const sessionId = createSession(user.id, ipAddress, userAgent); 135 return Response.json( 136 { user: { id: user.id, email: user.email } }, 137 { 138 headers: { 139 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 140 }, 141 }, 142 ); 143 } catch { 144 return Response.json({ error: "Login failed" }, { status: 500 }); 145 } 146 }, 147 }, 148 "/api/auth/logout": { 149 POST: async (req) => { 150 const sessionId = getSessionFromRequest(req); 151 if (sessionId) { 152 deleteSession(sessionId); 153 } 154 return Response.json( 155 { success: true }, 156 { 157 headers: { 158 "Set-Cookie": 159 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax", 160 }, 161 }, 162 ); 163 }, 164 }, 165 "/api/auth/me": { 166 GET: (req) => { 167 const sessionId = getSessionFromRequest(req); 168 if (!sessionId) { 169 return Response.json({ error: "Not authenticated" }, { status: 401 }); 170 } 171 const user = getUserBySession(sessionId); 172 if (!user) { 173 return Response.json({ error: "Invalid session" }, { status: 401 }); 174 } 175 return Response.json({ 176 email: user.email, 177 name: user.name, 178 avatar: user.avatar, 179 }); 180 }, 181 }, 182 "/api/sessions": { 183 GET: (req) => { 184 const sessionId = getSessionFromRequest(req); 185 if (!sessionId) { 186 return Response.json({ error: "Not authenticated" }, { status: 401 }); 187 } 188 const user = getUserBySession(sessionId); 189 if (!user) { 190 return Response.json({ error: "Invalid session" }, { status: 401 }); 191 } 192 const sessions = getUserSessionsForUser(user.id); 193 return Response.json({ 194 sessions: sessions.map((s) => ({ 195 id: s.id, 196 ip_address: s.ip_address, 197 user_agent: s.user_agent, 198 created_at: s.created_at, 199 expires_at: s.expires_at, 200 })), 201 }); 202 }, 203 DELETE: async (req) => { 204 const currentSessionId = getSessionFromRequest(req); 205 if (!currentSessionId) { 206 return Response.json({ error: "Not authenticated" }, { status: 401 }); 207 } 208 const user = getUserBySession(currentSessionId); 209 if (!user) { 210 return Response.json({ error: "Invalid session" }, { status: 401 }); 211 } 212 const body = await req.json(); 213 const targetSessionId = body.sessionId; 214 if (!targetSessionId) { 215 return Response.json( 216 { error: "Session ID required" }, 217 { status: 400 }, 218 ); 219 } 220 // Verify the session belongs to the user 221 const targetSession = getSession(targetSessionId); 222 if (!targetSession || targetSession.user_id !== user.id) { 223 return Response.json({ error: "Session not found" }, { status: 404 }); 224 } 225 deleteSession(targetSessionId); 226 return Response.json({ success: true }); 227 }, 228 }, 229 "/api/user": { 230 DELETE: (req) => { 231 const sessionId = getSessionFromRequest(req); 232 if (!sessionId) { 233 return Response.json({ error: "Not authenticated" }, { status: 401 }); 234 } 235 const user = getUserBySession(sessionId); 236 if (!user) { 237 return Response.json({ error: "Invalid session" }, { status: 401 }); 238 } 239 deleteUser(user.id); 240 return Response.json( 241 { success: true }, 242 { 243 headers: { 244 "Set-Cookie": 245 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax", 246 }, 247 }, 248 ); 249 }, 250 }, 251 "/api/user/email": { 252 PUT: async (req) => { 253 const sessionId = getSessionFromRequest(req); 254 if (!sessionId) { 255 return Response.json({ error: "Not authenticated" }, { status: 401 }); 256 } 257 const user = getUserBySession(sessionId); 258 if (!user) { 259 return Response.json({ error: "Invalid session" }, { status: 401 }); 260 } 261 const body = await req.json(); 262 const { email } = body; 263 if (!email) { 264 return Response.json({ error: "Email required" }, { status: 400 }); 265 } 266 try { 267 updateUserEmail(user.id, email); 268 return Response.json({ success: true }); 269 } catch (err: unknown) { 270 const error = err as { message?: string }; 271 if (error.message?.includes("UNIQUE constraint failed")) { 272 return Response.json( 273 { error: "Email already in use" }, 274 { status: 400 }, 275 ); 276 } 277 return Response.json( 278 { error: "Failed to update email" }, 279 { status: 500 }, 280 ); 281 } 282 }, 283 }, 284 "/api/user/password": { 285 PUT: async (req) => { 286 const sessionId = getSessionFromRequest(req); 287 if (!sessionId) { 288 return Response.json({ error: "Not authenticated" }, { status: 401 }); 289 } 290 const user = getUserBySession(sessionId); 291 if (!user) { 292 return Response.json({ error: "Invalid session" }, { status: 401 }); 293 } 294 const body = await req.json(); 295 const { password } = body; 296 if (!password) { 297 return Response.json({ error: "Password required" }, { status: 400 }); 298 } 299 if (password.length < 8) { 300 return Response.json( 301 { error: "Password must be at least 8 characters" }, 302 { status: 400 }, 303 ); 304 } 305 try { 306 await updateUserPassword(user.id, password); 307 return Response.json({ success: true }); 308 } catch { 309 return Response.json( 310 { error: "Failed to update password" }, 311 { status: 500 }, 312 ); 313 } 314 }, 315 }, 316 "/api/user/name": { 317 PUT: async (req) => { 318 const sessionId = getSessionFromRequest(req); 319 if (!sessionId) { 320 return Response.json({ error: "Not authenticated" }, { status: 401 }); 321 } 322 const user = getUserBySession(sessionId); 323 if (!user) { 324 return Response.json({ error: "Invalid session" }, { status: 401 }); 325 } 326 const body = await req.json(); 327 const { name } = body; 328 if (!name) { 329 return Response.json({ error: "Name required" }, { status: 400 }); 330 } 331 try { 332 updateUserName(user.id, name); 333 return Response.json({ success: true }); 334 } catch { 335 return Response.json( 336 { error: "Failed to update name" }, 337 { status: 500 }, 338 ); 339 } 340 }, 341 }, 342 "/api/user/avatar": { 343 PUT: async (req) => { 344 const sessionId = getSessionFromRequest(req); 345 if (!sessionId) { 346 return Response.json({ error: "Not authenticated" }, { status: 401 }); 347 } 348 const user = getUserBySession(sessionId); 349 if (!user) { 350 return Response.json({ error: "Invalid session" }, { status: 401 }); 351 } 352 const body = await req.json(); 353 const { avatar } = body; 354 if (!avatar) { 355 return Response.json({ error: "Avatar required" }, { status: 400 }); 356 } 357 try { 358 updateUserAvatar(user.id, avatar); 359 return Response.json({ success: true }); 360 } catch { 361 return Response.json( 362 { error: "Failed to update avatar" }, 363 { status: 500 }, 364 ); 365 } 366 }, 367 }, 368 "/api/transcriptions/:id/stream": { 369 GET: (req) => { 370 const sessionId = getSessionFromRequest(req); 371 if (!sessionId) { 372 return Response.json({ error: "Not authenticated" }, { status: 401 }); 373 } 374 const user = getUserBySession(sessionId); 375 if (!user) { 376 return Response.json({ error: "Invalid session" }, { status: 401 }); 377 } 378 const transcriptionId = req.params.id; 379 // Verify ownership 380 const transcription = db 381 .query<{ id: string; user_id: number; status: string }, [string]>( 382 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 383 ) 384 .get(transcriptionId); 385 if (!transcription || transcription.user_id !== user.id) { 386 return Response.json( 387 { error: "Transcription not found" }, 388 { status: 404 }, 389 ); 390 } 391 // Event-driven SSE stream (NO POLLING!) 392 const stream = new ReadableStream({ 393 start(controller) { 394 const encoder = new TextEncoder(); 395 396 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 397 controller.enqueue( 398 encoder.encode(`data: ${JSON.stringify(data)}\n\n`), 399 ); 400 }; 401 // Send initial state from DB 402 const current = db 403 .query< 404 { 405 status: string; 406 progress: number; 407 transcript: string | null; 408 }, 409 [string] 410 >( 411 "SELECT status, progress, transcript FROM transcriptions WHERE id = ?", 412 ) 413 .get(transcriptionId); 414 if (current) { 415 sendEvent({ 416 status: current.status as TranscriptionUpdate["status"], 417 progress: current.progress, 418 transcript: current.transcript || undefined, 419 }); 420 } 421 // If already complete, close immediately 422 if ( 423 current?.status === "completed" || 424 current?.status === "failed" 425 ) { 426 controller.close(); 427 return; 428 } 429 // Subscribe to EventEmitter for live updates 430 const updateHandler = (data: TranscriptionUpdate) => { 431 console.log(`[SSE to client] Job ${transcriptionId}:`, data); 432 // Only send changed fields to save bandwidth 433 const payload: Partial<TranscriptionUpdate> = { 434 status: data.status, 435 progress: data.progress, 436 }; 437 438 if (data.transcript !== undefined) { 439 payload.transcript = data.transcript; 440 } 441 if (data.error_message !== undefined) { 442 payload.error_message = data.error_message; 443 } 444 445 sendEvent(payload); 446 447 // Close stream when done 448 if (data.status === "completed" || data.status === "failed") { 449 transcriptionEvents.off(transcriptionId, updateHandler); 450 controller.close(); 451 } 452 }; 453 transcriptionEvents.on(transcriptionId, updateHandler); 454 // Cleanup on client disconnect 455 return () => { 456 transcriptionEvents.off(transcriptionId, updateHandler); 457 }; 458 }, 459 }); 460 return new Response(stream, { 461 headers: { 462 "Content-Type": "text/event-stream", 463 "Cache-Control": "no-cache", 464 Connection: "keep-alive", 465 }, 466 }); 467 }, 468 }, 469 "/api/transcriptions": { 470 GET: (req) => { 471 try { 472 const user = requireAuth(req); 473 474 const transcriptions = db 475 .query< 476 { 477 id: string; 478 filename: string; 479 original_filename: string; 480 status: string; 481 progress: number; 482 transcript: string | null; 483 created_at: number; 484 }, 485 [number] 486 >( 487 "SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 488 ) 489 .all(user.id); 490 491 return Response.json({ 492 jobs: transcriptions.map((t) => ({ 493 id: t.id, 494 filename: t.original_filename, 495 status: t.status, 496 progress: t.progress, 497 transcript: t.transcript, 498 created_at: t.created_at, 499 })), 500 }); 501 } catch (error) { 502 return handleError(error); 503 } 504 }, 505 POST: async (req) => { 506 try { 507 const user = requireAuth(req); 508 509 const formData = await req.formData(); 510 const file = formData.get("audio") as File; 511 512 if (!file) throw ValidationErrors.missingField("audio"); 513 514 if (!file.type.startsWith("audio/")) { 515 throw ValidationErrors.unsupportedFileType( 516 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 517 ); 518 } 519 520 if (file.size > MAX_FILE_SIZE) { 521 throw ValidationErrors.fileTooLarge("25MB"); 522 } 523 524 // Generate unique filename 525 const transcriptionId = crypto.randomUUID(); 526 const fileExtension = file.name.split(".").pop(); 527 const filename = `${transcriptionId}.${fileExtension}`; 528 529 // Save file to disk 530 const uploadDir = "./uploads"; 531 await Bun.write(`${uploadDir}/${filename}`, file); 532 533 // Create database record 534 db.run( 535 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 536 [transcriptionId, user.id, filename, file.name, "uploading"], 537 ); 538 539 // Start transcription in background 540 whisperService.startTranscription(transcriptionId, filename); 541 542 return Response.json({ 543 id: transcriptionId, 544 message: "Upload successful, transcription started", 545 }); 546 } catch (error) { 547 return handleError(error); 548 } 549 }, 550 }, 551 }, 552 development: { 553 hmr: true, 554 console: true, 555 }, 556}); 557console.log(`馃 Thistle running at http://localhost:${server.port}`);