馃 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 created_at: user.created_at,
180 });
181 },
182 },
183 "/api/sessions": {
184 GET: (req) => {
185 const sessionId = getSessionFromRequest(req);
186 if (!sessionId) {
187 return Response.json({ error: "Not authenticated" }, { status: 401 });
188 }
189 const user = getUserBySession(sessionId);
190 if (!user) {
191 return Response.json({ error: "Invalid session" }, { status: 401 });
192 }
193 const sessions = getUserSessionsForUser(user.id);
194 return Response.json({
195 sessions: sessions.map((s) => ({
196 id: s.id,
197 ip_address: s.ip_address,
198 user_agent: s.user_agent,
199 created_at: s.created_at,
200 expires_at: s.expires_at,
201 })),
202 });
203 },
204 DELETE: async (req) => {
205 const currentSessionId = getSessionFromRequest(req);
206 if (!currentSessionId) {
207 return Response.json({ error: "Not authenticated" }, { status: 401 });
208 }
209 const user = getUserBySession(currentSessionId);
210 if (!user) {
211 return Response.json({ error: "Invalid session" }, { status: 401 });
212 }
213 const body = await req.json();
214 const targetSessionId = body.sessionId;
215 if (!targetSessionId) {
216 return Response.json(
217 { error: "Session ID required" },
218 { status: 400 },
219 );
220 }
221 // Verify the session belongs to the user
222 const targetSession = getSession(targetSessionId);
223 if (!targetSession || targetSession.user_id !== user.id) {
224 return Response.json({ error: "Session not found" }, { status: 404 });
225 }
226 deleteSession(targetSessionId);
227 return Response.json({ success: true });
228 },
229 },
230 "/api/user": {
231 DELETE: (req) => {
232 const sessionId = getSessionFromRequest(req);
233 if (!sessionId) {
234 return Response.json({ error: "Not authenticated" }, { status: 401 });
235 }
236 const user = getUserBySession(sessionId);
237 if (!user) {
238 return Response.json({ error: "Invalid session" }, { status: 401 });
239 }
240 deleteUser(user.id);
241 return Response.json(
242 { success: true },
243 {
244 headers: {
245 "Set-Cookie":
246 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
247 },
248 },
249 );
250 },
251 },
252 "/api/user/email": {
253 PUT: async (req) => {
254 const sessionId = getSessionFromRequest(req);
255 if (!sessionId) {
256 return Response.json({ error: "Not authenticated" }, { status: 401 });
257 }
258 const user = getUserBySession(sessionId);
259 if (!user) {
260 return Response.json({ error: "Invalid session" }, { status: 401 });
261 }
262 const body = await req.json();
263 const { email } = body;
264 if (!email) {
265 return Response.json({ error: "Email required" }, { status: 400 });
266 }
267 try {
268 updateUserEmail(user.id, email);
269 return Response.json({ success: true });
270 } catch (err: unknown) {
271 const error = err as { message?: string };
272 if (error.message?.includes("UNIQUE constraint failed")) {
273 return Response.json(
274 { error: "Email already in use" },
275 { status: 400 },
276 );
277 }
278 return Response.json(
279 { error: "Failed to update email" },
280 { status: 500 },
281 );
282 }
283 },
284 },
285 "/api/user/password": {
286 PUT: async (req) => {
287 const sessionId = getSessionFromRequest(req);
288 if (!sessionId) {
289 return Response.json({ error: "Not authenticated" }, { status: 401 });
290 }
291 const user = getUserBySession(sessionId);
292 if (!user) {
293 return Response.json({ error: "Invalid session" }, { status: 401 });
294 }
295 const body = await req.json();
296 const { password } = body;
297 if (!password) {
298 return Response.json({ error: "Password required" }, { status: 400 });
299 }
300 if (password.length < 8) {
301 return Response.json(
302 { error: "Password must be at least 8 characters" },
303 { status: 400 },
304 );
305 }
306 try {
307 await updateUserPassword(user.id, password);
308 return Response.json({ success: true });
309 } catch {
310 return Response.json(
311 { error: "Failed to update password" },
312 { status: 500 },
313 );
314 }
315 },
316 },
317 "/api/user/name": {
318 PUT: async (req) => {
319 const sessionId = getSessionFromRequest(req);
320 if (!sessionId) {
321 return Response.json({ error: "Not authenticated" }, { status: 401 });
322 }
323 const user = getUserBySession(sessionId);
324 if (!user) {
325 return Response.json({ error: "Invalid session" }, { status: 401 });
326 }
327 const body = await req.json();
328 const { name } = body;
329 if (!name) {
330 return Response.json({ error: "Name required" }, { status: 400 });
331 }
332 try {
333 updateUserName(user.id, name);
334 return Response.json({ success: true });
335 } catch {
336 return Response.json(
337 { error: "Failed to update name" },
338 { status: 500 },
339 );
340 }
341 },
342 },
343 "/api/user/avatar": {
344 PUT: async (req) => {
345 const sessionId = getSessionFromRequest(req);
346 if (!sessionId) {
347 return Response.json({ error: "Not authenticated" }, { status: 401 });
348 }
349 const user = getUserBySession(sessionId);
350 if (!user) {
351 return Response.json({ error: "Invalid session" }, { status: 401 });
352 }
353 const body = await req.json();
354 const { avatar } = body;
355 if (!avatar) {
356 return Response.json({ error: "Avatar required" }, { status: 400 });
357 }
358 try {
359 updateUserAvatar(user.id, avatar);
360 return Response.json({ success: true });
361 } catch {
362 return Response.json(
363 { error: "Failed to update avatar" },
364 { status: 500 },
365 );
366 }
367 },
368 },
369 "/api/transcriptions/:id/stream": {
370 GET: (req) => {
371 const sessionId = getSessionFromRequest(req);
372 if (!sessionId) {
373 return Response.json({ error: "Not authenticated" }, { status: 401 });
374 }
375 const user = getUserBySession(sessionId);
376 if (!user) {
377 return Response.json({ error: "Invalid session" }, { status: 401 });
378 }
379 const transcriptionId = req.params.id;
380 // Verify ownership
381 const transcription = db
382 .query<{ id: string; user_id: number; status: string }, [string]>(
383 "SELECT id, user_id, status FROM transcriptions WHERE id = ?",
384 )
385 .get(transcriptionId);
386 if (!transcription || transcription.user_id !== user.id) {
387 return Response.json(
388 { error: "Transcription not found" },
389 { status: 404 },
390 );
391 }
392 // Event-driven SSE stream (NO POLLING!)
393 const stream = new ReadableStream({
394 start(controller) {
395 const encoder = new TextEncoder();
396
397 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
398 controller.enqueue(
399 encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
400 );
401 };
402 // Send initial state from DB
403 const current = db
404 .query<
405 {
406 status: string;
407 progress: number;
408 transcript: string | null;
409 },
410 [string]
411 >(
412 "SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
413 )
414 .get(transcriptionId);
415 if (current) {
416 sendEvent({
417 status: current.status as TranscriptionUpdate["status"],
418 progress: current.progress,
419 transcript: current.transcript || undefined,
420 });
421 }
422 // If already complete, close immediately
423 if (
424 current?.status === "completed" ||
425 current?.status === "failed"
426 ) {
427 controller.close();
428 return;
429 }
430 // Subscribe to EventEmitter for live updates
431 const updateHandler = (data: TranscriptionUpdate) => {
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}`);