import { get, writable, type Writable } from "svelte/store" import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const" import { goto } from "$app/navigation" import { usersPagination } from "./pages" interface UserResponse { id: string username: string isAdmin: boolean createdAt: Date updatedAt: Date } export interface FullNoteResponse { id: string owner: string title: string content: string versionNumber: number versionCreatedAt: Date noteCreatedAt: Date noteUpdatedAt: Date } export interface NoteMetadataResponse { id: string owner: string title: string updatedAt: Date } interface NewNoteResponse { title: string content: string } interface VersionMetadataResponse { versionID: string title: string } interface FullVersionResponse { versionID: string title: string content: string versionNumber: number createdAt: Date } export const currentUser: Writable = writable(null) export const currentFullNote: Writable = writable(null) export const availableNotes: Writable = writable(null) export const accessToken: Writable = writable(null) export const csrfToken: Writable = writable(null) export const isPending: Writable = writable(false) export const cError: Writable = writable(null) class ApiClient { private viewCookieName: string private baseUrl: string private lastAtUpdate = new Date(0) private lastCsrfUpdate = new Date(0) private refreshInProgress = false constructor(baseUrl: string) { this.baseUrl = baseUrl this.viewCookieName = "notatest.expires_at" } private async handleRequest( fn: () => Promise, options: { useBearerAuth: boolean } ): Promise { isPending.set(true) cError.set(null) // NOTE: If `handleResponse` is used, errors thrown from it will be caught here try { // Only bearers (JWT access tokens) need to be considered here as the refresh token cookies // will be automatically included to corresponding requests by browser and CSRF rotation is // handled by the `refreshAccessToken` method if (options.useBearerAuth) { await this.checkAndRefreshAccessToken() } return await fn() } catch (err) { cError.set(err instanceof Error ? err.message : "Unknown error") console.log(`error: ${get(cError)}`) } finally { isPending.set(false) } } // Should be attached to routes that handle authentication with the bearer token (access token) private async handleResponse( response: Response, options: { useBearerAuth: boolean } ): Promise { if (!response.ok) { if (response.status === 401 && options.useBearerAuth) { // This should never happen due to the token expiration checks we make client-side, // but it's still good to have as a fallback try { console.log("unexpected 401 caught, attempting refresh") await this.checkAndRefreshAccessToken() } catch (err) { console.log("refresh attempt not successful") await this.handleLocalLogout() throw new Error("Session expired, please authenticate again.") } } // Capitalize the error message and display (only if not 401) const { error } = await response.json() const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "." throw new Error(dError) } return await response.json() } private async checkAndRefreshAccessToken(): Promise { // Notably we must check whether we even have an authentication cookie present // as that's obviously required for completing the token rotation procedure if (this.refreshInProgress || !this.isAuthCookieValid) { return } const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime() const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF console.log(`timeSinceUpdate: ${timeSinceUpdate}`) if (needsRefresh) { console.log("running token refresh attempt") this.refreshInProgress = true await this.refreshAccessToken() this.refreshInProgress = false } } private async refreshAccessToken(): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, { method: "POST", headers: { ...(await this.getCsrfHeader()) }, credentials: "include" }) if (!response.ok) { await this.handleLocalLogout() throw new Error("Refreshing token failed.") } const { access_token: newToken } = await response.json() accessToken.set(newToken) this.lastAtUpdate = new Date() }, { useBearerAuth: false } ) } private async getCsrfHeader(): Promise { let token = get(csrfToken) const timeSinceUpdate = Date.now() - this.lastCsrfUpdate.getTime() const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF if (!token || needsRefresh) { console.log("refreshing csrf token") await this.refreshCsrfToken() token = get(csrfToken) } return { "X-Csrf-Token": token || "" } } private async refreshCsrfToken(): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, { credentials: "include" }) if (!response.ok) { await this.handleLocalLogout() throw new Error("Fetching CSRF token failed.") } const newToken = response.headers.get("X-Csrf-Token") csrfToken.set(newToken) this.lastCsrfUpdate = new Date() }, { useBearerAuth: false } ) } private async handleLocalLogout(): Promise { this.lastAtUpdate = new Date(0) this.lastCsrfUpdate = new Date(0) currentUser.set(null) accessToken.set(null) csrfToken.set(null) await goto("/login") } private getAuthHeader(): HeadersInit { const token = get(accessToken) return { Authorization: `Bearer ${token}` } } private isAuthCookieValid(): boolean { const now = new Date() const cookies = document.cookie.split(";") for (let cookie of cookies) { const [name, value] = cookie.trim().split("=") if (name === this.viewCookieName) { const expirationTimestamp = parseInt(value, 10) if (!isNaN(expirationTimestamp)) { const expirationDate = new Date(expirationTimestamp * 1000) console.log(`auth cookie expirationDate: ${expirationDate}`) return now < expirationDate } } } // Fallback when the cookie can't be found return false } public async register(username: string, password: string): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }) // Can't overwrite `username` parameter const data = await this.handleResponse<{ id: string username: string }>(response, { useBearerAuth: false }) console.log(`${data.username} -> ${data.id}`) await goto("/login") }, { useBearerAuth: false } ) } public async login(username: string, password: string): Promise { return this.handleRequest( async () => { const params = new URLSearchParams() params.append("includeUser", "true") const response = await fetch(`${this.baseUrl}/auth/login?${params}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), credentials: "include" }) const { access_token: token, user } = await this.handleResponse<{ access_token: string user: UserResponse }>(response, { useBearerAuth: false }) accessToken.set(token) currentUser.set(user || null) this.lastAtUpdate = new Date() console.log(user) goto("/") }, { useBearerAuth: false } ) } public async logout(): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/logout`, { method: "POST", headers: { ...this.getAuthHeader() }, credentials: "include" }) if (response.status === 204) { console.log("logout successful") await this.handleLocalLogout() return } await this.handleResponse(response, { useBearerAuth: false }) await this.handleLocalLogout() }, { useBearerAuth: true } ) } public async getCurrentUser(): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/me`, { headers: { ...this.getAuthHeader() } }) const user = await this.handleResponse(response, { useBearerAuth: false }) currentUser.set(user) }, { useBearerAuth: true } ) } public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise { return this.handleRequest( async () => { const data = { old_password: oldPassword, new_password: newPassword } const response = await fetch(`${this.baseUrl}/auth/owner`, { method: "PUT", headers: { ...this.getAuthHeader() }, body: JSON.stringify(data) }) const { accessToken: token, user } = await this.handleResponse<{ accessToken: string user: UserResponse }>(response, { useBearerAuth: false }) accessToken.set(token) currentUser.set(user || null) this.lastAtUpdate = new Date() console.log(user) }, { useBearerAuth: true } ) } public async deleteCurrentUser(password: string): Promise { return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/owner`, { method: "DELETE", headers: { ...this.getAuthHeader(), credentials: "include" }, body: JSON.stringify({ password }) }) if (response.status === 204) { console.log("deletion successful") await this.handleLocalLogout() return } await this.handleResponse(response, { useBearerAuth: false }) await this.handleLocalLogout() }, { useBearerAuth: true } ) } public async adminListAll(): Promise { const user = get(currentUser) if (!user || !user.isAdmin) { throw new Error("Admin privileges required.") } return this.handleRequest( async () => { const params = new URLSearchParams() params.append("limit", `${get(usersPagination).pageSize}`) params.append("offset", `${get(usersPagination).currentPage}`) const response = await fetch(`${this.baseUrl}/auth/admin/all?${params}`, { headers: { ...this.getAuthHeader() } }) const users = await this.handleResponse(response, { useBearerAuth: false }) console.log(`admin: got ${users.length} user results`) return users }, { useBearerAuth: true } ) } public async adminDeleteUser(userID: string): Promise { const user = get(currentUser) if (!user || !user.isAdmin) { throw new Error("Admin privileges required.") } if (!UUID_REGEX.test(userID)) { throw new Error("Invalid user ID format.") } return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/auth/admin/${userID}`, { method: "DELETE", headers: { ...this.getAuthHeader() } }) if (response.status === 204) { console.log("admin: deletion successful") return } await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } public async listNotes(): Promise { return this.handleRequest( async () => { const params = new URLSearchParams() params.append("limit", `${get(usersPagination).pageSize}`) params.append("offset", `${get(usersPagination).currentPage}`) const response = await fetch(`${this.baseUrl}/notes?${params}`, { headers: { ...this.getAuthHeader() } }) // TODO: handle case where no notes have yet been created, i.e. `notes` from response is `null` const notes = await this.handleResponse(response, { useBearerAuth: false }) console.log(`got ${notes.length} note metadata results`) return notes }, { useBearerAuth: true } ) } public async createNote(): Promise { // NOTE: The initial note version doesn't allow any user input, the first user-made modification // is applied through the version creation endpoint return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes`, { method: "POST", headers: { ...this.getAuthHeader() } }) return await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } public async getFullNote(noteID: string): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/${noteID}`, { headers: { ...this.getAuthHeader() } }) return await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } public async deleteNote(noteID: string): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}`, { method: "DELETE", headers: { ...this.getAuthHeader() } }) if (response.status === 204) { console.log("deletion successful") return } await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } public async getNoteHistory(noteID: string): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, { headers: { ...this.getAuthHeader() } }) const versions = await this.handleResponse(response, { useBearerAuth: false }) console.log(`got ${versions.length} version metadata results`) return versions }, { useBearerAuth: true } ) } public async createVersion(noteID: string, title: string, content: string): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } // NOTE: Title's length limit is applied in the UI component, so we don't need to worry about it here return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, { method: "POST", headers: { ...this.getAuthHeader() } }) if (response.status === 204) { console.log("creation successful") return } await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } public async getFullVersion( noteID: string, versionID: string ): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } if (!UUID_REGEX.test(versionID)) { throw new Error("Invalid version ID format.") } return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, { headers: { ...this.getAuthHeader() } }) return await this.handleResponse(response, { useBearerAuth: false }) }, { useBearerAuth: true } ) } } export const apiClient = new ApiClient(API_BASE_ADDR)