馃 distributed transcription service
thistle.dunkirk.sh
1import db from "./db/schema";
2import {
3 authenticateUser,
4 cleanupExpiredSessions,
5 createSession,
6 createUser,
7 deleteSession,
8 deleteUser,
9 getSession,
10 getSessionFromRequest,
11 getUserBySession,
12 getUserSessionsForUser,
13 updateUserAvatar,
14 updateUserEmail,
15 updateUserName,
16 updateUserPassword,
17} from "./lib/auth";
18import { handleError, ValidationErrors } from "./lib/errors";
19import { requireAuth } from "./lib/middleware";
20import {
21 MAX_FILE_SIZE,
22 TranscriptionEventEmitter,
23 type TranscriptionUpdate,
24 WhisperServiceManager,
25} from "./lib/transcription";
26import indexHTML from "./pages/index.html";
27import settingsHTML from "./pages/settings.html";
28import transcribeHTML from "./pages/transcribe.html";
29
30// Environment variables
31const WHISPER_SERVICE_URL =
32 process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
33
34// Create uploads directory if it doesn't exist
35await Bun.write("./uploads/.gitkeep", "");
36
37// Initialize transcription system
38const transcriptionEvents = new TranscriptionEventEmitter();
39const whisperService = new WhisperServiceManager(
40 WHISPER_SERVICE_URL,
41 db,
42 transcriptionEvents,
43);
44
45// Clean up expired sessions every hour
46setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
47
48// Sync with Whisper DB on startup
49await whisperService.syncWithWhisper();
50
51// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
52setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000);
53
54// Clean up stale files daily
55setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
56
57const server = Bun.serve({
58 port: 3000,
59 idleTimeout: 120, // 120 seconds for SSE connections
60 routes: {
61 "/": indexHTML,
62 "/settings": settingsHTML,
63 "/transcribe": transcribeHTML,
64 "/api/auth/register": {
65 POST: async (req) => {
66 try {
67 const body = await req.json();
68 const { email, password, name } = body;
69 if (!email || !password) {
70 return Response.json(
71 { error: "Email and password required" },
72 { status: 400 },
73 );
74 }
75 if (password.length < 8) {
76 return Response.json(
77 { error: "Password must be at least 8 characters" },
78 { status: 400 },
79 );
80 }
81 const user = await createUser(email, password, name);
82 const ipAddress =
83 req.headers.get("x-forwarded-for") ??
84 req.headers.get("x-real-ip") ??
85 "unknown";
86 const userAgent = req.headers.get("user-agent") ?? "unknown";
87 const sessionId = createSession(user.id, ipAddress, userAgent);
88 return Response.json(
89 { user: { id: user.id, email: user.email } },
90 {
91 headers: {
92 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
93 },
94 },
95 );
96 } catch (err: unknown) {
97 const error = err as { message?: string };
98 if (error.message?.includes("UNIQUE constraint failed")) {
99 return Response.json(
100 { error: "Email already registered" },
101 { status: 400 },
102 );
103 }
104 return Response.json(
105 { error: "Registration failed" },
106 { status: 500 },
107 );
108 }
109 },
110 },
111 "/api/auth/login": {
112 POST: async (req) => {
113 try {
114 const body = await req.json();
115 const { email, password } = body;
116 if (!email || !password) {
117 return Response.json(
118 { error: "Email and password required" },
119 { status: 400 },
120 );
121 }
122 const user = await authenticateUser(email, password);
123 if (!user) {
124 return Response.json(
125 { error: "Invalid email or password" },
126 { status: 401 },
127 );
128 }
129 const ipAddress =
130 req.headers.get("x-forwarded-for") ??
131 req.headers.get("x-real-ip") ??
132 "unknown";
133 const userAgent = req.headers.get("user-agent") ?? "unknown";
134 const sessionId = createSession(user.id, ipAddress, userAgent);
135 return Response.json(
136 { user: { id: user.id, email: user.email } },
137 {
138 headers: {
139 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
140 },
141 },
142 );
143 } catch {
144 return Response.json({ error: "Login failed" }, { status: 500 });
145 }
146 },
147 },
148 "/api/auth/logout": {
149 POST: async (req) => {
150 const sessionId = getSessionFromRequest(req);
151 if (sessionId) {
152 deleteSession(sessionId);
153 }
154 return Response.json(
155 { success: true },
156 {
157 headers: {
158 "Set-Cookie":
159 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
160 },
161 },
162 );
163 },
164 },
165 "/api/auth/me": {
166 GET: (req) => {
167 const sessionId = getSessionFromRequest(req);
168 if (!sessionId) {
169 return Response.json({ error: "Not authenticated" }, { status: 401 });
170 }
171 const user = getUserBySession(sessionId);
172 if (!user) {
173 return Response.json({ error: "Invalid session" }, { status: 401 });
174 }
175 return Response.json({
176 email: user.email,
177 name: user.name,
178 avatar: user.avatar,
179 });
180 },
181 },
182 "/api/sessions": {
183 GET: (req) => {
184 const sessionId = getSessionFromRequest(req);
185 if (!sessionId) {
186 return Response.json({ error: "Not authenticated" }, { status: 401 });
187 }
188 const user = getUserBySession(sessionId);
189 if (!user) {
190 return Response.json({ error: "Invalid session" }, { status: 401 });
191 }
192 const sessions = getUserSessionsForUser(user.id);
193 return Response.json({
194 sessions: sessions.map((s) => ({
195 id: s.id,
196 ip_address: s.ip_address,
197 user_agent: s.user_agent,
198 created_at: s.created_at,
199 expires_at: s.expires_at,
200 })),
201 });
202 },
203 DELETE: async (req) => {
204 const currentSessionId = getSessionFromRequest(req);
205 if (!currentSessionId) {
206 return Response.json({ error: "Not authenticated" }, { status: 401 });
207 }
208 const user = getUserBySession(currentSessionId);
209 if (!user) {
210 return Response.json({ error: "Invalid session" }, { status: 401 });
211 }
212 const body = await req.json();
213 const targetSessionId = body.sessionId;
214 if (!targetSessionId) {
215 return Response.json(
216 { error: "Session ID required" },
217 { status: 400 },
218 );
219 }
220 // Verify the session belongs to the user
221 const targetSession = getSession(targetSessionId);
222 if (!targetSession || targetSession.user_id !== user.id) {
223 return Response.json({ error: "Session not found" }, { status: 404 });
224 }
225 deleteSession(targetSessionId);
226 return Response.json({ success: true });
227 },
228 },
229 "/api/user": {
230 DELETE: (req) => {
231 const sessionId = getSessionFromRequest(req);
232 if (!sessionId) {
233 return Response.json({ error: "Not authenticated" }, { status: 401 });
234 }
235 const user = getUserBySession(sessionId);
236 if (!user) {
237 return Response.json({ error: "Invalid session" }, { status: 401 });
238 }
239 deleteUser(user.id);
240 return Response.json(
241 { success: true },
242 {
243 headers: {
244 "Set-Cookie":
245 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
246 },
247 },
248 );
249 },
250 },
251 "/api/user/email": {
252 PUT: async (req) => {
253 const sessionId = getSessionFromRequest(req);
254 if (!sessionId) {
255 return Response.json({ error: "Not authenticated" }, { status: 401 });
256 }
257 const user = getUserBySession(sessionId);
258 if (!user) {
259 return Response.json({ error: "Invalid session" }, { status: 401 });
260 }
261 const body = await req.json();
262 const { email } = body;
263 if (!email) {
264 return Response.json({ error: "Email required" }, { status: 400 });
265 }
266 try {
267 updateUserEmail(user.id, email);
268 return Response.json({ success: true });
269 } catch (err: unknown) {
270 const error = err as { message?: string };
271 if (error.message?.includes("UNIQUE constraint failed")) {
272 return Response.json(
273 { error: "Email already in use" },
274 { status: 400 },
275 );
276 }
277 return Response.json(
278 { error: "Failed to update email" },
279 { status: 500 },
280 );
281 }
282 },
283 },
284 "/api/user/password": {
285 PUT: async (req) => {
286 const sessionId = getSessionFromRequest(req);
287 if (!sessionId) {
288 return Response.json({ error: "Not authenticated" }, { status: 401 });
289 }
290 const user = getUserBySession(sessionId);
291 if (!user) {
292 return Response.json({ error: "Invalid session" }, { status: 401 });
293 }
294 const body = await req.json();
295 const { password } = body;
296 if (!password) {
297 return Response.json({ error: "Password required" }, { status: 400 });
298 }
299 if (password.length < 8) {
300 return Response.json(
301 { error: "Password must be at least 8 characters" },
302 { status: 400 },
303 );
304 }
305 try {
306 await updateUserPassword(user.id, password);
307 return Response.json({ success: true });
308 } catch {
309 return Response.json(
310 { error: "Failed to update password" },
311 { status: 500 },
312 );
313 }
314 },
315 },
316 "/api/user/name": {
317 PUT: async (req) => {
318 const sessionId = getSessionFromRequest(req);
319 if (!sessionId) {
320 return Response.json({ error: "Not authenticated" }, { status: 401 });
321 }
322 const user = getUserBySession(sessionId);
323 if (!user) {
324 return Response.json({ error: "Invalid session" }, { status: 401 });
325 }
326 const body = await req.json();
327 const { name } = body;
328 if (!name) {
329 return Response.json({ error: "Name required" }, { status: 400 });
330 }
331 try {
332 updateUserName(user.id, name);
333 return Response.json({ success: true });
334 } catch {
335 return Response.json(
336 { error: "Failed to update name" },
337 { status: 500 },
338 );
339 }
340 },
341 },
342 "/api/user/avatar": {
343 PUT: async (req) => {
344 const sessionId = getSessionFromRequest(req);
345 if (!sessionId) {
346 return Response.json({ error: "Not authenticated" }, { status: 401 });
347 }
348 const user = getUserBySession(sessionId);
349 if (!user) {
350 return Response.json({ error: "Invalid session" }, { status: 401 });
351 }
352 const body = await req.json();
353 const { avatar } = body;
354 if (!avatar) {
355 return Response.json({ error: "Avatar required" }, { status: 400 });
356 }
357 try {
358 updateUserAvatar(user.id, avatar);
359 return Response.json({ success: true });
360 } catch {
361 return Response.json(
362 { error: "Failed to update avatar" },
363 { status: 500 },
364 );
365 }
366 },
367 },
368 "/api/transcriptions/:id/stream": {
369 GET: (req) => {
370 const sessionId = getSessionFromRequest(req);
371 if (!sessionId) {
372 return Response.json({ error: "Not authenticated" }, { status: 401 });
373 }
374 const user = getUserBySession(sessionId);
375 if (!user) {
376 return Response.json({ error: "Invalid session" }, { status: 401 });
377 }
378 const transcriptionId = req.params.id;
379 // Verify ownership
380 const transcription = db
381 .query<{ id: string; user_id: number; status: string }, [string]>(
382 "SELECT id, user_id, status FROM transcriptions WHERE id = ?",
383 )
384 .get(transcriptionId);
385 if (!transcription || transcription.user_id !== user.id) {
386 return Response.json(
387 { error: "Transcription not found" },
388 { status: 404 },
389 );
390 }
391 // Event-driven SSE stream (NO POLLING!)
392 const stream = new ReadableStream({
393 start(controller) {
394 const encoder = new TextEncoder();
395
396 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
397 controller.enqueue(
398 encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
399 );
400 };
401 // Send initial state from DB
402 const current = db
403 .query<
404 {
405 status: string;
406 progress: number;
407 transcript: string | null;
408 },
409 [string]
410 >(
411 "SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
412 )
413 .get(transcriptionId);
414 if (current) {
415 sendEvent({
416 status: current.status as TranscriptionUpdate["status"],
417 progress: current.progress,
418 transcript: current.transcript || undefined,
419 });
420 }
421 // If already complete, close immediately
422 if (
423 current?.status === "completed" ||
424 current?.status === "failed"
425 ) {
426 controller.close();
427 return;
428 }
429 // Subscribe to EventEmitter for live updates
430 const updateHandler = (data: TranscriptionUpdate) => {
431 console.log(`[SSE to client] Job ${transcriptionId}:`, data);
432 // Only send changed fields to save bandwidth
433 const payload: Partial<TranscriptionUpdate> = {
434 status: data.status,
435 progress: data.progress,
436 };
437
438 if (data.transcript !== undefined) {
439 payload.transcript = data.transcript;
440 }
441 if (data.error_message !== undefined) {
442 payload.error_message = data.error_message;
443 }
444
445 sendEvent(payload);
446
447 // Close stream when done
448 if (data.status === "completed" || data.status === "failed") {
449 transcriptionEvents.off(transcriptionId, updateHandler);
450 controller.close();
451 }
452 };
453 transcriptionEvents.on(transcriptionId, updateHandler);
454 // Cleanup on client disconnect
455 return () => {
456 transcriptionEvents.off(transcriptionId, updateHandler);
457 };
458 },
459 });
460 return new Response(stream, {
461 headers: {
462 "Content-Type": "text/event-stream",
463 "Cache-Control": "no-cache",
464 Connection: "keep-alive",
465 },
466 });
467 },
468 },
469 "/api/transcriptions": {
470 GET: (req) => {
471 try {
472 const user = requireAuth(req);
473
474 const transcriptions = db
475 .query<
476 {
477 id: string;
478 filename: string;
479 original_filename: string;
480 status: string;
481 progress: number;
482 transcript: string | null;
483 created_at: number;
484 },
485 [number]
486 >(
487 "SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
488 )
489 .all(user.id);
490
491 return Response.json({
492 jobs: transcriptions.map((t) => ({
493 id: t.id,
494 filename: t.original_filename,
495 status: t.status,
496 progress: t.progress,
497 transcript: t.transcript,
498 created_at: t.created_at,
499 })),
500 });
501 } catch (error) {
502 return handleError(error);
503 }
504 },
505 POST: async (req) => {
506 try {
507 const user = requireAuth(req);
508
509 const formData = await req.formData();
510 const file = formData.get("audio") as File;
511
512 if (!file) throw ValidationErrors.missingField("audio");
513
514 if (!file.type.startsWith("audio/")) {
515 throw ValidationErrors.unsupportedFileType(
516 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
517 );
518 }
519
520 if (file.size > MAX_FILE_SIZE) {
521 throw ValidationErrors.fileTooLarge("25MB");
522 }
523
524 // Generate unique filename
525 const transcriptionId = crypto.randomUUID();
526 const fileExtension = file.name.split(".").pop();
527 const filename = `${transcriptionId}.${fileExtension}`;
528
529 // Save file to disk
530 const uploadDir = "./uploads";
531 await Bun.write(`${uploadDir}/${filename}`, file);
532
533 // Create database record
534 db.run(
535 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
536 [transcriptionId, user.id, filename, file.name, "uploading"],
537 );
538
539 // Start transcription in background
540 whisperService.startTranscription(transcriptionId, filename);
541
542 return Response.json({
543 id: transcriptionId,
544 message: "Upload successful, transcription started",
545 });
546 } catch (error) {
547 return handleError(error);
548 }
549 },
550 },
551 },
552 development: {
553 hmr: true,
554 console: true,
555 },
556});
557console.log(`馃 Thistle running at http://localhost:${server.port}`);