馃 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}`);