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