馃 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 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("25MB");
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}`);