馃 distributed transcription service
thistle.dunkirk.sh
1import db from "../db/schema";
2
3const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
4
5export type UserRole = "user" | "admin";
6
7export interface User {
8 id: number;
9 email: string;
10 name: string | null;
11 avatar: string;
12 created_at: number;
13 role: UserRole;
14}
15
16export interface Session {
17 id: string;
18 user_id: number;
19 ip_address: string | null;
20 user_agent: string | null;
21 created_at: number;
22 expires_at: number;
23}
24
25export function createSession(
26 userId: number,
27 ipAddress?: string,
28 userAgent?: string,
29): string {
30 const sessionId = crypto.randomUUID();
31 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
32
33 db.run(
34 "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
35 [sessionId, userId, ipAddress ?? null, userAgent ?? null, expiresAt],
36 );
37
38 return sessionId;
39}
40
41export function getSession(sessionId: string): Session | null {
42 const now = Math.floor(Date.now() / 1000);
43
44 const session = db
45 .query<Session, [string, number]>(
46 "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE id = ? AND expires_at > ?",
47 )
48 .get(sessionId, now);
49
50 return session ?? null;
51}
52
53export function getUserBySession(sessionId: string): User | null {
54 const session = getSession(sessionId);
55 if (!session) return null;
56
57 const user = db
58 .query<User, [number]>(
59 "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
60 )
61 .get(session.user_id);
62
63 return user ?? null;
64}
65
66export function deleteSession(sessionId: string): void {
67 db.run("DELETE FROM sessions WHERE id = ?", [sessionId]);
68}
69
70export function cleanupExpiredSessions(): void {
71 const now = Math.floor(Date.now() / 1000);
72 db.run("DELETE FROM sessions WHERE expires_at <= ?", [now]);
73}
74
75export async function createUser(
76 email: string,
77 password: string,
78 name?: string,
79): Promise<User> {
80 // Generate deterministic avatar from email
81 const encoder = new TextEncoder();
82 const data = encoder.encode(email.toLowerCase());
83 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
84 const hashArray = Array.from(new Uint8Array(hashBuffer));
85 const avatar = hashArray
86 .map((b) => b.toString(16).padStart(2, "0"))
87 .join("")
88 .substring(0, 16);
89
90 const result = db.run(
91 "INSERT INTO users (email, password_hash, name, avatar) VALUES (?, ?, ?, ?)",
92 [email, password, name ?? null, avatar],
93 );
94
95 const user = db
96 .query<User, [number]>(
97 "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
98 )
99 .get(Number(result.lastInsertRowid));
100
101 if (!user) {
102 throw new Error("Failed to create user");
103 }
104
105 return user;
106}
107
108export async function authenticateUser(
109 email: string,
110 password: string,
111): Promise<User | null> {
112 const result = db
113 .query<
114 {
115 id: number;
116 email: string;
117 name: string | null;
118 avatar: string;
119 password_hash: string;
120 created_at: number;
121 role: UserRole;
122 },
123 [string]
124 >(
125 "SELECT id, email, name, avatar, password_hash, created_at, role FROM users WHERE email = ?",
126 )
127 .get(email);
128
129 if (!result) {
130 // Dummy comparison to prevent timing-based account enumeration
131 const dummyHash = "0".repeat(64);
132 password === dummyHash;
133 return null;
134 }
135
136 if (password !== result.password_hash) return null;
137
138 return {
139 id: result.id,
140 email: result.email,
141 name: result.name,
142 avatar: result.avatar,
143 created_at: result.created_at,
144 role: result.role,
145 };
146}
147
148export function getUserSessionsForUser(userId: number): Session[] {
149 const now = Math.floor(Date.now() / 1000);
150
151 const sessions = db
152 .query<Session, [number, number]>(
153 "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC",
154 )
155 .all(userId, now);
156
157 return sessions;
158}
159
160export function getSessionFromRequest(req: Request): string | null {
161 const cookie = req.headers.get("cookie");
162 if (!cookie) return null;
163
164 const match = cookie.match(/session=([^;]+)/);
165 return match?.[1] ?? null;
166}
167
168export function deleteUser(userId: number): void {
169 db.run("DELETE FROM users WHERE id = ?", [userId]);
170}
171
172export function updateUserEmail(userId: number, newEmail: string): void {
173 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
174}
175
176export function updateUserName(userId: number, newName: string): void {
177 db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]);
178}
179
180export function updateUserAvatar(userId: number, avatar: string): void {
181 db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]);
182}
183
184export async function updateUserPassword(
185 userId: number,
186 newPassword: string,
187): Promise<void> {
188 db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
189 newPassword,
190 userId,
191 ]);
192 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
193}
194
195export function isUserAdmin(userId: number): boolean {
196 const result = db
197 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
198 .get(userId);
199
200 return result?.role === "admin";
201}
202
203export function updateUserRole(userId: number, role: UserRole): void {
204 db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]);
205}
206
207export function getAllUsers(): Array<{
208 id: number;
209 email: string;
210 name: string | null;
211 avatar: string;
212 created_at: number;
213 role: UserRole;
214}> {
215 return db
216 .query<
217 {
218 id: number;
219 email: string;
220 name: string | null;
221 avatar: string;
222 created_at: number;
223 role: UserRole;
224 },
225 []
226 >("SELECT id, email, name, avatar, created_at, role FROM users ORDER BY created_at DESC")
227 .all();
228}
229
230export function getAllTranscriptions(): Array<{
231 id: string;
232 user_id: number;
233 user_email: string;
234 user_name: string | null;
235 original_filename: string;
236 status: string;
237 created_at: number;
238 error_message: string | null;
239}> {
240 return db
241 .query<
242 {
243 id: string;
244 user_id: number;
245 user_email: string;
246 user_name: string | null;
247 original_filename: string;
248 status: string;
249 created_at: number;
250 error_message: string | null;
251 },
252 []
253 >(
254 `SELECT
255 t.id,
256 t.user_id,
257 u.email as user_email,
258 u.name as user_name,
259 t.original_filename,
260 t.status,
261 t.created_at,
262 t.error_message
263 FROM transcriptions t
264 LEFT JOIN users u ON t.user_id = u.id
265 ORDER BY t.created_at DESC`,
266 )
267 .all();
268}
269
270export function deleteTranscription(transcriptionId: string): void {
271 const transcription = db
272 .query<{ id: string; filename: string }, [string]>(
273 "SELECT id, filename FROM transcriptions WHERE id = ?",
274 )
275 .get(transcriptionId);
276
277 if (!transcription) {
278 throw new Error("Transcription not found");
279 }
280
281 // Delete database record
282 db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
283
284 // Delete files (audio file and transcript files)
285 try {
286 const audioPath = `./uploads/${transcription.filename}`;
287 const transcriptPath = `./transcripts/${transcriptionId}.txt`;
288 const vttPath = `./transcripts/${transcriptionId}.vtt`;
289
290 if (Bun.file(audioPath).size) {
291 Bun.write(audioPath, "").then(() => {
292 // File deleted by overwriting with empty content, then unlink
293 import("node:fs").then((fs) => {
294 fs.unlinkSync(audioPath);
295 });
296 });
297 }
298
299 if (Bun.file(transcriptPath).size) {
300 import("node:fs").then((fs) => {
301 fs.unlinkSync(transcriptPath);
302 });
303 }
304
305 if (Bun.file(vttPath).size) {
306 import("node:fs").then((fs) => {
307 fs.unlinkSync(vttPath);
308 });
309 }
310 } catch {
311 // Files might not exist, ignore errors
312 }
313}