600 lines
16 KiB
TypeScript
600 lines
16 KiB
TypeScript
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<UserResponse | null> = writable(null)
|
|
export const currentFullNote: Writable<FullNoteResponse | null> = writable(null)
|
|
export const availableNotes: Writable<NoteMetadataResponse[] | null> = writable(null)
|
|
export const accessToken: Writable<string | null> = writable(null)
|
|
export const csrfToken: Writable<string | null> = writable(null)
|
|
export const isPending: Writable<boolean> = writable(false)
|
|
export const cError: Writable<string | null> = 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<T>(
|
|
fn: () => Promise<T>,
|
|
options: { useBearerAuth: boolean }
|
|
): Promise<T | undefined> {
|
|
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<T>(
|
|
response: Response,
|
|
options: { useBearerAuth: boolean }
|
|
): Promise<T> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<HeadersInit> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void>(response, { useBearerAuth: false })
|
|
await this.handleLocalLogout()
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async getCurrentUser(): Promise<void> {
|
|
return this.handleRequest(
|
|
async () => {
|
|
const response = await fetch(`${this.baseUrl}/auth/me`, {
|
|
headers: {
|
|
...this.getAuthHeader()
|
|
}
|
|
})
|
|
const user = await this.handleResponse<UserResponse>(response, { useBearerAuth: false })
|
|
currentUser.set(user)
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise<void> {
|
|
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<void> {
|
|
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<void>(response, { useBearerAuth: false })
|
|
await this.handleLocalLogout()
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async adminListAll(): Promise<UserResponse[] | undefined> {
|
|
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<UserResponse[]>(response, { useBearerAuth: false })
|
|
console.log(`admin: got ${users.length} user results`)
|
|
|
|
return users
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async adminDeleteUser(userID: string): Promise<void> {
|
|
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<void>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async listNotes(): Promise<NoteMetadataResponse[] | undefined> {
|
|
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<NoteMetadataResponse[]>(response, {
|
|
useBearerAuth: false
|
|
})
|
|
console.log(`got ${notes.length} note metadata results`)
|
|
|
|
return notes
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async createNote(): Promise<NewNoteResponse | undefined> {
|
|
// 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<NewNoteResponse>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async getFullNote(noteID: string): Promise<FullNoteResponse | undefined> {
|
|
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<FullNoteResponse>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async deleteNote(noteID: string): Promise<void> {
|
|
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<void>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async getNoteHistory(noteID: string): Promise<VersionMetadataResponse[] | undefined> {
|
|
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<VersionMetadataResponse[]>(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<void> {
|
|
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<void>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
|
|
public async getFullVersion(
|
|
noteID: string,
|
|
versionID: string
|
|
): Promise<FullVersionResponse | undefined> {
|
|
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<FullVersionResponse>(response, { useBearerAuth: false })
|
|
},
|
|
{ useBearerAuth: true }
|
|
)
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient(API_BASE_ADDR)
|