detached 872a426

feat: implement session backend logic

commit
872a426c72092e97b7635d10e2d1f42fd10dc9c3
author
Kit · · 2025-02-12 13:57 UTC
parent
08d9c0f
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The 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 @@
11 import { sqliteTable as table } from "drizzle-orm/sqlite-core";
22 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";
44 import { v4 as uuidv4 } from "uuid";
55 import { eq } from "drizzle-orm";
66
@@ -11,13 +11,23 @@ export const users = table(
1111 email: t.text("email").notNull().unique(),
1212 password: t.text("password").notNull(),
1313 nickname: t.text("nickname").notNull(),
14- status: t.text("status").$type<UserStatus>().default("offline"),
14+ status: t.text("status").notNull().$type<UserStatus>().default("offline"),
1515 avatarUrl: t.text("avatar_url"),
1616 createdAt: t.integer("created_at").notNull(),
1717 lastSeen: t.integer("last_seen"),
1818 }
1919 );
2020
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+
2131 export const chatRooms = table(
2232 "chat_rooms",
2333 {
@@ -41,14 +51,15 @@ export const messages = table(
4151 );
4252
4353 // 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;
4858 // 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>;
5263
5364 export const userView = t.sqliteView("user_view").as((qb) =>
5465 qb.select({
@@ -83,5 +94,3 @@ export const messageView = t.sqliteView("message_view").as((qb) =>
8394 .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
8495 );
8596
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 interfaces53 // 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 interfaces58 // 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 {
1010 password: string; // hashed password
1111 nickname: string;
1212 status: UserStatus; // default is 'offline'
13- avatarUrl?: string;
13+ avatarUrl?: string | null;
1414 createdAt: number; // Timestamp (e.g., Unix timestamp)
15- lastSeen?: number; // Optional last active timestamp
15+ lastSeen?: number | null; // Optional last active timestamp
1616 }
1717
1818 export type UserStatus = 'offline' | 'online' | 'away' | string;
@@ -31,6 +31,16 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
3131 chatRoomId: string; // Reference to the chat room (ChatRoom.id)
3232 senderId: string; // Reference to the sender (User.id)
3333 content: string;
34- type: 'chat' | 'emote' | 'system' | string; // Default is 'chat'
34+ type: MessageType; // Default is 'chat'
3535 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 password10 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 timestamp15+ 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 sent35 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+} ⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close