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