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