馃 distributed transcription service
thistle.dunkirk.sh
1import {
2authenticateUser,
3cleanupExpiredSessions,
4createSession,
5createUser,
6deleteSession,
7deleteUser,
8getSession,
9getSessionFromRequest,
10getUserBySession,
11getUserSessionsForUser,
12updateUserAvatar,
13updateUserEmail,
14updateUserName,
15 updateUserPassword,
16} from "./lib/auth";
17import indexHTML from "./pages/index.html";
18import settingsHTML from "./pages/settings.html";
19
20// Clean up expired sessions every hour
21setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
22
23const server = Bun.serve({
24 port: 3000,
25 routes: {
26 "/": indexHTML,
27 "/settings": settingsHTML,
28 "/api/auth/register": {
29 POST: async (req) => {
30 try {
31 const body = await req.json();
32 const { email, password, name } = body;
33
34 if (!email || !password) {
35 return Response.json(
36 { error: "Email and password required" },
37 { status: 400 },
38 );
39 }
40
41 if (password.length < 8) {
42 return Response.json(
43 { error: "Password must be at least 8 characters" },
44 { status: 400 },
45 );
46 }
47
48 const user = await createUser(email, password, name);
49 const ipAddress =
50 req.headers.get("x-forwarded-for") ??
51 req.headers.get("x-real-ip") ??
52 "unknown";
53 const userAgent = req.headers.get("user-agent") ?? "unknown";
54 const sessionId = createSession(user.id, ipAddress, userAgent);
55
56 return Response.json(
57 { user: { id: user.id, email: user.email } },
58 {
59 headers: {
60 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
61 },
62 },
63 );
64 } catch (err: unknown) {
65 const error = err as { message?: string };
66 if (error.message?.includes("UNIQUE constraint failed")) {
67 return Response.json(
68 { error: "Email already registered" },
69 { status: 400 },
70 );
71 }
72 return Response.json(
73 { error: "Registration failed" },
74 { status: 500 },
75 );
76 }
77 },
78 },
79 "/api/auth/login": {
80 POST: async (req) => {
81 try {
82 const body = await req.json();
83 const { email, password } = body;
84
85 if (!email || !password) {
86 return Response.json(
87 { error: "Email and password required" },
88 { status: 400 },
89 );
90 }
91
92 const user = await authenticateUser(email, password);
93
94 if (!user) {
95 return Response.json(
96 { error: "Invalid email or password" },
97 { status: 401 },
98 );
99 }
100
101 const ipAddress =
102 req.headers.get("x-forwarded-for") ??
103 req.headers.get("x-real-ip") ??
104 "unknown";
105 const userAgent = req.headers.get("user-agent") ?? "unknown";
106 const sessionId = createSession(user.id, ipAddress, userAgent);
107
108 return Response.json(
109 { user: { id: user.id, email: user.email } },
110 {
111 headers: {
112 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
113 },
114 },
115 );
116 } catch (_) {
117 return Response.json({ error: "Login failed" }, { status: 500 });
118 }
119 },
120 },
121 "/api/auth/logout": {
122 POST: (req) => {
123 const sessionId = getSessionFromRequest(req);
124 if (sessionId) {
125 deleteSession(sessionId);
126 }
127
128 return Response.json(
129 { success: true },
130 {
131 headers: {
132 "Set-Cookie":
133 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
134 },
135 },
136 );
137 },
138 },
139 "/api/auth/me": {
140 GET: (req) => {
141 const sessionId = getSessionFromRequest(req);
142 if (!sessionId) {
143 return Response.json({ error: "Not authenticated" }, { status: 401 });
144 }
145
146 const user = getUserBySession(sessionId);
147 if (!user) {
148 return Response.json({ error: "Invalid session" }, { status: 401 });
149 }
150
151 return Response.json({
152 email: user.email,
153 name: user.name,
154 avatar: user.avatar,
155 created_at: user.created_at,
156 });
157 },
158 },
159 "/api/sessions": {
160 GET: (req) => {
161 const sessionId = getSessionFromRequest(req);
162 if (!sessionId) {
163 return Response.json({ error: "Not authenticated" }, { status: 401 });
164 }
165
166 const user = getUserBySession(sessionId);
167 if (!user) {
168 return Response.json({ error: "Invalid session" }, { status: 401 });
169 }
170
171 const sessions = getUserSessionsForUser(user.id);
172 return Response.json({
173 sessions: sessions.map((s) => ({
174 id: s.id,
175 ip_address: s.ip_address,
176 user_agent: s.user_agent,
177 created_at: s.created_at,
178 expires_at: s.expires_at,
179 is_current: s.id === sessionId,
180 })),
181 });
182 },
183 DELETE: async (req) => {
184 const currentSessionId = getSessionFromRequest(req);
185 if (!currentSessionId) {
186 return Response.json({ error: "Not authenticated" }, { status: 401 });
187 }
188
189 const user = getUserBySession(currentSessionId);
190 if (!user) {
191 return Response.json({ error: "Invalid session" }, { status: 401 });
192 }
193
194 const body = await req.json();
195 const targetSessionId = body.sessionId;
196
197 if (!targetSessionId) {
198 return Response.json(
199 { error: "Session ID required" },
200 { status: 400 },
201 );
202 }
203
204 // Verify the session belongs to the user
205 const targetSession = getSession(targetSessionId);
206 if (!targetSession || targetSession.user_id !== user.id) {
207 return Response.json({ error: "Session not found" }, { status: 404 });
208 }
209
210 deleteSession(targetSessionId);
211
212 return Response.json({ success: true });
213 },
214 },
215 "/api/auth/delete-account": {
216 DELETE: (req) => {
217 const sessionId = getSessionFromRequest(req);
218 if (!sessionId) {
219 return Response.json({ error: "Not authenticated" }, { status: 401 });
220 }
221
222 const user = getUserBySession(sessionId);
223 if (!user) {
224 return Response.json({ error: "Invalid session" }, { status: 401 });
225 }
226
227 deleteUser(user.id);
228
229 return Response.json(
230 { success: true },
231 {
232 headers: {
233 "Set-Cookie":
234 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
235 },
236 },
237 );
238 },
239 },
240 "/api/user/email": {
241 PUT: async (req) => {
242 const sessionId = getSessionFromRequest(req);
243 if (!sessionId) {
244 return Response.json({ error: "Not authenticated" }, { status: 401 });
245 }
246
247 const user = getUserBySession(sessionId);
248 if (!user) {
249 return Response.json({ error: "Invalid session" }, { status: 401 });
250 }
251
252 const body = await req.json();
253 const { email } = body;
254
255 if (!email) {
256 return Response.json({ error: "Email required" }, { status: 400 });
257 }
258
259 try {
260 updateUserEmail(user.id, email);
261 return Response.json({ success: true });
262 } catch (err: unknown) {
263 const error = err as { message?: string };
264 if (error.message?.includes("UNIQUE constraint failed")) {
265 return Response.json(
266 { error: "Email already in use" },
267 { status: 400 },
268 );
269 }
270 return Response.json(
271 { error: "Failed to update email" },
272 { status: 500 },
273 );
274 }
275 },
276 },
277 "/api/user/password": {
278 PUT: async (req) => {
279 const sessionId = getSessionFromRequest(req);
280 if (!sessionId) {
281 return Response.json({ error: "Not authenticated" }, { status: 401 });
282 }
283
284 const user = getUserBySession(sessionId);
285 if (!user) {
286 return Response.json({ error: "Invalid session" }, { status: 401 });
287 }
288
289 const body = await req.json();
290 const { password } = body;
291
292 if (!password) {
293 return Response.json({ error: "Password required" }, { status: 400 });
294 }
295
296 if (password.length < 8) {
297 return Response.json(
298 { error: "Password must be at least 8 characters" },
299 { status: 400 },
300 );
301 }
302
303 try {
304 await updateUserPassword(user.id, password);
305 return Response.json({ success: true });
306 } catch {
307 return Response.json(
308 { error: "Failed to update password" },
309 { status: 500 },
310 );
311 }
312 },
313 },
314 "/api/user/name": {
315 PUT: async (req) => {
316 const sessionId = getSessionFromRequest(req);
317 if (!sessionId) {
318 return Response.json({ error: "Not authenticated" }, { status: 401 });
319 }
320
321 const user = getUserBySession(sessionId);
322 if (!user) {
323 return Response.json({ error: "Invalid session" }, { status: 401 });
324 }
325
326 const body = await req.json();
327 const { name } = body;
328
329 if (!name) {
330 return Response.json({ error: "Name required" }, { status: 400 });
331 }
332
333 try {
334 updateUserName(user.id, name);
335 return Response.json({ success: true });
336 } catch {
337 return Response.json(
338 { error: "Failed to update name" },
339 { status: 500 },
340 );
341 }
342 },
343 },
344 "/api/user/avatar": {
345 PUT: async (req) => {
346 const sessionId = getSessionFromRequest(req);
347 if (!sessionId) {
348 return Response.json({ error: "Not authenticated" }, { status: 401 });
349 }
350
351 const user = getUserBySession(sessionId);
352 if (!user) {
353 return Response.json({ error: "Invalid session" }, { status: 401 });
354 }
355
356 const body = await req.json();
357 const { avatar } = body;
358
359 if (!avatar) {
360 return Response.json({ error: "Avatar required" }, { status: 400 });
361 }
362
363 try {
364 updateUserAvatar(user.id, avatar);
365 return Response.json({ success: true });
366 } catch {
367 return Response.json(
368 { error: "Failed to update avatar" },
369 { status: 500 },
370 );
371 }
372 },
373 },
374 },
375 development: {
376 hmr: true,
377 console: true,
378 },
379});
380
381console.log(`馃 Thistle running at http://localhost:${server.port}`);