132 lines
4.3 KiB
JavaScript
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)}`),
|
|
}
|