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