馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 createSession, 6 createUser, 7 deleteAllUserSessions, 8 deleteSession, 9 deleteSessionById, 10 deleteTranscription, 11 deleteUser, 12 getAllTranscriptions, 13 getAllUsers, 14 getAllUsersWithStats, 15 getSession, 16 getSessionFromRequest, 17 getSessionsForUser, 18 getUserBySession, 19 getUserSessionsForUser, 20 updateUserAvatar, 21 updateUserEmail, 22 updateUserEmailAddress, 23 updateUserName, 24 updateUserPassword, 25 updateUserRole, 26 type UserRole, 27} from "./lib/auth"; 28import { 29 createAuthenticationOptions, 30 createRegistrationOptions, 31 deletePasskey, 32 getPasskeysForUser, 33 updatePasskeyName, 34 verifyAndAuthenticatePasskey, 35 verifyAndCreatePasskey, 36} from "./lib/passkey"; 37import { handleError, ValidationErrors } from "./lib/errors"; 38import { requireAdmin, requireAuth } from "./lib/middleware"; 39import { enforceRateLimit } from "./lib/rate-limit"; 40import { 41 MAX_FILE_SIZE, 42 TranscriptionEventEmitter, 43 type TranscriptionUpdate, 44 WhisperServiceManager, 45} from "./lib/transcription"; 46import { getTranscriptVTT } from "./lib/transcript-storage"; 47import indexHTML from "./pages/index.html"; 48import adminHTML from "./pages/admin.html"; 49import settingsHTML from "./pages/settings.html"; 50import transcribeHTML from "./pages/transcribe.html"; 51 52// Environment variables 53const WHISPER_SERVICE_URL = 54 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 55 56// Create uploads and transcripts directories if they don't exist 57await Bun.write("./uploads/.gitkeep", ""); 58await Bun.write("./transcripts/.gitkeep", ""); 59 60// Initialize transcription system 61console.log( 62 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 63); 64const transcriptionEvents = new TranscriptionEventEmitter(); 65const whisperService = new WhisperServiceManager( 66 WHISPER_SERVICE_URL, 67 db, 68 transcriptionEvents, 69); 70 71// Clean up expired sessions every hour 72setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 73 74// Sync with Whisper DB on startup 75try { 76 await whisperService.syncWithWhisper(); 77 console.log("[Transcription] Successfully connected to Murmur"); 78} catch (error) { 79 console.warn( 80 "[Transcription] Murmur unavailable at startup:", 81 error instanceof Error ? error.message : "Unknown error", 82 ); 83} 84 85// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 86setInterval( 87 async () => { 88 try { 89 await whisperService.syncWithWhisper(); 90 } catch (error) { 91 console.warn( 92 "[Sync] Failed to sync with Murmur:", 93 error instanceof Error ? error.message : "Unknown error", 94 ); 95 } 96 }, 97 5 * 60 * 1000, 98); 99 100// Clean up stale files daily 101setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); 102 103const server = Bun.serve({ 104 port: 3000, 105 idleTimeout: 120, // 120 seconds for SSE connections 106 routes: { 107 "/": indexHTML, 108 "/admin": adminHTML, 109 "/settings": settingsHTML, 110 "/transcribe": transcribeHTML, 111 "/api/auth/register": { 112 POST: async (req) => { 113 try { 114 // Rate limiting 115 const rateLimitError = enforceRateLimit(req, "register", { 116 ip: { max: 5, windowSeconds: 60 * 60 }, 117 }); 118 if (rateLimitError) return rateLimitError; 119 120 const body = await req.json(); 121 const { email, password, name } = body; 122 if (!email || !password) { 123 return Response.json( 124 { error: "Email and password required" }, 125 { status: 400 }, 126 ); 127 } 128 // Password is client-side hashed (PBKDF2), should be 64 char hex 129 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 130 return Response.json( 131 { error: "Invalid password format" }, 132 { status: 400 }, 133 ); 134 } 135 const user = await createUser(email, password, name); 136 const ipAddress = 137 req.headers.get("x-forwarded-for") ?? 138 req.headers.get("x-real-ip") ?? 139 "unknown"; 140 const userAgent = req.headers.get("user-agent") ?? "unknown"; 141 const sessionId = createSession(user.id, ipAddress, userAgent); 142 return Response.json( 143 { user: { id: user.id, email: user.email } }, 144 { 145 headers: { 146 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 147 }, 148 }, 149 ); 150 } catch (err: unknown) { 151 const error = err as { message?: string }; 152 if (error.message?.includes("UNIQUE constraint failed")) { 153 return Response.json( 154 { error: "Email already registered" }, 155 { status: 400 }, 156 ); 157 } 158 return Response.json( 159 { error: "Registration failed" }, 160 { status: 500 }, 161 ); 162 } 163 }, 164 }, 165 "/api/auth/login": { 166 POST: async (req) => { 167 try { 168 const body = await req.json(); 169 const { email, password } = body; 170 if (!email || !password) { 171 return Response.json( 172 { error: "Email and password required" }, 173 { status: 400 }, 174 ); 175 } 176 177 // Rate limiting: Per IP and per account 178 const rateLimitError = enforceRateLimit(req, "login", { 179 ip: { max: 10, windowSeconds: 15 * 60 }, 180 account: { max: 5, windowSeconds: 15 * 60, email }, 181 }); 182 if (rateLimitError) return rateLimitError; 183 184 // Password is client-side hashed (PBKDF2), should be 64 char hex 185 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 186 return Response.json( 187 { error: "Invalid password format" }, 188 { status: 400 }, 189 ); 190 } 191 const user = await authenticateUser(email, password); 192 if (!user) { 193 return Response.json( 194 { error: "Invalid email or password" }, 195 { status: 401 }, 196 ); 197 } 198 const ipAddress = 199 req.headers.get("x-forwarded-for") ?? 200 req.headers.get("x-real-ip") ?? 201 "unknown"; 202 const userAgent = req.headers.get("user-agent") ?? "unknown"; 203 const sessionId = createSession(user.id, ipAddress, userAgent); 204 return Response.json( 205 { user: { id: user.id, email: user.email } }, 206 { 207 headers: { 208 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 209 }, 210 }, 211 ); 212 } catch { 213 return Response.json({ error: "Login failed" }, { status: 500 }); 214 } 215 }, 216 }, 217 "/api/auth/logout": { 218 POST: async (req) => { 219 const sessionId = getSessionFromRequest(req); 220 if (sessionId) { 221 deleteSession(sessionId); 222 } 223 return Response.json( 224 { success: true }, 225 { 226 headers: { 227 "Set-Cookie": 228 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 229 }, 230 }, 231 ); 232 }, 233 }, 234 "/api/auth/me": { 235 GET: (req) => { 236 const sessionId = getSessionFromRequest(req); 237 if (!sessionId) { 238 return Response.json({ error: "Not authenticated" }, { status: 401 }); 239 } 240 const user = getUserBySession(sessionId); 241 if (!user) { 242 return Response.json({ error: "Invalid session" }, { status: 401 }); 243 } 244 return Response.json({ 245 email: user.email, 246 name: user.name, 247 avatar: user.avatar, 248 created_at: user.created_at, 249 role: user.role, 250 }); 251 }, 252 }, 253 "/api/passkeys/register/options": { 254 POST: async (req) => { 255 try { 256 const user = requireAuth(req); 257 const options = await createRegistrationOptions(user); 258 return Response.json(options); 259 } catch (err) { 260 return handleError(err); 261 } 262 }, 263 }, 264 "/api/passkeys/register/verify": { 265 POST: async (req) => { 266 try { 267 const user = requireAuth(req); 268 const body = await req.json(); 269 const { response: credentialResponse, challenge, name } = body; 270 271 const passkey = await verifyAndCreatePasskey( 272 credentialResponse, 273 challenge, 274 name, 275 ); 276 277 return Response.json({ 278 success: true, 279 passkey: { 280 id: passkey.id, 281 name: passkey.name, 282 created_at: passkey.created_at, 283 }, 284 }); 285 } catch (err) { 286 return handleError(err); 287 } 288 }, 289 }, 290 "/api/passkeys/authenticate/options": { 291 POST: async (req) => { 292 try { 293 const body = await req.json(); 294 const { email } = body; 295 296 const options = await createAuthenticationOptions(email); 297 return Response.json(options); 298 } catch (err) { 299 return handleError(err); 300 } 301 }, 302 }, 303 "/api/passkeys/authenticate/verify": { 304 POST: async (req) => { 305 try { 306 const body = await req.json(); 307 const { response: credentialResponse, challenge } = body; 308 309 const { user } = await verifyAndAuthenticatePasskey( 310 credentialResponse, 311 challenge, 312 ); 313 314 // Create session 315 const ipAddress = 316 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 317 req.headers.get("x-real-ip") || 318 "unknown"; 319 const userAgent = req.headers.get("user-agent") || "unknown"; 320 const sessionId = createSession(user.id, ipAddress, userAgent); 321 322 return Response.json( 323 { 324 email: user.email, 325 name: user.name, 326 avatar: user.avatar, 327 created_at: user.created_at, 328 role: user.role, 329 }, 330 { 331 headers: { 332 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 333 }, 334 }, 335 ); 336 } catch (err) { 337 return handleError(err); 338 } 339 }, 340 }, 341 "/api/passkeys": { 342 GET: async (req) => { 343 try { 344 const user = requireAuth(req); 345 const passkeys = getPasskeysForUser(user.id); 346 return Response.json({ 347 passkeys: passkeys.map((p) => ({ 348 id: p.id, 349 name: p.name, 350 created_at: p.created_at, 351 last_used_at: p.last_used_at, 352 })), 353 }); 354 } catch (err) { 355 return handleError(err); 356 } 357 }, 358 }, 359 "/api/passkeys/:id": { 360 PUT: async (req) => { 361 try { 362 const user = requireAuth(req); 363 const body = await req.json(); 364 const { name } = body; 365 const passkeyId = req.params.id; 366 367 if (!name) { 368 return Response.json({ error: "Name required" }, { status: 400 }); 369 } 370 371 updatePasskeyName(passkeyId, user.id, name); 372 return Response.json({ success: true }); 373 } catch (err) { 374 return handleError(err); 375 } 376 }, 377 DELETE: async (req) => { 378 try { 379 const user = requireAuth(req); 380 const passkeyId = req.params.id; 381 deletePasskey(passkeyId, user.id); 382 return Response.json({ success: true }); 383 } catch (err) { 384 return handleError(err); 385 } 386 }, 387 }, 388 "/api/sessions": { 389 GET: (req) => { 390 const sessionId = getSessionFromRequest(req); 391 if (!sessionId) { 392 return Response.json({ error: "Not authenticated" }, { status: 401 }); 393 } 394 const user = getUserBySession(sessionId); 395 if (!user) { 396 return Response.json({ error: "Invalid session" }, { status: 401 }); 397 } 398 const sessions = getUserSessionsForUser(user.id); 399 return Response.json({ 400 sessions: sessions.map((s) => ({ 401 id: s.id, 402 ip_address: s.ip_address, 403 user_agent: s.user_agent, 404 created_at: s.created_at, 405 expires_at: s.expires_at, 406 })), 407 }); 408 }, 409 DELETE: async (req) => { 410 const currentSessionId = getSessionFromRequest(req); 411 if (!currentSessionId) { 412 return Response.json({ error: "Not authenticated" }, { status: 401 }); 413 } 414 const user = getUserBySession(currentSessionId); 415 if (!user) { 416 return Response.json({ error: "Invalid session" }, { status: 401 }); 417 } 418 const body = await req.json(); 419 const targetSessionId = body.sessionId; 420 if (!targetSessionId) { 421 return Response.json( 422 { error: "Session ID required" }, 423 { status: 400 }, 424 ); 425 } 426 // Verify the session belongs to the user 427 const targetSession = getSession(targetSessionId); 428 if (!targetSession || targetSession.user_id !== user.id) { 429 return Response.json({ error: "Session not found" }, { status: 404 }); 430 } 431 deleteSession(targetSessionId); 432 return Response.json({ success: true }); 433 }, 434 }, 435 "/api/user": { 436 DELETE: (req) => { 437 const sessionId = getSessionFromRequest(req); 438 if (!sessionId) { 439 return Response.json({ error: "Not authenticated" }, { status: 401 }); 440 } 441 const user = getUserBySession(sessionId); 442 if (!user) { 443 return Response.json({ error: "Invalid session" }, { status: 401 }); 444 } 445 446 // Rate limiting 447 const rateLimitError = enforceRateLimit(req, "delete-user", { 448 ip: { max: 3, windowSeconds: 60 * 60 }, 449 }); 450 if (rateLimitError) return rateLimitError; 451 452 deleteUser(user.id); 453 return Response.json( 454 { success: true }, 455 { 456 headers: { 457 "Set-Cookie": 458 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 459 }, 460 }, 461 ); 462 }, 463 }, 464 "/api/user/email": { 465 PUT: async (req) => { 466 const sessionId = getSessionFromRequest(req); 467 if (!sessionId) { 468 return Response.json({ error: "Not authenticated" }, { status: 401 }); 469 } 470 const user = getUserBySession(sessionId); 471 if (!user) { 472 return Response.json({ error: "Invalid session" }, { status: 401 }); 473 } 474 475 // Rate limiting 476 const rateLimitError = enforceRateLimit(req, "update-email", { 477 ip: { max: 5, windowSeconds: 60 * 60 }, 478 }); 479 if (rateLimitError) return rateLimitError; 480 481 const body = await req.json(); 482 const { email } = body; 483 if (!email) { 484 return Response.json({ error: "Email required" }, { status: 400 }); 485 } 486 try { 487 updateUserEmail(user.id, email); 488 return Response.json({ success: true }); 489 } catch (err: unknown) { 490 const error = err as { message?: string }; 491 if (error.message?.includes("UNIQUE constraint failed")) { 492 return Response.json( 493 { error: "Email already in use" }, 494 { status: 400 }, 495 ); 496 } 497 return Response.json( 498 { error: "Failed to update email" }, 499 { status: 500 }, 500 ); 501 } 502 }, 503 }, 504 "/api/user/password": { 505 PUT: async (req) => { 506 const sessionId = getSessionFromRequest(req); 507 if (!sessionId) { 508 return Response.json({ error: "Not authenticated" }, { status: 401 }); 509 } 510 const user = getUserBySession(sessionId); 511 if (!user) { 512 return Response.json({ error: "Invalid session" }, { status: 401 }); 513 } 514 515 // Rate limiting 516 const rateLimitError = enforceRateLimit(req, "update-password", { 517 ip: { max: 5, windowSeconds: 60 * 60 }, 518 }); 519 if (rateLimitError) return rateLimitError; 520 521 const body = await req.json(); 522 const { password } = body; 523 if (!password) { 524 return Response.json({ error: "Password required" }, { status: 400 }); 525 } 526 // Password is client-side hashed (PBKDF2), should be 64 char hex 527 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 528 return Response.json( 529 { error: "Invalid password format" }, 530 { status: 400 }, 531 ); 532 } 533 try { 534 await updateUserPassword(user.id, password); 535 return Response.json({ success: true }); 536 } catch { 537 return Response.json( 538 { error: "Failed to update password" }, 539 { status: 500 }, 540 ); 541 } 542 }, 543 }, 544 "/api/user/name": { 545 PUT: async (req) => { 546 const sessionId = getSessionFromRequest(req); 547 if (!sessionId) { 548 return Response.json({ error: "Not authenticated" }, { status: 401 }); 549 } 550 const user = getUserBySession(sessionId); 551 if (!user) { 552 return Response.json({ error: "Invalid session" }, { status: 401 }); 553 } 554 const body = await req.json(); 555 const { name } = body; 556 if (!name) { 557 return Response.json({ error: "Name required" }, { status: 400 }); 558 } 559 try { 560 updateUserName(user.id, name); 561 return Response.json({ success: true }); 562 } catch { 563 return Response.json( 564 { error: "Failed to update name" }, 565 { status: 500 }, 566 ); 567 } 568 }, 569 }, 570 "/api/user/avatar": { 571 PUT: async (req) => { 572 const sessionId = getSessionFromRequest(req); 573 if (!sessionId) { 574 return Response.json({ error: "Not authenticated" }, { status: 401 }); 575 } 576 const user = getUserBySession(sessionId); 577 if (!user) { 578 return Response.json({ error: "Invalid session" }, { status: 401 }); 579 } 580 const body = await req.json(); 581 const { avatar } = body; 582 if (!avatar) { 583 return Response.json({ error: "Avatar required" }, { status: 400 }); 584 } 585 try { 586 updateUserAvatar(user.id, avatar); 587 return Response.json({ success: true }); 588 } catch { 589 return Response.json( 590 { error: "Failed to update avatar" }, 591 { status: 500 }, 592 ); 593 } 594 }, 595 }, 596 "/api/transcriptions/:id/stream": { 597 GET: async (req) => { 598 const sessionId = getSessionFromRequest(req); 599 if (!sessionId) { 600 return Response.json({ error: "Not authenticated" }, { status: 401 }); 601 } 602 const user = getUserBySession(sessionId); 603 if (!user) { 604 return Response.json({ error: "Invalid session" }, { status: 401 }); 605 } 606 const transcriptionId = req.params.id; 607 // Verify ownership 608 const transcription = db 609 .query<{ id: string; user_id: number; status: string }, [string]>( 610 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 611 ) 612 .get(transcriptionId); 613 if (!transcription || transcription.user_id !== user.id) { 614 return Response.json( 615 { error: "Transcription not found" }, 616 { status: 404 }, 617 ); 618 } 619 // Event-driven SSE stream with reconnection support 620 const stream = new ReadableStream({ 621 async start(controller) { 622 const encoder = new TextEncoder(); 623 let isClosed = false; 624 let lastEventId = Math.floor(Date.now() / 1000); 625 626 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 627 if (isClosed) return; 628 try { 629 // Send event ID for reconnection support 630 lastEventId = Math.floor(Date.now() / 1000); 631 controller.enqueue( 632 encoder.encode( 633 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 634 ), 635 ); 636 } catch { 637 // Controller already closed (client disconnected) 638 isClosed = true; 639 } 640 }; 641 642 const sendHeartbeat = () => { 643 if (isClosed) return; 644 try { 645 controller.enqueue(encoder.encode(": heartbeat\n\n")); 646 } catch { 647 isClosed = true; 648 } 649 }; 650 // Send initial state from DB and file 651 const current = db 652 .query< 653 { 654 status: string; 655 progress: number; 656 }, 657 [string] 658 >("SELECT status, progress FROM transcriptions WHERE id = ?") 659 .get(transcriptionId); 660 if (current) { 661 sendEvent({ 662 status: current.status as TranscriptionUpdate["status"], 663 progress: current.progress, 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 or admin 736 const transcription = db 737 .query< 738 { 739 id: string; 740 user_id: number; 741 filename: string; 742 original_filename: string; 743 status: string; 744 progress: number; 745 created_at: number; 746 }, 747 [string] 748 >( 749 "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 750 ) 751 .get(transcriptionId); 752 753 if (!transcription) { 754 return Response.json( 755 { error: "Transcription not found" }, 756 { status: 404 }, 757 ); 758 } 759 760 // Allow access if user owns it or is admin 761 if (transcription.user_id !== user.id && user.role !== "admin") { 762 return Response.json( 763 { error: "Transcription not found" }, 764 { status: 404 }, 765 ); 766 } 767 768 if (transcription.status !== "completed") { 769 return Response.json( 770 { error: "Transcription not completed yet" }, 771 { status: 400 }, 772 ); 773 } 774 775 // Get format from query parameter 776 const url = new URL(req.url); 777 const format = url.searchParams.get("format"); 778 779 // Return WebVTT format if requested 780 if (format === "vtt") { 781 const vttContent = await getTranscriptVTT(transcriptionId); 782 783 if (!vttContent) { 784 return Response.json( 785 { error: "VTT transcript not available" }, 786 { status: 404 }, 787 ); 788 } 789 790 return new Response(vttContent, { 791 headers: { 792 "Content-Type": "text/vtt", 793 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 794 }, 795 }); 796 } 797 798 // return info on transcript 799 const transcript = { 800 id: transcription.id, 801 filename: transcription.original_filename, 802 status: transcription.status, 803 progress: transcription.progress, 804 created_at: transcription.created_at, 805 } 806 return new Response(JSON.stringify(transcript), { 807 headers: { 808 "Content-Type": "application/json", 809 }, 810 }); 811 } catch (error) { 812 return handleError(error); 813 } 814 }, 815 }, 816 "/api/transcriptions/:id/audio": { 817 GET: async (req) => { 818 try { 819 const user = requireAuth(req); 820 const transcriptionId = req.params.id; 821 822 // Verify ownership or admin 823 const transcription = db 824 .query< 825 { 826 id: string; 827 user_id: number; 828 filename: string; 829 status: string; 830 }, 831 [string] 832 >( 833 "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?", 834 ) 835 .get(transcriptionId); 836 837 if (!transcription) { 838 return Response.json( 839 { error: "Transcription not found" }, 840 { status: 404 }, 841 ); 842 } 843 844 // Allow access if user owns it or is admin 845 if (transcription.user_id !== user.id && user.role !== "admin") { 846 return Response.json( 847 { error: "Transcription not found" }, 848 { status: 404 }, 849 ); 850 } 851 852 if (transcription.status !== "completed") { 853 return Response.json( 854 { error: "Transcription not completed yet" }, 855 { status: 400 }, 856 ); 857 } 858 859 // Serve the audio file with range request support 860 const filePath = `./uploads/${transcription.filename}`; 861 const file = Bun.file(filePath); 862 863 if (!(await file.exists())) { 864 return Response.json( 865 { error: "Audio file not found" }, 866 { status: 404 }, 867 ); 868 } 869 870 const fileSize = file.size; 871 const range = req.headers.get("range"); 872 873 // Handle range requests for seeking 874 if (range) { 875 const parts = range.replace(/bytes=/, "").split("-"); 876 const start = Number.parseInt(parts[0] || "0", 10); 877 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 878 const chunkSize = end - start + 1; 879 880 const fileSlice = file.slice(start, end + 1); 881 882 return new Response(fileSlice, { 883 status: 206, 884 headers: { 885 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 886 "Accept-Ranges": "bytes", 887 "Content-Length": chunkSize.toString(), 888 "Content-Type": file.type || "audio/mpeg", 889 }, 890 }); 891 } 892 893 // No range request, send entire file 894 return new Response(file, { 895 headers: { 896 "Content-Type": file.type || "audio/mpeg", 897 "Accept-Ranges": "bytes", 898 "Content-Length": fileSize.toString(), 899 }, 900 }); 901 } catch (error) { 902 return handleError(error); 903 } 904 }, 905 }, 906 "/api/transcriptions": { 907 GET: async (req) => { 908 try { 909 const user = requireAuth(req); 910 911 const transcriptions = db 912 .query< 913 { 914 id: string; 915 filename: string; 916 original_filename: string; 917 status: string; 918 progress: number; 919 created_at: number; 920 }, 921 [number] 922 >( 923 "SELECT id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 924 ) 925 .all(user.id); 926 927 // Load transcripts from files for completed jobs 928 const jobs = await Promise.all( 929 transcriptions.map(async (t) => { 930 return { 931 id: t.id, 932 filename: t.original_filename, 933 status: t.status, 934 progress: t.progress, 935 created_at: t.created_at, 936 }; 937 }), 938 ); 939 940 return Response.json({ jobs }); 941 } catch (error) { 942 return handleError(error); 943 } 944 }, 945 POST: async (req) => { 946 try { 947 const user = requireAuth(req); 948 949 const formData = await req.formData(); 950 const file = formData.get("audio") as File; 951 952 if (!file) throw ValidationErrors.missingField("audio"); 953 954 // Validate file type 955 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 956 const allowedExtensions = [ 957 "mp3", 958 "wav", 959 "m4a", 960 "aac", 961 "ogg", 962 "webm", 963 "flac", 964 "mp4", 965 ]; 966 const isAudioType = 967 file.type.startsWith("audio/") || file.type === "video/mp4"; 968 const isAudioExtension = 969 fileExtension && allowedExtensions.includes(fileExtension); 970 971 if (!isAudioType && !isAudioExtension) { 972 throw ValidationErrors.unsupportedFileType( 973 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 974 ); 975 } 976 977 if (file.size > MAX_FILE_SIZE) { 978 throw ValidationErrors.fileTooLarge("25MB"); 979 } 980 981 // Generate unique filename 982 const transcriptionId = crypto.randomUUID(); 983 const filename = `${transcriptionId}.${fileExtension}`; 984 985 // Save file to disk 986 const uploadDir = "./uploads"; 987 await Bun.write(`${uploadDir}/${filename}`, file); 988 989 // Create database record 990 db.run( 991 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 992 [transcriptionId, user.id, filename, file.name, "uploading"], 993 ); 994 995 // Start transcription in background 996 whisperService.startTranscription(transcriptionId, filename); 997 998 return Response.json({ 999 id: transcriptionId, 1000 message: "Upload successful, transcription started", 1001 }); 1002 } catch (error) { 1003 return handleError(error); 1004 } 1005 }, 1006 }, 1007 "/api/admin/transcriptions": { 1008 GET: async (req) => { 1009 try { 1010 requireAdmin(req); 1011 const transcriptions = getAllTranscriptions(); 1012 return Response.json(transcriptions); 1013 } catch (error) { 1014 return handleError(error); 1015 } 1016 }, 1017 }, 1018 "/api/admin/users": { 1019 GET: async (req) => { 1020 try { 1021 requireAdmin(req); 1022 const users = getAllUsersWithStats(); 1023 return Response.json(users); 1024 } catch (error) { 1025 return handleError(error); 1026 } 1027 }, 1028 }, 1029 "/api/admin/transcriptions/:id": { 1030 DELETE: async (req) => { 1031 try { 1032 requireAdmin(req); 1033 const transcriptionId = req.params.id; 1034 deleteTranscription(transcriptionId); 1035 return Response.json({ success: true }); 1036 } catch (error) { 1037 return handleError(error); 1038 } 1039 }, 1040 }, 1041 "/api/admin/users/:id": { 1042 DELETE: async (req) => { 1043 try { 1044 requireAdmin(req); 1045 const userId = Number.parseInt(req.params.id, 10); 1046 if (Number.isNaN(userId)) { 1047 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1048 } 1049 deleteUser(userId); 1050 return Response.json({ success: true }); 1051 } catch (error) { 1052 return handleError(error); 1053 } 1054 }, 1055 }, 1056 "/api/admin/users/:id/role": { 1057 PUT: async (req) => { 1058 try { 1059 requireAdmin(req); 1060 const userId = Number.parseInt(req.params.id, 10); 1061 if (Number.isNaN(userId)) { 1062 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1063 } 1064 1065 const body = await req.json(); 1066 const { role } = body as { role: UserRole }; 1067 1068 if (!role || (role !== "user" && role !== "admin")) { 1069 return Response.json( 1070 { error: "Invalid role. Must be 'user' or 'admin'" }, 1071 { status: 400 }, 1072 ); 1073 } 1074 1075 updateUserRole(userId, role); 1076 return Response.json({ success: true }); 1077 } catch (error) { 1078 return handleError(error); 1079 } 1080 }, 1081 }, 1082 "/api/admin/users/:id/details": { 1083 GET: async (req) => { 1084 try { 1085 requireAdmin(req); 1086 const userId = Number.parseInt(req.params.id, 10); 1087 if (Number.isNaN(userId)) { 1088 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1089 } 1090 1091 const user = db 1092 .query< 1093 { 1094 id: number; 1095 email: string; 1096 name: string | null; 1097 avatar: string; 1098 created_at: number; 1099 role: UserRole; 1100 password_hash: string | null; 1101 last_login: number | null; 1102 }, 1103 [number] 1104 >( 1105 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 1106 ) 1107 .get(userId); 1108 1109 if (!user) { 1110 return Response.json({ error: "User not found" }, { status: 404 }); 1111 } 1112 1113 const passkeys = getPasskeysForUser(userId); 1114 const sessions = getSessionsForUser(userId); 1115 1116 // Get transcription count 1117 const transcriptionCount = 1118 db 1119 .query<{ count: number }, [number]>( 1120 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1121 ) 1122 .get(userId)?.count ?? 0; 1123 1124 return Response.json({ 1125 id: user.id, 1126 email: user.email, 1127 name: user.name, 1128 avatar: user.avatar, 1129 created_at: user.created_at, 1130 role: user.role, 1131 last_login: user.last_login, 1132 hasPassword: !!user.password_hash, 1133 transcriptionCount, 1134 passkeys: passkeys.map((pk) => ({ 1135 id: pk.id, 1136 name: pk.name, 1137 created_at: pk.created_at, 1138 last_used_at: pk.last_used_at, 1139 })), 1140 sessions: sessions.map((s) => ({ 1141 id: s.id, 1142 ip_address: s.ip_address, 1143 user_agent: s.user_agent, 1144 created_at: s.created_at, 1145 expires_at: s.expires_at, 1146 })), 1147 }); 1148 } catch (error) { 1149 return handleError(error); 1150 } 1151 }, 1152 }, 1153 "/api/admin/users/:id/password": { 1154 PUT: async (req) => { 1155 try { 1156 requireAdmin(req); 1157 const userId = Number.parseInt(req.params.id, 10); 1158 if (Number.isNaN(userId)) { 1159 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1160 } 1161 1162 const body = await req.json(); 1163 const { password } = body as { password: string }; 1164 1165 if (!password || password.length < 8) { 1166 return Response.json( 1167 { error: "Password must be at least 8 characters" }, 1168 { status: 400 }, 1169 ); 1170 } 1171 1172 await updateUserPassword(userId, password); 1173 return Response.json({ success: true }); 1174 } catch (error) { 1175 return handleError(error); 1176 } 1177 }, 1178 }, 1179 "/api/admin/users/:id/passkeys/:passkeyId": { 1180 DELETE: async (req) => { 1181 try { 1182 requireAdmin(req); 1183 const userId = Number.parseInt(req.params.id, 10); 1184 if (Number.isNaN(userId)) { 1185 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1186 } 1187 1188 const { passkeyId } = req.params; 1189 deletePasskey(passkeyId, userId); 1190 return Response.json({ success: true }); 1191 } catch (error) { 1192 return handleError(error); 1193 } 1194 }, 1195 }, 1196 "/api/admin/users/:id/name": { 1197 PUT: async (req) => { 1198 try { 1199 requireAdmin(req); 1200 const userId = Number.parseInt(req.params.id, 10); 1201 if (Number.isNaN(userId)) { 1202 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1203 } 1204 1205 const body = await req.json(); 1206 const { name } = body as { name: string }; 1207 1208 if (!name || name.trim().length === 0) { 1209 return Response.json( 1210 { error: "Name cannot be empty" }, 1211 { status: 400 }, 1212 ); 1213 } 1214 1215 updateUserName(userId, name.trim()); 1216 return Response.json({ success: true }); 1217 } catch (error) { 1218 return handleError(error); 1219 } 1220 }, 1221 }, 1222 "/api/admin/users/:id/email": { 1223 PUT: async (req) => { 1224 try { 1225 requireAdmin(req); 1226 const userId = Number.parseInt(req.params.id, 10); 1227 if (Number.isNaN(userId)) { 1228 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1229 } 1230 1231 const body = await req.json(); 1232 const { email } = body as { email: string }; 1233 1234 if (!email || !email.includes("@")) { 1235 return Response.json( 1236 { error: "Invalid email address" }, 1237 { status: 400 }, 1238 ); 1239 } 1240 1241 // Check if email already exists 1242 const existing = db 1243 .query<{ id: number }, [string, number]>( 1244 "SELECT id FROM users WHERE email = ? AND id != ?", 1245 ) 1246 .get(email, userId); 1247 1248 if (existing) { 1249 return Response.json( 1250 { error: "Email already in use" }, 1251 { status: 400 }, 1252 ); 1253 } 1254 1255 updateUserEmailAddress(userId, email); 1256 return Response.json({ success: true }); 1257 } catch (error) { 1258 return handleError(error); 1259 } 1260 }, 1261 }, 1262 "/api/admin/users/:id/sessions": { 1263 GET: async (req) => { 1264 try { 1265 requireAdmin(req); 1266 const userId = Number.parseInt(req.params.id, 10); 1267 if (Number.isNaN(userId)) { 1268 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1269 } 1270 1271 const sessions = getSessionsForUser(userId); 1272 return Response.json(sessions); 1273 } catch (error) { 1274 return handleError(error); 1275 } 1276 }, 1277 DELETE: async (req) => { 1278 try { 1279 requireAdmin(req); 1280 const userId = Number.parseInt(req.params.id, 10); 1281 if (Number.isNaN(userId)) { 1282 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1283 } 1284 1285 deleteAllUserSessions(userId); 1286 return Response.json({ success: true }); 1287 } catch (error) { 1288 return handleError(error); 1289 } 1290 }, 1291 }, 1292 "/api/admin/users/:id/sessions/:sessionId": { 1293 DELETE: async (req) => { 1294 try { 1295 requireAdmin(req); 1296 const userId = Number.parseInt(req.params.id, 10); 1297 if (Number.isNaN(userId)) { 1298 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1299 } 1300 1301 const { sessionId } = req.params; 1302 const success = deleteSessionById(sessionId, userId); 1303 1304 if (!success) { 1305 return Response.json( 1306 { error: "Session not found" }, 1307 { status: 404 }, 1308 ); 1309 } 1310 1311 return Response.json({ success: true }); 1312 } catch (error) { 1313 return handleError(error); 1314 } 1315 }, 1316 }, 1317 "/api/admin/transcriptions/:id/details": { 1318 GET: async (req) => { 1319 try { 1320 requireAdmin(req); 1321 const transcriptionId = req.params.id; 1322 1323 const transcription = db 1324 .query< 1325 { 1326 id: string; 1327 original_filename: string; 1328 status: string; 1329 created_at: number; 1330 updated_at: number; 1331 error_message: string | null; 1332 user_id: number; 1333 }, 1334 [string] 1335 >( 1336 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 1337 ) 1338 .get(transcriptionId); 1339 1340 if (!transcription) { 1341 return Response.json( 1342 { error: "Transcription not found" }, 1343 { status: 404 }, 1344 ); 1345 } 1346 1347 const user = db 1348 .query<{ email: string; name: string | null }, [number]>( 1349 "SELECT email, name FROM users WHERE id = ?", 1350 ) 1351 .get(transcription.user_id); 1352 1353 return Response.json({ 1354 id: transcription.id, 1355 original_filename: transcription.original_filename, 1356 status: transcription.status, 1357 created_at: transcription.created_at, 1358 completed_at: transcription.updated_at, 1359 error_message: transcription.error_message, 1360 user_id: transcription.user_id, 1361 user_email: user?.email || "Unknown", 1362 user_name: user?.name || null, 1363 }); 1364 } catch (error) { 1365 return handleError(error); 1366 } 1367 }, 1368 }, 1369 }, 1370 development: { 1371 hmr: true, 1372 console: true, 1373 }, 1374}); 1375console.log(`馃 Thistle running at http://localhost:${server.port}`);