馃 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 created_at: user.created_at, 180 }); 181 }, 182 }, 183 "/api/sessions": { 184 GET: (req) => { 185 const sessionId = getSessionFromRequest(req); 186 if (!sessionId) { 187 return Response.json({ error: "Not authenticated" }, { status: 401 }); 188 } 189 const user = getUserBySession(sessionId); 190 if (!user) { 191 return Response.json({ error: "Invalid session" }, { status: 401 }); 192 } 193 const sessions = getUserSessionsForUser(user.id); 194 return Response.json({ 195 sessions: sessions.map((s) => ({ 196 id: s.id, 197 ip_address: s.ip_address, 198 user_agent: s.user_agent, 199 created_at: s.created_at, 200 expires_at: s.expires_at, 201 })), 202 }); 203 }, 204 DELETE: async (req) => { 205 const currentSessionId = getSessionFromRequest(req); 206 if (!currentSessionId) { 207 return Response.json({ error: "Not authenticated" }, { status: 401 }); 208 } 209 const user = getUserBySession(currentSessionId); 210 if (!user) { 211 return Response.json({ error: "Invalid session" }, { status: 401 }); 212 } 213 const body = await req.json(); 214 const targetSessionId = body.sessionId; 215 if (!targetSessionId) { 216 return Response.json( 217 { error: "Session ID required" }, 218 { status: 400 }, 219 ); 220 } 221 // Verify the session belongs to the user 222 const targetSession = getSession(targetSessionId); 223 if (!targetSession || targetSession.user_id !== user.id) { 224 return Response.json({ error: "Session not found" }, { status: 404 }); 225 } 226 deleteSession(targetSessionId); 227 return Response.json({ success: true }); 228 }, 229 }, 230 "/api/user": { 231 DELETE: (req) => { 232 const sessionId = getSessionFromRequest(req); 233 if (!sessionId) { 234 return Response.json({ error: "Not authenticated" }, { status: 401 }); 235 } 236 const user = getUserBySession(sessionId); 237 if (!user) { 238 return Response.json({ error: "Invalid session" }, { status: 401 }); 239 } 240 deleteUser(user.id); 241 return Response.json( 242 { success: true }, 243 { 244 headers: { 245 "Set-Cookie": 246 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax", 247 }, 248 }, 249 ); 250 }, 251 }, 252 "/api/user/email": { 253 PUT: async (req) => { 254 const sessionId = getSessionFromRequest(req); 255 if (!sessionId) { 256 return Response.json({ error: "Not authenticated" }, { status: 401 }); 257 } 258 const user = getUserBySession(sessionId); 259 if (!user) { 260 return Response.json({ error: "Invalid session" }, { status: 401 }); 261 } 262 const body = await req.json(); 263 const { email } = body; 264 if (!email) { 265 return Response.json({ error: "Email required" }, { status: 400 }); 266 } 267 try { 268 updateUserEmail(user.id, email); 269 return Response.json({ success: true }); 270 } catch (err: unknown) { 271 const error = err as { message?: string }; 272 if (error.message?.includes("UNIQUE constraint failed")) { 273 return Response.json( 274 { error: "Email already in use" }, 275 { status: 400 }, 276 ); 277 } 278 return Response.json( 279 { error: "Failed to update email" }, 280 { status: 500 }, 281 ); 282 } 283 }, 284 }, 285 "/api/user/password": { 286 PUT: async (req) => { 287 const sessionId = getSessionFromRequest(req); 288 if (!sessionId) { 289 return Response.json({ error: "Not authenticated" }, { status: 401 }); 290 } 291 const user = getUserBySession(sessionId); 292 if (!user) { 293 return Response.json({ error: "Invalid session" }, { status: 401 }); 294 } 295 const body = await req.json(); 296 const { password } = body; 297 if (!password) { 298 return Response.json({ error: "Password required" }, { status: 400 }); 299 } 300 if (password.length < 8) { 301 return Response.json( 302 { error: "Password must be at least 8 characters" }, 303 { status: 400 }, 304 ); 305 } 306 try { 307 await updateUserPassword(user.id, password); 308 return Response.json({ success: true }); 309 } catch { 310 return Response.json( 311 { error: "Failed to update password" }, 312 { status: 500 }, 313 ); 314 } 315 }, 316 }, 317 "/api/user/name": { 318 PUT: async (req) => { 319 const sessionId = getSessionFromRequest(req); 320 if (!sessionId) { 321 return Response.json({ error: "Not authenticated" }, { status: 401 }); 322 } 323 const user = getUserBySession(sessionId); 324 if (!user) { 325 return Response.json({ error: "Invalid session" }, { status: 401 }); 326 } 327 const body = await req.json(); 328 const { name } = body; 329 if (!name) { 330 return Response.json({ error: "Name required" }, { status: 400 }); 331 } 332 try { 333 updateUserName(user.id, name); 334 return Response.json({ success: true }); 335 } catch { 336 return Response.json( 337 { error: "Failed to update name" }, 338 { status: 500 }, 339 ); 340 } 341 }, 342 }, 343 "/api/user/avatar": { 344 PUT: async (req) => { 345 const sessionId = getSessionFromRequest(req); 346 if (!sessionId) { 347 return Response.json({ error: "Not authenticated" }, { status: 401 }); 348 } 349 const user = getUserBySession(sessionId); 350 if (!user) { 351 return Response.json({ error: "Invalid session" }, { status: 401 }); 352 } 353 const body = await req.json(); 354 const { avatar } = body; 355 if (!avatar) { 356 return Response.json({ error: "Avatar required" }, { status: 400 }); 357 } 358 try { 359 updateUserAvatar(user.id, avatar); 360 return Response.json({ success: true }); 361 } catch { 362 return Response.json( 363 { error: "Failed to update avatar" }, 364 { status: 500 }, 365 ); 366 } 367 }, 368 }, 369 "/api/transcriptions/:id/stream": { 370 GET: (req) => { 371 const sessionId = getSessionFromRequest(req); 372 if (!sessionId) { 373 return Response.json({ error: "Not authenticated" }, { status: 401 }); 374 } 375 const user = getUserBySession(sessionId); 376 if (!user) { 377 return Response.json({ error: "Invalid session" }, { status: 401 }); 378 } 379 const transcriptionId = req.params.id; 380 // Verify ownership 381 const transcription = db 382 .query<{ id: string; user_id: number; status: string }, [string]>( 383 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 384 ) 385 .get(transcriptionId); 386 if (!transcription || transcription.user_id !== user.id) { 387 return Response.json( 388 { error: "Transcription not found" }, 389 { status: 404 }, 390 ); 391 } 392 // Event-driven SSE stream (NO POLLING!) 393 const stream = new ReadableStream({ 394 start(controller) { 395 const encoder = new TextEncoder(); 396 397 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 398 controller.enqueue( 399 encoder.encode(`data: ${JSON.stringify(data)}\n\n`), 400 ); 401 }; 402 // Send initial state from DB 403 const current = db 404 .query< 405 { 406 status: string; 407 progress: number; 408 transcript: string | null; 409 }, 410 [string] 411 >( 412 "SELECT status, progress, transcript FROM transcriptions WHERE id = ?", 413 ) 414 .get(transcriptionId); 415 if (current) { 416 sendEvent({ 417 status: current.status as TranscriptionUpdate["status"], 418 progress: current.progress, 419 transcript: current.transcript || undefined, 420 }); 421 } 422 // If already complete, close immediately 423 if ( 424 current?.status === "completed" || 425 current?.status === "failed" 426 ) { 427 controller.close(); 428 return; 429 } 430 // Subscribe to EventEmitter for live updates 431 const updateHandler = (data: TranscriptionUpdate) => { 432 console.log(`[SSE to client] Job ${transcriptionId}:`, data); 433 // Only send changed fields to save bandwidth 434 const payload: Partial<TranscriptionUpdate> = { 435 status: data.status, 436 progress: data.progress, 437 }; 438 439 if (data.transcript !== undefined) { 440 payload.transcript = data.transcript; 441 } 442 if (data.error_message !== undefined) { 443 payload.error_message = data.error_message; 444 } 445 446 sendEvent(payload); 447 448 // Close stream when done 449 if (data.status === "completed" || data.status === "failed") { 450 transcriptionEvents.off(transcriptionId, updateHandler); 451 controller.close(); 452 } 453 }; 454 transcriptionEvents.on(transcriptionId, updateHandler); 455 // Cleanup on client disconnect 456 return () => { 457 transcriptionEvents.off(transcriptionId, updateHandler); 458 }; 459 }, 460 }); 461 return new Response(stream, { 462 headers: { 463 "Content-Type": "text/event-stream", 464 "Cache-Control": "no-cache", 465 Connection: "keep-alive", 466 }, 467 }); 468 }, 469 }, 470 "/api/transcriptions": { 471 GET: (req) => { 472 try { 473 const user = requireAuth(req); 474 475 const transcriptions = db 476 .query< 477 { 478 id: string; 479 filename: string; 480 original_filename: string; 481 status: string; 482 progress: number; 483 transcript: string | null; 484 created_at: number; 485 }, 486 [number] 487 >( 488 "SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 489 ) 490 .all(user.id); 491 492 return Response.json({ 493 jobs: transcriptions.map((t) => ({ 494 id: t.id, 495 filename: t.original_filename, 496 status: t.status, 497 progress: t.progress, 498 transcript: t.transcript, 499 created_at: t.created_at, 500 })), 501 }); 502 } catch (error) { 503 return handleError(error); 504 } 505 }, 506 POST: async (req) => { 507 try { 508 const user = requireAuth(req); 509 510 const formData = await req.formData(); 511 const file = formData.get("audio") as File; 512 513 if (!file) throw ValidationErrors.missingField("audio"); 514 515 if (!file.type.startsWith("audio/")) { 516 throw ValidationErrors.unsupportedFileType( 517 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 518 ); 519 } 520 521 if (file.size > MAX_FILE_SIZE) { 522 throw ValidationErrors.fileTooLarge("25MB"); 523 } 524 525 // Generate unique filename 526 const transcriptionId = crypto.randomUUID(); 527 const fileExtension = file.name.split(".").pop(); 528 const filename = `${transcriptionId}.${fileExtension}`; 529 530 // Save file to disk 531 const uploadDir = "./uploads"; 532 await Bun.write(`${uploadDir}/${filename}`, file); 533 534 // Create database record 535 db.run( 536 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 537 [transcriptionId, user.id, filename, file.name, "uploading"], 538 ); 539 540 // Start transcription in background 541 whisperService.startTranscription(transcriptionId, filename); 542 543 return Response.json({ 544 id: transcriptionId, 545 message: "Upload successful, transcription started", 546 }); 547 } catch (error) { 548 return handleError(error); 549 } 550 }, 551 }, 552 }, 553 development: { 554 hmr: true, 555 console: true, 556 }, 557}); 558console.log(`馃 Thistle running at http://localhost:${server.port}`);