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