馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 createSession, 6 createUser, 7 deleteSession, 8 deleteTranscription, 9 deleteUser, 10 getAllTranscriptions, 11 getAllUsers, 12 getSession, 13 getSessionFromRequest, 14 getUserBySession, 15 getUserSessionsForUser, 16 updateUserAvatar, 17 updateUserEmail, 18 updateUserName, 19 updateUserPassword, 20 updateUserRole, 21 type UserRole, 22} from "./lib/auth"; 23import { 24 createAuthenticationOptions, 25 createRegistrationOptions, 26 deletePasskey, 27 getPasskeysForUser, 28 updatePasskeyName, 29 verifyAndAuthenticatePasskey, 30 verifyAndCreatePasskey, 31} from "./lib/passkey"; 32import { handleError, ValidationErrors } from "./lib/errors"; 33import { requireAdmin, requireAuth } from "./lib/middleware"; 34import { enforceRateLimit } from "./lib/rate-limit"; 35import { 36 MAX_FILE_SIZE, 37 TranscriptionEventEmitter, 38 type TranscriptionUpdate, 39 WhisperServiceManager, 40} from "./lib/transcription"; 41import { getTranscript, getTranscriptVTT } from "./lib/transcript-storage"; 42import indexHTML from "./pages/index.html"; 43import adminHTML from "./pages/admin.html"; 44import settingsHTML from "./pages/settings.html"; 45import transcribeHTML from "./pages/transcribe.html"; 46 47// Environment variables 48const WHISPER_SERVICE_URL = 49 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 50 51// Create uploads and transcripts directories if they don't exist 52await Bun.write("./uploads/.gitkeep", ""); 53await Bun.write("./transcripts/.gitkeep", ""); 54 55// Initialize transcription system 56console.log( 57 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 58); 59const transcriptionEvents = new TranscriptionEventEmitter(); 60const whisperService = new WhisperServiceManager( 61 WHISPER_SERVICE_URL, 62 db, 63 transcriptionEvents, 64); 65 66// Clean up expired sessions every hour 67setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 68 69// Sync with Whisper DB on startup 70try { 71 await whisperService.syncWithWhisper(); 72 console.log("[Transcription] Successfully connected to Murmur"); 73} catch (error) { 74 console.warn( 75 "[Transcription] Murmur unavailable at startup:", 76 error instanceof Error ? error.message : "Unknown error", 77 ); 78} 79 80// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 81setInterval(async () => { 82 try { 83 await whisperService.syncWithWhisper(); 84 } catch (error) { 85 console.warn( 86 "[Sync] Failed to sync with Murmur:", 87 error instanceof Error ? error.message : "Unknown error", 88 ); 89 } 90}, 5 * 60 * 1000); 91 92// Clean up stale files daily 93setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); 94 95const server = Bun.serve({ 96 port: 3000, 97 idleTimeout: 120, // 120 seconds for SSE connections 98 routes: { 99 "/": indexHTML, 100 "/admin": adminHTML, 101 "/settings": settingsHTML, 102 "/transcribe": transcribeHTML, 103 "/api/auth/register": { 104 POST: async (req) => { 105 try { 106 // Rate limiting 107 const rateLimitError = enforceRateLimit(req, "register", { 108 ip: { max: 5, windowSeconds: 60 * 60 }, 109 }); 110 if (rateLimitError) return rateLimitError; 111 112 const body = await req.json(); 113 const { email, password, name } = body; 114 if (!email || !password) { 115 return Response.json( 116 { error: "Email and password required" }, 117 { status: 400 }, 118 ); 119 } 120 // Password is client-side hashed (PBKDF2), should be 64 char hex 121 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 122 return Response.json( 123 { error: "Invalid password format" }, 124 { status: 400 }, 125 ); 126 } 127 const user = await createUser(email, password, name); 128 const ipAddress = 129 req.headers.get("x-forwarded-for") ?? 130 req.headers.get("x-real-ip") ?? 131 "unknown"; 132 const userAgent = req.headers.get("user-agent") ?? "unknown"; 133 const sessionId = createSession(user.id, ipAddress, userAgent); 134 return Response.json( 135 { user: { id: user.id, email: user.email } }, 136 { 137 headers: { 138 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 139 }, 140 }, 141 ); 142 } catch (err: unknown) { 143 const error = err as { message?: string }; 144 if (error.message?.includes("UNIQUE constraint failed")) { 145 return Response.json( 146 { error: "Email already registered" }, 147 { status: 400 }, 148 ); 149 } 150 return Response.json( 151 { error: "Registration failed" }, 152 { status: 500 }, 153 ); 154 } 155 }, 156 }, 157 "/api/auth/login": { 158 POST: async (req) => { 159 try { 160 const body = await req.json(); 161 const { email, password } = body; 162 if (!email || !password) { 163 return Response.json( 164 { error: "Email and password required" }, 165 { status: 400 }, 166 ); 167 } 168 169 // Rate limiting: Per IP and per account 170 const rateLimitError = enforceRateLimit(req, "login", { 171 ip: { max: 10, windowSeconds: 15 * 60 }, 172 account: { max: 5, windowSeconds: 15 * 60, email }, 173 }); 174 if (rateLimitError) return rateLimitError; 175 176 // Password is client-side hashed (PBKDF2), should be 64 char hex 177 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 178 return Response.json( 179 { error: "Invalid password format" }, 180 { status: 400 }, 181 ); 182 } 183 const user = await authenticateUser(email, password); 184 if (!user) { 185 return Response.json( 186 { error: "Invalid email or password" }, 187 { status: 401 }, 188 ); 189 } 190 const ipAddress = 191 req.headers.get("x-forwarded-for") ?? 192 req.headers.get("x-real-ip") ?? 193 "unknown"; 194 const userAgent = req.headers.get("user-agent") ?? "unknown"; 195 const sessionId = createSession(user.id, ipAddress, userAgent); 196 return Response.json( 197 { user: { id: user.id, email: user.email } }, 198 { 199 headers: { 200 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 201 }, 202 }, 203 ); 204 } catch { 205 return Response.json({ error: "Login failed" }, { status: 500 }); 206 } 207 }, 208 }, 209 "/api/auth/logout": { 210 POST: async (req) => { 211 const sessionId = getSessionFromRequest(req); 212 if (sessionId) { 213 deleteSession(sessionId); 214 } 215 return Response.json( 216 { success: true }, 217 { 218 headers: { 219 "Set-Cookie": 220 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 221 }, 222 }, 223 ); 224 }, 225 }, 226 "/api/auth/me": { 227 GET: (req) => { 228 const sessionId = getSessionFromRequest(req); 229 if (!sessionId) { 230 return Response.json({ error: "Not authenticated" }, { status: 401 }); 231 } 232 const user = getUserBySession(sessionId); 233 if (!user) { 234 return Response.json({ error: "Invalid session" }, { status: 401 }); 235 } 236 return Response.json({ 237 email: user.email, 238 name: user.name, 239 avatar: user.avatar, 240 created_at: user.created_at, 241 role: user.role, 242 }); 243 }, 244 }, 245 "/api/passkeys/register/options": { 246 POST: async (req) => { 247 try { 248 const user = requireAuth(req); 249 const options = await createRegistrationOptions(user); 250 return Response.json(options); 251 } catch (err) { 252 return handleError(err); 253 } 254 }, 255 }, 256 "/api/passkeys/register/verify": { 257 POST: async (req) => { 258 try { 259 const user = requireAuth(req); 260 const body = await req.json(); 261 const { response: credentialResponse, challenge, name } = body; 262 263 const passkey = await verifyAndCreatePasskey( 264 credentialResponse, 265 challenge, 266 name, 267 ); 268 269 return Response.json({ 270 success: true, 271 passkey: { 272 id: passkey.id, 273 name: passkey.name, 274 created_at: passkey.created_at, 275 }, 276 }); 277 } catch (err) { 278 return handleError(err); 279 } 280 }, 281 }, 282 "/api/passkeys/authenticate/options": { 283 POST: async (req) => { 284 try { 285 const body = await req.json(); 286 const { email } = body; 287 288 const options = await createAuthenticationOptions(email); 289 return Response.json(options); 290 } catch (err) { 291 return handleError(err); 292 } 293 }, 294 }, 295 "/api/passkeys/authenticate/verify": { 296 POST: async (req) => { 297 try { 298 const body = await req.json(); 299 const { response: credentialResponse, challenge } = body; 300 301 const { user } = await verifyAndAuthenticatePasskey( 302 credentialResponse, 303 challenge, 304 ); 305 306 // Create session 307 const ipAddress = 308 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 309 req.headers.get("x-real-ip") || 310 "unknown"; 311 const userAgent = req.headers.get("user-agent") || "unknown"; 312 const sessionId = createSession(user.id, ipAddress, userAgent); 313 314 return Response.json( 315 { 316 email: user.email, 317 name: user.name, 318 avatar: user.avatar, 319 created_at: user.created_at, 320 role: user.role, 321 }, 322 { 323 headers: { 324 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 325 }, 326 }, 327 ); 328 } catch (err) { 329 return handleError(err); 330 } 331 }, 332 }, 333 "/api/passkeys": { 334 GET: async (req) => { 335 try { 336 const user = requireAuth(req); 337 const passkeys = getPasskeysForUser(user.id); 338 return Response.json({ 339 passkeys: passkeys.map((p) => ({ 340 id: p.id, 341 name: p.name, 342 created_at: p.created_at, 343 last_used_at: p.last_used_at, 344 })), 345 }); 346 } catch (err) { 347 return handleError(err); 348 } 349 }, 350 }, 351 "/api/passkeys/:id": { 352 PUT: async (req) => { 353 try { 354 const user = requireAuth(req); 355 const body = await req.json(); 356 const { name } = body; 357 const passkeyId = req.params.id; 358 359 if (!name) { 360 return Response.json({ error: "Name required" }, { status: 400 }); 361 } 362 363 updatePasskeyName(passkeyId, user.id, name); 364 return Response.json({ success: true }); 365 } catch (err) { 366 return handleError(err); 367 } 368 }, 369 DELETE: async (req) => { 370 try { 371 const user = requireAuth(req); 372 const passkeyId = req.params.id; 373 deletePasskey(passkeyId, user.id); 374 return Response.json({ success: true }); 375 } catch (err) { 376 return handleError(err); 377 } 378 }, 379 }, 380 "/api/sessions": { 381 GET: (req) => { 382 const sessionId = getSessionFromRequest(req); 383 if (!sessionId) { 384 return Response.json({ error: "Not authenticated" }, { status: 401 }); 385 } 386 const user = getUserBySession(sessionId); 387 if (!user) { 388 return Response.json({ error: "Invalid session" }, { status: 401 }); 389 } 390 const sessions = getUserSessionsForUser(user.id); 391 return Response.json({ 392 sessions: sessions.map((s) => ({ 393 id: s.id, 394 ip_address: s.ip_address, 395 user_agent: s.user_agent, 396 created_at: s.created_at, 397 expires_at: s.expires_at, 398 })), 399 }); 400 }, 401 DELETE: async (req) => { 402 const currentSessionId = getSessionFromRequest(req); 403 if (!currentSessionId) { 404 return Response.json({ error: "Not authenticated" }, { status: 401 }); 405 } 406 const user = getUserBySession(currentSessionId); 407 if (!user) { 408 return Response.json({ error: "Invalid session" }, { status: 401 }); 409 } 410 const body = await req.json(); 411 const targetSessionId = body.sessionId; 412 if (!targetSessionId) { 413 return Response.json( 414 { error: "Session ID required" }, 415 { status: 400 }, 416 ); 417 } 418 // Verify the session belongs to the user 419 const targetSession = getSession(targetSessionId); 420 if (!targetSession || targetSession.user_id !== user.id) { 421 return Response.json({ error: "Session not found" }, { status: 404 }); 422 } 423 deleteSession(targetSessionId); 424 return Response.json({ success: true }); 425 }, 426 }, 427 "/api/user": { 428 DELETE: (req) => { 429 const sessionId = getSessionFromRequest(req); 430 if (!sessionId) { 431 return Response.json({ error: "Not authenticated" }, { status: 401 }); 432 } 433 const user = getUserBySession(sessionId); 434 if (!user) { 435 return Response.json({ error: "Invalid session" }, { status: 401 }); 436 } 437 438 // Rate limiting 439 const rateLimitError = enforceRateLimit(req, "delete-user", { 440 ip: { max: 3, windowSeconds: 60 * 60 }, 441 }); 442 if (rateLimitError) return rateLimitError; 443 444 deleteUser(user.id); 445 return Response.json( 446 { success: true }, 447 { 448 headers: { 449 "Set-Cookie": 450 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 451 }, 452 }, 453 ); 454 }, 455 }, 456 "/api/user/email": { 457 PUT: async (req) => { 458 const sessionId = getSessionFromRequest(req); 459 if (!sessionId) { 460 return Response.json({ error: "Not authenticated" }, { status: 401 }); 461 } 462 const user = getUserBySession(sessionId); 463 if (!user) { 464 return Response.json({ error: "Invalid session" }, { status: 401 }); 465 } 466 467 // Rate limiting 468 const rateLimitError = enforceRateLimit(req, "update-email", { 469 ip: { max: 5, windowSeconds: 60 * 60 }, 470 }); 471 if (rateLimitError) return rateLimitError; 472 473 const body = await req.json(); 474 const { email } = body; 475 if (!email) { 476 return Response.json({ error: "Email required" }, { status: 400 }); 477 } 478 try { 479 updateUserEmail(user.id, email); 480 return Response.json({ success: true }); 481 } catch (err: unknown) { 482 const error = err as { message?: string }; 483 if (error.message?.includes("UNIQUE constraint failed")) { 484 return Response.json( 485 { error: "Email already in use" }, 486 { status: 400 }, 487 ); 488 } 489 return Response.json( 490 { error: "Failed to update email" }, 491 { status: 500 }, 492 ); 493 } 494 }, 495 }, 496 "/api/user/password": { 497 PUT: async (req) => { 498 const sessionId = getSessionFromRequest(req); 499 if (!sessionId) { 500 return Response.json({ error: "Not authenticated" }, { status: 401 }); 501 } 502 const user = getUserBySession(sessionId); 503 if (!user) { 504 return Response.json({ error: "Invalid session" }, { status: 401 }); 505 } 506 507 // Rate limiting 508 const rateLimitError = enforceRateLimit(req, "update-password", { 509 ip: { max: 5, windowSeconds: 60 * 60 }, 510 }); 511 if (rateLimitError) return rateLimitError; 512 513 const body = await req.json(); 514 const { password } = body; 515 if (!password) { 516 return Response.json({ error: "Password required" }, { status: 400 }); 517 } 518 // Password is client-side hashed (PBKDF2), should be 64 char hex 519 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 520 return Response.json( 521 { error: "Invalid password format" }, 522 { status: 400 }, 523 ); 524 } 525 try { 526 await updateUserPassword(user.id, password); 527 return Response.json({ success: true }); 528 } catch { 529 return Response.json( 530 { error: "Failed to update password" }, 531 { status: 500 }, 532 ); 533 } 534 }, 535 }, 536 "/api/user/name": { 537 PUT: async (req) => { 538 const sessionId = getSessionFromRequest(req); 539 if (!sessionId) { 540 return Response.json({ error: "Not authenticated" }, { status: 401 }); 541 } 542 const user = getUserBySession(sessionId); 543 if (!user) { 544 return Response.json({ error: "Invalid session" }, { status: 401 }); 545 } 546 const body = await req.json(); 547 const { name } = body; 548 if (!name) { 549 return Response.json({ error: "Name required" }, { status: 400 }); 550 } 551 try { 552 updateUserName(user.id, name); 553 return Response.json({ success: true }); 554 } catch { 555 return Response.json( 556 { error: "Failed to update name" }, 557 { status: 500 }, 558 ); 559 } 560 }, 561 }, 562 "/api/user/avatar": { 563 PUT: async (req) => { 564 const sessionId = getSessionFromRequest(req); 565 if (!sessionId) { 566 return Response.json({ error: "Not authenticated" }, { status: 401 }); 567 } 568 const user = getUserBySession(sessionId); 569 if (!user) { 570 return Response.json({ error: "Invalid session" }, { status: 401 }); 571 } 572 const body = await req.json(); 573 const { avatar } = body; 574 if (!avatar) { 575 return Response.json({ error: "Avatar required" }, { status: 400 }); 576 } 577 try { 578 updateUserAvatar(user.id, avatar); 579 return Response.json({ success: true }); 580 } catch { 581 return Response.json( 582 { error: "Failed to update avatar" }, 583 { status: 500 }, 584 ); 585 } 586 }, 587 }, 588 "/api/transcriptions/:id/stream": { 589 GET: async (req) => { 590 const sessionId = getSessionFromRequest(req); 591 if (!sessionId) { 592 return Response.json({ error: "Not authenticated" }, { status: 401 }); 593 } 594 const user = getUserBySession(sessionId); 595 if (!user) { 596 return Response.json({ error: "Invalid session" }, { status: 401 }); 597 } 598 const transcriptionId = req.params.id; 599 // Verify ownership 600 const transcription = db 601 .query<{ id: string; user_id: number; status: string }, [string]>( 602 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 603 ) 604 .get(transcriptionId); 605 if (!transcription || transcription.user_id !== user.id) { 606 return Response.json( 607 { error: "Transcription not found" }, 608 { status: 404 }, 609 ); 610 } 611 // Event-driven SSE stream with reconnection support 612 const stream = new ReadableStream({ 613 async start(controller) { 614 const encoder = new TextEncoder(); 615 let isClosed = false; 616 let lastEventId = Math.floor(Date.now() / 1000); 617 618 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 619 if (isClosed) return; 620 try { 621 // Send event ID for reconnection support 622 lastEventId = Math.floor(Date.now() / 1000); 623 controller.enqueue( 624 encoder.encode( 625 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 626 ), 627 ); 628 } catch { 629 // Controller already closed (client disconnected) 630 isClosed = true; 631 } 632 }; 633 634 const sendHeartbeat = () => { 635 if (isClosed) return; 636 try { 637 controller.enqueue(encoder.encode(": heartbeat\n\n")); 638 } catch { 639 isClosed = true; 640 } 641 }; 642 // Send initial state from DB and file 643 const current = db 644 .query< 645 { 646 status: string; 647 progress: number; 648 }, 649 [string] 650 >( 651 "SELECT status, progress FROM transcriptions WHERE id = ?", 652 ) 653 .get(transcriptionId); 654 if (current) { 655 // Load transcript from file if completed 656 let transcript: string | undefined; 657 if (current.status === "completed") { 658 transcript = (await getTranscript(transcriptionId)) || undefined; 659 } 660 sendEvent({ 661 status: current.status as TranscriptionUpdate["status"], 662 progress: current.progress, 663 transcript, 664 }); 665 } 666 // If already complete, close immediately 667 if ( 668 current?.status === "completed" || 669 current?.status === "failed" 670 ) { 671 isClosed = true; 672 controller.close(); 673 return; 674 } 675 // Send heartbeats every 2.5 seconds to keep connection alive 676 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 677 678 // Subscribe to EventEmitter for live updates 679 const updateHandler = (data: TranscriptionUpdate) => { 680 if (isClosed) return; 681 682 // Only send changed fields to save bandwidth 683 const payload: Partial<TranscriptionUpdate> = { 684 status: data.status, 685 progress: data.progress, 686 }; 687 688 if (data.transcript !== undefined) { 689 payload.transcript = data.transcript; 690 } 691 if (data.error_message !== undefined) { 692 payload.error_message = data.error_message; 693 } 694 695 sendEvent(payload); 696 697 // Close stream when done 698 if (data.status === "completed" || data.status === "failed") { 699 isClosed = true; 700 clearInterval(heartbeatInterval); 701 transcriptionEvents.off(transcriptionId, updateHandler); 702 controller.close(); 703 } 704 }; 705 transcriptionEvents.on(transcriptionId, updateHandler); 706 // Cleanup on client disconnect 707 return () => { 708 isClosed = true; 709 clearInterval(heartbeatInterval); 710 transcriptionEvents.off(transcriptionId, updateHandler); 711 }; 712 }, 713 }); 714 return new Response(stream, { 715 headers: { 716 "Content-Type": "text/event-stream", 717 "Cache-Control": "no-cache", 718 Connection: "keep-alive", 719 }, 720 }); 721 }, 722 }, 723 "/api/transcriptions/health": { 724 GET: async () => { 725 const isHealthy = await whisperService.checkHealth(); 726 return Response.json({ available: isHealthy }); 727 }, 728 }, 729 "/api/transcriptions/:id": { 730 GET: async (req) => { 731 try { 732 const user = requireAuth(req); 733 const transcriptionId = req.params.id; 734 735 // Verify ownership 736 const transcription = db 737 .query< 738 { 739 id: string; 740 user_id: number; 741 status: string; 742 original_filename: string; 743 }, 744 [string] 745 >( 746 "SELECT id, user_id, status, original_filename FROM transcriptions WHERE id = ?", 747 ) 748 .get(transcriptionId); 749 750 if (!transcription || transcription.user_id !== user.id) { 751 return Response.json( 752 { error: "Transcription not found" }, 753 { status: 404 }, 754 ); 755 } 756 757 if (transcription.status !== "completed") { 758 return Response.json( 759 { error: "Transcription not completed yet" }, 760 { status: 400 }, 761 ); 762 } 763 764 // Get format from query parameter 765 const url = new URL(req.url); 766 const format = url.searchParams.get("format"); 767 768 // Return WebVTT format if requested 769 if (format === "vtt") { 770 const vttContent = await getTranscriptVTT(transcriptionId); 771 772 if (!vttContent) { 773 return Response.json( 774 { error: "VTT transcript not available" }, 775 { status: 404 }, 776 ); 777 } 778 779 return new Response(vttContent, { 780 headers: { 781 "Content-Type": "text/vtt", 782 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 783 }, 784 }); 785 } 786 787 // Default: return plain text transcript from file 788 const transcript = await getTranscript(transcriptionId); 789 if (!transcript) { 790 return Response.json( 791 { error: "Transcript not available" }, 792 { status: 404 }, 793 ); 794 } 795 796 return new Response(transcript, { 797 headers: { 798 "Content-Type": "text/plain", 799 }, 800 }); 801 } catch (error) { 802 return handleError(error); 803 } 804 }, 805 }, 806 "/api/transcriptions/:id/audio": { 807 GET: async (req) => { 808 try { 809 const user = requireAuth(req); 810 const transcriptionId = req.params.id; 811 812 // Verify ownership and get filename 813 const transcription = db 814 .query< 815 { 816 id: string; 817 user_id: number; 818 filename: string; 819 status: string; 820 }, 821 [string] 822 >("SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?") 823 .get(transcriptionId); 824 825 if (!transcription || transcription.user_id !== user.id) { 826 return Response.json( 827 { error: "Transcription not found" }, 828 { status: 404 }, 829 ); 830 } 831 832 if (transcription.status !== "completed") { 833 return Response.json( 834 { error: "Transcription not completed yet" }, 835 { status: 400 }, 836 ); 837 } 838 839 // Serve the audio file with range request support 840 const filePath = `./uploads/${transcription.filename}`; 841 const file = Bun.file(filePath); 842 843 if (!(await file.exists())) { 844 return Response.json({ error: "Audio file not found" }, { status: 404 }); 845 } 846 847 const fileSize = file.size; 848 const range = req.headers.get("range"); 849 850 // Handle range requests for seeking 851 if (range) { 852 const parts = range.replace(/bytes=/, "").split("-"); 853 const start = Number.parseInt(parts[0] || "0", 10); 854 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 855 const chunkSize = end - start + 1; 856 857 const fileSlice = file.slice(start, end + 1); 858 859 return new Response(fileSlice, { 860 status: 206, 861 headers: { 862 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 863 "Accept-Ranges": "bytes", 864 "Content-Length": chunkSize.toString(), 865 "Content-Type": file.type || "audio/mpeg", 866 }, 867 }); 868 } 869 870 // No range request, send entire file 871 return new Response(file, { 872 headers: { 873 "Content-Type": file.type || "audio/mpeg", 874 "Accept-Ranges": "bytes", 875 "Content-Length": fileSize.toString(), 876 }, 877 }); 878 } catch (error) { 879 return handleError(error); 880 } 881 }, 882 }, 883 "/api/transcriptions": { 884 GET: async (req) => { 885 try { 886 const user = requireAuth(req); 887 888 const transcriptions = db 889 .query< 890 { 891 id: string; 892 filename: string; 893 original_filename: string; 894 status: string; 895 progress: number; 896 created_at: number; 897 }, 898 [number] 899 >( 900 "SELECT id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 901 ) 902 .all(user.id); 903 904 // Load transcripts from files for completed jobs 905 const jobs = await Promise.all( 906 transcriptions.map(async (t) => { 907 let transcript: string | null = null; 908 if (t.status === "completed") { 909 transcript = await getTranscript(t.id); 910 } 911 return { 912 id: t.id, 913 filename: t.original_filename, 914 status: t.status, 915 progress: t.progress, 916 transcript, 917 created_at: t.created_at, 918 }; 919 }), 920 ); 921 922 return Response.json({ jobs }); 923 } catch (error) { 924 return handleError(error); 925 } 926 }, 927 POST: async (req) => { 928 try { 929 const user = requireAuth(req); 930 931 const formData = await req.formData(); 932 const file = formData.get("audio") as File; 933 934 if (!file) throw ValidationErrors.missingField("audio"); 935 936 // Validate file type 937 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 938 const allowedExtensions = [ 939 "mp3", 940 "wav", 941 "m4a", 942 "aac", 943 "ogg", 944 "webm", 945 "flac", 946 "mp4", 947 ]; 948 const isAudioType = 949 file.type.startsWith("audio/") || file.type === "video/mp4"; 950 const isAudioExtension = 951 fileExtension && allowedExtensions.includes(fileExtension); 952 953 if (!isAudioType && !isAudioExtension) { 954 throw ValidationErrors.unsupportedFileType( 955 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 956 ); 957 } 958 959 if (file.size > MAX_FILE_SIZE) { 960 throw ValidationErrors.fileTooLarge("25MB"); 961 } 962 963 // Generate unique filename 964 const transcriptionId = crypto.randomUUID(); 965 const filename = `${transcriptionId}.${fileExtension}`; 966 967 // Save file to disk 968 const uploadDir = "./uploads"; 969 await Bun.write(`${uploadDir}/${filename}`, file); 970 971 // Create database record 972 db.run( 973 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 974 [transcriptionId, user.id, filename, file.name, "uploading"], 975 ); 976 977 // Start transcription in background 978 whisperService.startTranscription(transcriptionId, filename); 979 980 return Response.json({ 981 id: transcriptionId, 982 message: "Upload successful, transcription started", 983 }); 984 } catch (error) { 985 return handleError(error); 986 } 987 }, 988 }, 989 "/api/admin/transcriptions": { 990 GET: async (req) => { 991 try { 992 requireAdmin(req); 993 const transcriptions = getAllTranscriptions(); 994 return Response.json(transcriptions); 995 } catch (error) { 996 return handleError(error); 997 } 998 }, 999 }, 1000 "/api/admin/users": { 1001 GET: async (req) => { 1002 try { 1003 requireAdmin(req); 1004 const users = getAllUsers(); 1005 return Response.json(users); 1006 } catch (error) { 1007 return handleError(error); 1008 } 1009 }, 1010 }, 1011 "/api/admin/transcriptions/:id": { 1012 DELETE: async (req) => { 1013 try { 1014 requireAdmin(req); 1015 const transcriptionId = req.params.id; 1016 deleteTranscription(transcriptionId); 1017 return Response.json({ success: true }); 1018 } catch (error) { 1019 return handleError(error); 1020 } 1021 }, 1022 }, 1023 "/api/admin/users/:id": { 1024 DELETE: async (req) => { 1025 try { 1026 requireAdmin(req); 1027 const userId = Number.parseInt(req.params.id, 10); 1028 if (Number.isNaN(userId)) { 1029 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1030 } 1031 deleteUser(userId); 1032 return Response.json({ success: true }); 1033 } catch (error) { 1034 return handleError(error); 1035 } 1036 }, 1037 }, 1038 "/api/admin/users/:id/role": { 1039 PUT: async (req) => { 1040 try { 1041 requireAdmin(req); 1042 const userId = Number.parseInt(req.params.id, 10); 1043 if (Number.isNaN(userId)) { 1044 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1045 } 1046 1047 const body = await req.json(); 1048 const { role } = body as { role: UserRole }; 1049 1050 if (!role || (role !== "user" && role !== "admin")) { 1051 return Response.json( 1052 { error: "Invalid role. Must be 'user' or 'admin'" }, 1053 { status: 400 }, 1054 ); 1055 } 1056 1057 updateUserRole(userId, role); 1058 return Response.json({ success: true }); 1059 } catch (error) { 1060 return handleError(error); 1061 } 1062 }, 1063 }, 1064 }, 1065 development: { 1066 hmr: true, 1067 console: true, 1068 }, 1069}); 1070console.log(`馃 Thistle running at http://localhost:${server.port}`);