Files
uPortal2/frontend/src/api.js
T

132 lines
4.3 KiB
JavaScript

// 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)}`),
}