// Small fetch wrapper around the goChat REST API. // // Notes on the backend contract: // * Auth uses an httpOnly session cookie, so every request must be sent // with `credentials: 'include'`. // * State changing requests require an `X-CSRF-Token` header that matches // the (non-httpOnly) `csrf` cookie. // * The API answers with `{ "error": "..." }` on failures. // // All requests use relative /api paths on the same origin. In development the // esbuild dev server proxies /api to the backend; in production nginx does the // same. Either way the app never needs to know the backend's address. const CSRF_COOKIE = 'csrf' function readCookie(name) { const prefix = `${name}=` const parts = document.cookie ? document.cookie.split('; ') : [] for (const part of parts) { if (part.startsWith(prefix)) { return decodeURIComponent(part.slice(prefix.length)) } } return '' } export class ApiError extends Error { constructor(message, status) { super(message) this.name = 'ApiError' this.status = status } } async function parseBody(response) { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } // A GET request is used to seed the csrf cookie when it is missing. The // endpoint may answer 401 when nobody is signed in, which is fine here. async function ensureCsrf() { if (readCookie(CSRF_COOKIE)) return readCookie(CSRF_COOKIE) try { await fetch('/api/profile', { credentials: 'include' }) } catch { // ignore, the request below will surface any real problem } return readCookie(CSRF_COOKIE) } export async function request(method, path, body, options = {}) { const headers = { ...(options.headers || {}) } const isFormData = body instanceof FormData if (body !== undefined && body !== null && !isFormData) { headers['Content-Type'] = 'application/json' } if (method !== 'GET' && method !== 'HEAD') { const token = await ensureCsrf() if (token) headers['X-CSRF-Token'] = token } const response = await fetch(path, { method, credentials: 'include', headers, body: isFormData ? body : body !== undefined && body !== null ? JSON.stringify(body) : undefined, }) if (response.status === 204) return null const data = await parseBody(response) if (!response.ok) { const message = (data && data.error) || response.statusText || 'Request failed' throw new ApiError(message, response.status) } return data } export const api = { // auth signup: (payload) => request('POST', '/api/auth/signup', payload), signin: (payload) => request('POST', '/api/auth/signin', payload), signout: () => request('POST', '/api/auth/signout'), // profile getProfile: () => request('GET', '/api/profile'), updateProfile: (payload) => request('PATCH', '/api/profile', payload), updatePassword: (payload) => request('PUT', '/api/profile/password', payload), uploadAvatar: (file) => { const form = new FormData() form.append('avatar', file) return request('PUT', '/api/profile/avatar', form) }, deleteAvatar: () => request('DELETE', '/api/profile/avatar'), avatarUrl: () => '/api/profile/avatar', // sessions listSessions: () => request('GET', '/api/sessions'), revokeSession: (token) => request('DELETE', `/api/sessions/${encodeURIComponent(token)}`), revokeAllSessions: () => request('DELETE', '/api/sessions'), // invites (admin) listInvites: (limit = 50, offset = 0) => request('GET', `/api/invites?limit=${limit}&offset=${offset}`), createInvite: (payload) => request('POST', '/api/invites', payload), deleteInvite: (token) => request('DELETE', `/api/invites/${encodeURIComponent(token)}`), // users (admin) listUsers: (includeInactive = false, limit = 50, offset = 0) => request( 'GET', `/api/users?includeInactive=${includeInactive}&limit=${limit}&offset=${offset}`, ), changeUserPassword: (id, password) => request('PUT', `/api/users/${id}/password`, { password }), revokeUserSessions: (id) => request('DELETE', `/api/users/${id}/sessions`), // jitsi (moderator rights are derived from the user's admin flag server-side) listRooms: () => request('GET', '/api/jitsi/rooms'), joinRoom: (room) => request('POST', '/api/jitsi/rooms', { room }), deleteRoom: (room) => request('DELETE', `/api/jitsi/rooms/${encodeURIComponent(room)}`), }