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