feat: implement session backend logic
- commit
- 872a426c72092e97b7635d10e2d1f42fd10dc9c3
- author
- Kit · · 2025-02-12 13:57 UTC
- parent
- 08d9c0f
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
6 files · +203 -15
A src/lib/api/session.server.ts +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | import db from '../db/db.server'; | |
| 2 | + | import { sessions, users } from '../db/schema'; | |
| 3 | + | import { eq } from 'drizzle-orm'; | |
| 4 | + | import type { Session, User } from '../types/chat'; | |
| 5 | + | ||
| 6 | + | // Helper: Compute SHA-256 hash of a message and encode it as a hex string. | |
| 7 | + | async function sha256(message: string): Promise<string> { | |
| 8 | + | const encoder = new TextEncoder(); | |
| 9 | + | const data = encoder.encode(message); | |
| 10 | + | const hashBuffer = await crypto.subtle.digest('SHA-256', data); | |
| 11 | + | const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| 12 | + | const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |
| 13 | + | return hashHex; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Helper: Custom Base32 encoder using the standard alphabet. | |
| 17 | + | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |
| 18 | + | const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; | |
| 19 | + | function base32Encode(data: Uint8Array): string { | |
| 20 | + | let bits = 0; | |
| 21 | + | let value = 0; | |
| 22 | + | let output = ""; | |
| 23 | + | for (let i = 0; i < data.length; i++) { | |
| 24 | + | value = (value << 8) | data[i]; | |
| 25 | + | bits += 8; | |
| 26 | + | while (bits >= 5) { | |
| 27 | + | const index = (value >>> (bits - 5)) & 0x1f; | |
| 28 | + | output += base32Alphabet[index]; | |
| 29 | + | bits -= 5; | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | if (bits > 0) { | |
| 33 | + | const index = (value << (5 - bits)) & 0x1f; | |
| 34 | + | output += base32Alphabet[index]; | |
| 35 | + | } | |
| 36 | + | return output; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Generate a secure session token by generating 20 random bytes and Base32 encoding them. | |
| 40 | + | export function generateSessionToken(): string { | |
| 41 | + | const randomBytesArray = new Uint8Array(20); | |
| 42 | + | crypto.getRandomValues(randomBytesArray); | |
| 43 | + | const token = base32Encode(randomBytesArray); | |
| 44 | + | console.debug("Generated session token:", token); | |
| 45 | + | return token; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Create a session record once a valid token is available. | |
| 49 | + | // The session ID is the SHA-256 hash of the token. | |
| 50 | + | export async function createSession(token: string, userId: string): Promise<Session> { | |
| 51 | + | console.debug("Creating session for user:", userId); | |
| 52 | + | const sessionId = await sha256(token); | |
| 53 | + | const createdAt = Date.now(); | |
| 54 | + | const expiresAt = createdAt + 24 * 60 * 60 * 1000; // 24 hours expiry | |
| 55 | + | await db.insert(sessions).values({ | |
| 56 | + | id: sessionId, | |
| 57 | + | userId, | |
| 58 | + | createdAt, | |
| 59 | + | expiresAt, | |
| 60 | + | }); | |
| 61 | + | console.debug("Session created with id:", sessionId); | |
| 62 | + | return { id: sessionId, userId, createdAt, expiresAt }; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** | |
| 66 | + | * Type distributed through our API. | |
| 67 | + | * Returns an object with both session and user if valid, or nulls if not. | |
| 68 | + | */ | |
| 69 | + | export type SessionValidationResult = | |
| 70 | + | | { session: Session; user: User } | |
| 71 | + | | { session: null; user: null }; | |
| 72 | + | ||
| 73 | + | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | |
| 74 | + | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | |
| 75 | + | console.debug("Validating session token:", token); | |
| 76 | + | const sessionId = await sha256(token); | |
| 77 | + | const session = await db.query.sessions.findFirst({ | |
| 78 | + | where: eq(sessions.id, sessionId) | |
| 79 | + | }); | |
| 80 | + | if (!session) { | |
| 81 | + | console.debug("Session not found for id:", sessionId); | |
| 82 | + | return { session: null, user: null }; | |
| 83 | + | } | |
| 84 | + | const now = Date.now(); | |
| 85 | + | if (session.expiresAt < now) { | |
| 86 | + | console.debug("Session expired for id:", sessionId); | |
| 87 | + | return { session: null, user: null }; | |
| 88 | + | } | |
| 89 | + | const user = await db.query.users.findFirst({ | |
| 90 | + | where: eq(users.id, session.userId) | |
| 91 | + | }); | |
| 92 | + | if (!user) { | |
| 93 | + | console.debug("User not found for session id:", sessionId); | |
| 94 | + | return { session: null, user: null }; | |
| 95 | + | } | |
| 96 | + | console.debug("Session validated for id:", sessionId); | |
| 97 | + | return { session, user }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Invalidate a specific session by deleting it from the database. | |
| 101 | + | export async function invalidateSession(sessionId: string): Promise<void> { | |
| 102 | + | console.debug("Invalidating session with id:", sessionId); | |
| 103 | + | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 104 | + | console.debug("Session invalidated:", sessionId); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Invalidate all sessions for a given user. | |
| 108 | + | export async function invalidateAllSessions(userId: string): Promise<void> { | |
| 109 | + | console.debug("Invalidating all sessions for user:", userId); | |
| 110 | + | await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 111 | + | console.debug("All sessions invalidated for user:", userId); | |
| 112 | + | } ⏎̸ | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | import db from '../db/db.server'; | |||
| 2 | + | import { sessions, users } from '../db/schema'; | |||
| 3 | + | import { eq } from 'drizzle-orm'; | |||
| 4 | + | import type { Session, User } from '../types/chat'; | |||
| 5 | + | ||||
| 6 | + | // Helper: Compute SHA-256 hash of a message and encode it as a hex string. | |||
| 7 | + | async function sha256(message: string): Promise<string> { | |||
| 8 | + | const encoder = new TextEncoder(); | |||
| 9 | + | const data = encoder.encode(message); | |||
| 10 | + | const hashBuffer = await crypto.subtle.digest('SHA-256', data); | |||
| 11 | + | const hashArray = Array.from(new Uint8Array(hashBuffer)); | |||
| 12 | + | const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |||
| 13 | + | return hashHex; | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Helper: Custom Base32 encoder using the standard alphabet. | |||
| 17 | + | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |||
| 18 | + | const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; | |||
| 19 | + | function base32Encode(data: Uint8Array): string { | |||
| 20 | + | let bits = 0; | |||
| 21 | + | let value = 0; | |||
| 22 | + | let output = ""; | |||
| 23 | + | for (let i = 0; i < data.length; i++) { | |||
| 24 | + | value = (value << 8) | data[i]; | |||
| 25 | + | bits += 8; | |||
| 26 | + | while (bits >= 5) { | |||
| 27 | + | const index = (value >>> (bits - 5)) & 0x1f; | |||
| 28 | + | output += base32Alphabet[index]; | |||
| 29 | + | bits -= 5; | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | if (bits > 0) { | |||
| 33 | + | const index = (value << (5 - bits)) & 0x1f; | |||
| 34 | + | output += base32Alphabet[index]; | |||
| 35 | + | } | |||
| 36 | + | return output; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Generate a secure session token by generating 20 random bytes and Base32 encoding them. | |||
| 40 | + | export function generateSessionToken(): string { | |||
| 41 | + | const randomBytesArray = new Uint8Array(20); | |||
| 42 | + | crypto.getRandomValues(randomBytesArray); | |||
| 43 | + | const token = base32Encode(randomBytesArray); | |||
| 44 | + | console.debug("Generated session token:", token); | |||
| 45 | + | return token; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Create a session record once a valid token is available. | |||
| 49 | + | // The session ID is the SHA-256 hash of the token. | |||
| 50 | + | export async function createSession(token: string, userId: string): Promise<Session> { | |||
| 51 | + | console.debug("Creating session for user:", userId); | |||
| 52 | + | const sessionId = await sha256(token); | |||
| 53 | + | const createdAt = Date.now(); | |||
| 54 | + | const expiresAt = createdAt + 24 * 60 * 60 * 1000; // 24 hours expiry | |||
| 55 | + | await db.insert(sessions).values({ | |||
| 56 | + | id: sessionId, | |||
| 57 | + | userId, | |||
| 58 | + | createdAt, | |||
| 59 | + | expiresAt, | |||
| 60 | + | }); | |||
| 61 | + | console.debug("Session created with id:", sessionId); | |||
| 62 | + | return { id: sessionId, userId, createdAt, expiresAt }; | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | /** | |||
| 66 | + | * Type distributed through our API. | |||
| 67 | + | * Returns an object with both session and user if valid, or nulls if not. | |||
| 68 | + | */ | |||
| 69 | + | export type SessionValidationResult = | |||
| 70 | + | | { session: Session; user: User } | |||
| 71 | + | | { session: null; user: null }; | |||
| 72 | + | ||||
| 73 | + | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | |||
| 74 | + | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | |||
| 75 | + | console.debug("Validating session token:", token); | |||
| 76 | + | const sessionId = await sha256(token); | |||
| 77 | + | const session = await db.query.sessions.findFirst({ | |||
| 78 | + | where: eq(sessions.id, sessionId) | |||
| 79 | + | }); | |||
| 80 | + | if (!session) { | |||
| 81 | + | console.debug("Session not found for id:", sessionId); | |||
| 82 | + | return { session: null, user: null }; | |||
| 83 | + | } | |||
| 84 | + | const now = Date.now(); | |||
| 85 | + | if (session.expiresAt < now) { | |||
| 86 | + | console.debug("Session expired for id:", sessionId); | |||
| 87 | + | return { session: null, user: null }; | |||
| 88 | + | } | |||
| 89 | + | const user = await db.query.users.findFirst({ | |||
| 90 | + | where: eq(users.id, session.userId) | |||
| 91 | + | }); | |||
| 92 | + | if (!user) { | |||
| 93 | + | console.debug("User not found for session id:", sessionId); | |||
| 94 | + | return { session: null, user: null }; | |||
| 95 | + | } | |||
| 96 | + | console.debug("Session validated for id:", sessionId); | |||
| 97 | + | return { session, user }; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Invalidate a specific session by deleting it from the database. | |||
| 101 | + | export async function invalidateSession(sessionId: string): Promise<void> { | |||
| 102 | + | console.debug("Invalidating session with id:", sessionId); | |||
| 103 | + | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |||
| 104 | + | console.debug("Session invalidated:", sessionId); | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Invalidate all sessions for a given user. | |||
| 108 | + | export async function invalidateAllSessions(userId: string): Promise<void> { | |||
| 109 | + | console.debug("Invalidating all sessions for user:", userId); | |||
| 110 | + | await db.delete(sessions).where(eq(sessions.userId, userId)); | |||
| 111 | + | console.debug("All sessions invalidated for user:", userId); | |||
| 112 | + | } ⏎̸ | |||
M src/lib/db/schema.ts +20 -11
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | import { sqliteTable as table } from "drizzle-orm/sqlite-core"; | |
| 2 | 2 | import * as t from "drizzle-orm/sqlite-core"; | |
| 3 | - | import type { User, ChatRoom, Message, UserStatus, MessageType } from "../types/chat"; | |
| 3 | + | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat"; | |
| 4 | 4 | import { v4 as uuidv4 } from "uuid"; | |
| 5 | 5 | import { eq } from "drizzle-orm"; | |
| 6 | 6 | ||
| @@ -11,13 +11,23 @@ export const users = table( | |||
| 11 | 11 | email: t.text("email").notNull().unique(), | |
| 12 | 12 | password: t.text("password").notNull(), | |
| 13 | 13 | nickname: t.text("nickname").notNull(), | |
| 14 | - | status: t.text("status").$type<UserStatus>().default("offline"), | |
| 14 | + | status: t.text("status").notNull().$type<UserStatus>().default("offline"), | |
| 15 | 15 | avatarUrl: t.text("avatar_url"), | |
| 16 | 16 | createdAt: t.integer("created_at").notNull(), | |
| 17 | 17 | lastSeen: t.integer("last_seen"), | |
| 18 | 18 | } | |
| 19 | 19 | ); | |
| 20 | 20 | ||
| 21 | + | export const sessions = table( | |
| 22 | + | "sessions", | |
| 23 | + | { | |
| 24 | + | id: t.text("id").primaryKey(), | |
| 25 | + | userId: t.text("user_id").references(() => users.id).notNull(), | |
| 26 | + | expiresAt: t.integer("expires_at").notNull(), | |
| 27 | + | createdAt: t.integer("created_at").notNull(), | |
| 28 | + | } | |
| 29 | + | ); | |
| 30 | + | ||
| 21 | 31 | export const chatRooms = table( | |
| 22 | 32 | "chat_rooms", | |
| 23 | 33 | { | |
| @@ -41,14 +51,15 @@ export const messages = table( | |||
| 41 | 51 | ); | |
| 42 | 52 | ||
| 43 | 53 | // Validate schema types against interfaces | |
| 44 | - | type UsersTable = typeof users.$inferSelect; | |
| 45 | - | type ChatRoomsTable = typeof chatRooms.$inferSelect; | |
| 46 | - | type MessagesTable = typeof messages.$inferSelect; | |
| 47 | - | ||
| 54 | + | export type Users = typeof users.$inferSelect; | |
| 55 | + | export type ChatRooms = typeof chatRooms.$inferSelect; | |
| 56 | + | export type Messages = typeof messages.$inferSelect; | |
| 57 | + | export type Sessions = typeof sessions.$inferSelect; | |
| 48 | 58 | // These type assertions will fail if the schema doesn't match the interfaces | |
| 49 | - | type _UsersValidation = Omit<User, keyof UsersTable> & Omit<UsersTable, keyof User>; | |
| 50 | - | type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRoomsTable> & Omit<ChatRoomsTable, keyof ChatRoom>; | |
| 51 | - | type _MessagesValidation = Omit<Message, keyof MessagesTable> & Omit<MessagesTable, keyof Message>; | |
| 59 | + | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | |
| 60 | + | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | |
| 61 | + | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; | |
| 62 | + | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | |
| 52 | 63 | ||
| 53 | 64 | export const userView = t.sqliteView("user_view").as((qb) => | |
| 54 | 65 | qb.select({ | |
| @@ -83,5 +94,3 @@ export const messageView = t.sqliteView("message_view").as((qb) => | |||
| 83 | 94 | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | |
| 84 | 95 | ); | |
| 85 | 96 | ||
| 86 | - | ||
| 87 | - | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | import { sqliteTable as table } from "drizzle-orm/sqlite-core"; | 1 | import { sqliteTable as table } from "drizzle-orm/sqlite-core"; | ||
| 2 | import * as t from "drizzle-orm/sqlite-core"; | 2 | import * as t from "drizzle-orm/sqlite-core"; | ||
| 3 | - | import type { User, ChatRoom, Message, UserStatus, MessageType } from "../types/chat"; | 3 | + | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat"; |
| 4 | import { v4 as uuidv4 } from "uuid"; | 4 | import { v4 as uuidv4 } from "uuid"; | ||
| 5 | import { eq } from "drizzle-orm"; | 5 | import { eq } from "drizzle-orm"; | ||
| 6 | 6 | ||||
| @@ -11,13 +11,23 @@ export const users = table( | |||||
| 11 | email: t.text("email").notNull().unique(), | 11 | email: t.text("email").notNull().unique(), | ||
| 12 | password: t.text("password").notNull(), | 12 | password: t.text("password").notNull(), | ||
| 13 | nickname: t.text("nickname").notNull(), | 13 | nickname: t.text("nickname").notNull(), | ||
| 14 | - | status: t.text("status").$type<UserStatus>().default("offline"), | 14 | + | status: t.text("status").notNull().$type<UserStatus>().default("offline"), |
| 15 | avatarUrl: t.text("avatar_url"), | 15 | avatarUrl: t.text("avatar_url"), | ||
| 16 | createdAt: t.integer("created_at").notNull(), | 16 | createdAt: t.integer("created_at").notNull(), | ||
| 17 | lastSeen: t.integer("last_seen"), | 17 | lastSeen: t.integer("last_seen"), | ||
| 18 | } | 18 | } | ||
| 19 | ); | 19 | ); | ||
| 20 | 20 | ||||
| 21 | + | export const sessions = table( | |||
| 22 | + | "sessions", | |||
| 23 | + | { | |||
| 24 | + | id: t.text("id").primaryKey(), | |||
| 25 | + | userId: t.text("user_id").references(() => users.id).notNull(), | |||
| 26 | + | expiresAt: t.integer("expires_at").notNull(), | |||
| 27 | + | createdAt: t.integer("created_at").notNull(), | |||
| 28 | + | } | |||
| 29 | + | ); | |||
| 30 | + | ||||
| 21 | export const chatRooms = table( | 31 | export const chatRooms = table( | ||
| 22 | "chat_rooms", | 32 | "chat_rooms", | ||
| 23 | { | 33 | { | ||
| @@ -41,14 +51,15 @@ export const messages = table( | |||||
| 41 | ); | 51 | ); | ||
| 42 | 52 | ||||
| 43 | // Validate schema types against interfaces | 53 | // Validate schema types against interfaces | ||
| 44 | - | type UsersTable = typeof users.$inferSelect; | 54 | + | export type Users = typeof users.$inferSelect; |
| 45 | - | type ChatRoomsTable = typeof chatRooms.$inferSelect; | 55 | + | export type ChatRooms = typeof chatRooms.$inferSelect; |
| 46 | - | type MessagesTable = typeof messages.$inferSelect; | 56 | + | export type Messages = typeof messages.$inferSelect; |
| 47 | - | 57 | + | export type Sessions = typeof sessions.$inferSelect; | |
| 48 | // These type assertions will fail if the schema doesn't match the interfaces | 58 | // These type assertions will fail if the schema doesn't match the interfaces | ||
| 49 | - | type _UsersValidation = Omit<User, keyof UsersTable> & Omit<UsersTable, keyof User>; | 59 | + | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; |
| 50 | - | type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRoomsTable> & Omit<ChatRoomsTable, keyof ChatRoom>; | 60 | + | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; |
| 51 | - | type _MessagesValidation = Omit<Message, keyof MessagesTable> & Omit<MessagesTable, keyof Message>; | 61 | + | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; |
| 62 | + | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | |||
| 52 | 63 | ||||
| 53 | export const userView = t.sqliteView("user_view").as((qb) => | 64 | export const userView = t.sqliteView("user_view").as((qb) => | ||
| 54 | qb.select({ | 65 | qb.select({ | ||
| @@ -83,5 +94,3 @@ export const messageView = t.sqliteView("message_view").as((qb) => | |||||
| 83 | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | 94 | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | ||
| 84 | ); | 95 | ); | ||
| 85 | 96 | ||||
| 86 | - | ||||
| 87 | - | ||||
M src/lib/types/chat.ts +14 -4
| @@ -10,9 +10,9 @@ export interface User { | |||
| 10 | 10 | password: string; // hashed password | |
| 11 | 11 | nickname: string; | |
| 12 | 12 | status: UserStatus; // default is 'offline' | |
| 13 | - | avatarUrl?: string; | |
| 13 | + | avatarUrl?: string | null; | |
| 14 | 14 | createdAt: number; // Timestamp (e.g., Unix timestamp) | |
| 15 | - | lastSeen?: number; // Optional last active timestamp | |
| 15 | + | lastSeen?: number | null; // Optional last active timestamp | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | export type UserStatus = 'offline' | 'online' | 'away' | string; | |
| @@ -31,6 +31,16 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||
| 31 | 31 | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | |
| 32 | 32 | senderId: string; // Reference to the sender (User.id) | |
| 33 | 33 | content: string; | |
| 34 | - | type: 'chat' | 'emote' | 'system' | string; // Default is 'chat' | |
| 34 | + | type: MessageType; // Default is 'chat' | |
| 35 | 35 | timestamp: number; // Timestamp when the message was sent | |
| 36 | - | }⏎̸ | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export type MessageType = 'chat' | 'emote' | 'system' | string; | |
| 39 | + | ||
| 40 | + | export interface Session { | |
| 41 | + | id: string; | |
| 42 | + | userId: string; | |
| 43 | + | expiresAt: number; | |
| 44 | + | createdAt: number; | |
| 45 | + | } | |
| 46 | + | ||
| @@ -10,9 +10,9 @@ export interface User { | |||||
| 10 | password: string; // hashed password | 10 | password: string; // hashed password | ||
| 11 | nickname: string; | 11 | nickname: string; | ||
| 12 | status: UserStatus; // default is 'offline' | 12 | status: UserStatus; // default is 'offline' | ||
| 13 | - | avatarUrl?: string; | 13 | + | avatarUrl?: string | null; |
| 14 | createdAt: number; // Timestamp (e.g., Unix timestamp) | 14 | createdAt: number; // Timestamp (e.g., Unix timestamp) | ||
| 15 | - | lastSeen?: number; // Optional last active timestamp | 15 | + | lastSeen?: number | null; // Optional last active timestamp |
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | export type UserStatus = 'offline' | 'online' | 'away' | string; | 18 | export type UserStatus = 'offline' | 'online' | 'away' | string; | ||
| @@ -31,6 +31,16 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||||
| 31 | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | 31 | chatRoomId: string; // Reference to the chat room (ChatRoom.id) | ||
| 32 | senderId: string; // Reference to the sender (User.id) | 32 | senderId: string; // Reference to the sender (User.id) | ||
| 33 | content: string; | 33 | content: string; | ||
| 34 | - | type: 'chat' | 'emote' | 'system' | string; // Default is 'chat' | 34 | + | type: MessageType; // Default is 'chat' |
| 35 | timestamp: number; // Timestamp when the message was sent | 35 | timestamp: number; // Timestamp when the message was sent | ||
| 36 | - | }⏎̸ | 36 | + | } |
| 37 | + | ||||
| 38 | + | export type MessageType = 'chat' | 'emote' | 'system' | string; | |||
| 39 | + | ||||
| 40 | + | export interface Session { | |||
| 41 | + | id: string; | |||
| 42 | + | userId: string; | |||
| 43 | + | expiresAt: number; | |||
| 44 | + | createdAt: number; | |||
| 45 | + | } | |||
| 46 | + | ||||
A src/routes/api/session/login/+server.ts +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | import { json } from '@sveltejs/kit'; | |
| 2 | + | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 3 | + | ||
| 4 | + | export async function POST({ request, cookies }) { | |
| 5 | + | // For simplicity, assume the request includes the validated userId. | |
| 6 | + | const { userId } = await request.json(); | |
| 7 | + | ||
| 8 | + | // Generate a secure token and create the session record. | |
| 9 | + | const token = generateSessionToken(); | |
| 10 | + | const session = await createSession(token, userId); | |
| 11 | + | ||
| 12 | + | // Set the token as a cookie (adjust options as needed for production) | |
| 13 | + | cookies.set('session', token, { | |
| 14 | + | path: '/', | |
| 15 | + | httpOnly: true, | |
| 16 | + | secure: process.env.NODE_ENV === 'production', | |
| 17 | + | sameSite: 'lax', | |
| 18 | + | maxAge: 60 * 60 * 24 // 24h expiry | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | console.debug("Login endpoint created session:", session); | |
| 22 | + | ||
| 23 | + | return json({ session }); | |
| 24 | + | } ⏎̸ | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | import { json } from '@sveltejs/kit'; | |||
| 2 | + | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||
| 3 | + | ||||
| 4 | + | export async function POST({ request, cookies }) { | |||
| 5 | + | // For simplicity, assume the request includes the validated userId. | |||
| 6 | + | const { userId } = await request.json(); | |||
| 7 | + | ||||
| 8 | + | // Generate a secure token and create the session record. | |||
| 9 | + | const token = generateSessionToken(); | |||
| 10 | + | const session = await createSession(token, userId); | |||
| 11 | + | ||||
| 12 | + | // Set the token as a cookie (adjust options as needed for production) | |||
| 13 | + | cookies.set('session', token, { | |||
| 14 | + | path: '/', | |||
| 15 | + | httpOnly: true, | |||
| 16 | + | secure: process.env.NODE_ENV === 'production', | |||
| 17 | + | sameSite: 'lax', | |||
| 18 | + | maxAge: 60 * 60 * 24 // 24h expiry | |||
| 19 | + | }); | |||
| 20 | + | ||||
| 21 | + | console.debug("Login endpoint created session:", session); | |||
| 22 | + | ||||
| 23 | + | return json({ session }); | |||
| 24 | + | } ⏎̸ | |||
A src/routes/api/session/logout/+server.ts +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | import { json } from '@sveltejs/kit'; | |
| 2 | + | import { invalidateSession } from '$lib/api/session.server'; | |
| 3 | + | import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id | |
| 4 | + | ||
| 5 | + | export async function POST({ cookies }) { | |
| 6 | + | const token = cookies.get('session'); | |
| 7 | + | ||
| 8 | + | if (token) { | |
| 9 | + | const sessionId = await sha256(token); | |
| 10 | + | await invalidateSession(sessionId); | |
| 11 | + | cookies.delete('session', { path: '/' }); | |
| 12 | + | console.debug("Logged out session with id:", sessionId); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | return json({ success: true }); | |
| 16 | + | } ⏎̸ | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | import { json } from '@sveltejs/kit'; | |||
| 2 | + | import { invalidateSession } from '$lib/api/session.server'; | |||
| 3 | + | import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id | |||
| 4 | + | ||||
| 5 | + | export async function POST({ cookies }) { | |||
| 6 | + | const token = cookies.get('session'); | |||
| 7 | + | ||||
| 8 | + | if (token) { | |||
| 9 | + | const sessionId = await sha256(token); | |||
| 10 | + | await invalidateSession(sessionId); | |||
| 11 | + | cookies.delete('session', { path: '/' }); | |||
| 12 | + | console.debug("Logged out session with id:", sessionId); | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | return json({ success: true }); | |||
| 16 | + | } ⏎̸ | |||
A src/routes/api/session/validate/+server.ts +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | import { json } from '@sveltejs/kit'; | |
| 2 | + | import { validateSessionToken } from '$lib/api/session.server'; | |
| 3 | + | ||
| 4 | + | export async function GET({ cookies }) { | |
| 5 | + | const token = cookies.get('session'); | |
| 6 | + | ||
| 7 | + | if (!token) { | |
| 8 | + | console.debug("No session token found in cookies."); | |
| 9 | + | return json({ session: null, user: null }, { status: 401 }); | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | const result = await validateSessionToken(token); | |
| 13 | + | ||
| 14 | + | console.debug("Validate endpoint result:", result); | |
| 15 | + | ||
| 16 | + | return json(result); | |
| 17 | + | } ⏎̸ | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | import { json } from '@sveltejs/kit'; | |||
| 2 | + | import { validateSessionToken } from '$lib/api/session.server'; | |||
| 3 | + | ||||
| 4 | + | export async function GET({ cookies }) { | |||
| 5 | + | const token = cookies.get('session'); | |||
| 6 | + | ||||
| 7 | + | if (!token) { | |||
| 8 | + | console.debug("No session token found in cookies."); | |||
| 9 | + | return json({ session: null, user: null }, { status: 401 }); | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | const result = await validateSessionToken(token); | |||
| 13 | + | ||||
| 14 | + | console.debug("Validate endpoint result:", result); | |||
| 15 | + | ||||
| 16 | + | return json(result); | |||
| 17 | + | } ⏎̸ | |||