230 行
6.9 KiB
TypeScript
230 行
6.9 KiB
TypeScript
import axios from 'axios'
|
|
import toCamel from 'camelcase-keys'
|
|
|
|
import { API_BASE_URL } from '@/config'
|
|
import { currentRenderContext } from '@/lib/renderContext'
|
|
|
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
|
|
|
|
import type { LocaleCode } from '@/lib/locales'
|
|
import type { RenderContext } from '@/lib/renderContext'
|
|
|
|
type Opt = {
|
|
locale?: LocaleCode
|
|
params?: AxiosRequestConfig['params']
|
|
headers?: Record<string, string>
|
|
signal?: AbortSignal
|
|
responseType?: 'blob' }
|
|
|
|
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
|
|
type CSRFState = NonNullable<RenderContext['csrf']>
|
|
let browserCSRF: CSRFState = { }
|
|
|
|
|
|
const csrfState = (): CSRFState => {
|
|
if (typeof window !== 'undefined')
|
|
return browserCSRF
|
|
const context = currentRenderContext ()
|
|
if (context == null)
|
|
throw new Error ('Missing request context for CSRF')
|
|
return context.csrf ??= { }
|
|
}
|
|
|
|
|
|
const resetCSRF = (): void => {
|
|
if (typeof window !== 'undefined')
|
|
browserCSRF = { }
|
|
else
|
|
{
|
|
const context = currentRenderContext ()
|
|
if (context != null)
|
|
context.csrf = { }
|
|
}
|
|
}
|
|
|
|
|
|
const withSession = (opt?: Opt): AxiosRequestConfig => {
|
|
const { locale, ...config } = opt ?? { }
|
|
return {
|
|
...config,
|
|
...(currentRenderContext ()?.apiBaseURL != null
|
|
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
|
...(locale != null && { params: { locale, ...config.params } }),
|
|
headers: { ...(currentRenderContext ()?.cookie != null && {
|
|
Cookie: currentRenderContext ()!.cookie }),
|
|
...(typeof window === 'undefined' && currentRenderContext () != null && {
|
|
'X-Forwarded-Host': new URL (currentRenderContext ()!.origin).host,
|
|
'X-Forwarded-Proto': new URL (currentRenderContext ()!.origin)
|
|
.protocol.replace (':', '') }),
|
|
...(currentRenderContext ()?.forwardedFor != null && {
|
|
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
|
|
...(config.headers ?? { }) } }
|
|
}
|
|
|
|
|
|
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
|
|
const token = await csrfToken ()
|
|
const config = withSession (opt)
|
|
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
|
|
}
|
|
|
|
|
|
const csrfToken = async (): Promise<string> => {
|
|
const state = csrfState ()
|
|
if (state.token != null)
|
|
return state.token
|
|
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
|
|
.then (response => {
|
|
if (csrfState () !== state)
|
|
return csrfToken ()
|
|
if (typeof response.data.token !== 'string')
|
|
throw new Error ('Missing CSRF token')
|
|
rememberSessionCookies (response.headers)
|
|
state.token = response.data.token
|
|
delete state.pending
|
|
return state.token
|
|
}).catch (error => {
|
|
delete state.pending
|
|
throw error
|
|
})
|
|
return state.pending
|
|
}
|
|
|
|
|
|
const csrfWrite = async <T,> (
|
|
send: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
|
|
opt?: Opt,
|
|
): Promise<AxiosResponse<T>> => {
|
|
try
|
|
{
|
|
return await send (await withCSRF (opt))
|
|
}
|
|
catch (error)
|
|
{
|
|
if (axios.isAxiosError (error)
|
|
&& error.response?.headers?.['x-csrf-invalid'] === '1')
|
|
resetCSRF ()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
|
|
const rememberSessionCookies = (headers: AxiosResponse['headers']) => {
|
|
const context = currentRenderContext ()
|
|
const cookies = headers?.['set-cookie']
|
|
if (typeof window === 'undefined' && context != null && Array.isArray (cookies))
|
|
{
|
|
context.setCookies = cookies
|
|
const pairs = new Map ((context.cookie ?? '').split (';').filter (Boolean)
|
|
.map (value => value.trim ().split (/=(.*)/s).slice (0, 2) as [string, string]))
|
|
for (const cookie of cookies)
|
|
{
|
|
const pair = cookie.split (';')[0]
|
|
const separator = pair.indexOf ('=')
|
|
pairs.set (pair.slice (0, separator), pair.slice (separator + 1))
|
|
}
|
|
context.cookie = [...pairs].map (([key, value]) => `${key}=${value}`).join ('; ')
|
|
}
|
|
}
|
|
|
|
|
|
const apiP = async <T> (
|
|
method: 'post' | 'put' | 'patch',
|
|
path: string,
|
|
body?: unknown,
|
|
opt?: Opt): Promise<T> => {
|
|
const res = await csrfWrite (config => client[method] (path, body ?? { }, config), opt)
|
|
if (method === 'post' && (path === '/users' || path === '/users/code/renew'
|
|
|| (path === '/users/verify' && res.data?.valid)))
|
|
resetCSRF ()
|
|
rememberSessionCookies (res.headers)
|
|
if (opt?.responseType === 'blob')
|
|
return res.data as T
|
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
|
}
|
|
|
|
|
|
export const apiGet = async <T> (
|
|
path: string,
|
|
opt?: Opt): Promise<T> => {
|
|
const res = await client.get (path, withSession (opt))
|
|
rememberSessionCookies (res.headers)
|
|
if (opt?.responseType === 'blob')
|
|
return res.data as T
|
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
|
}
|
|
|
|
|
|
export const apiPost = async <T> (
|
|
path: string,
|
|
body?: unknown,
|
|
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
|
|
|
|
|
|
export const apiPut = async <T> (
|
|
path: string,
|
|
body?: unknown,
|
|
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
|
|
|
|
|
|
export const apiPatch = async <T> (
|
|
path: string,
|
|
body?: unknown,
|
|
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
|
|
|
|
|
|
export const apiDelete = async <T = void> (
|
|
path: string,
|
|
opt?: Opt): Promise<T> => {
|
|
const res = await csrfWrite (config => client.delete (path, config), opt)
|
|
if (path === '/users/session')
|
|
resetCSRF ()
|
|
rememberSessionCookies (res.headers)
|
|
if (typeof window !== 'undefined' && path === '/users/session')
|
|
{
|
|
try
|
|
{
|
|
localStorage.removeItem ('user_code')
|
|
}
|
|
catch
|
|
{
|
|
// The server session is already cleared even if storage is unavailable.
|
|
}
|
|
}
|
|
if (res.data == null || res.data === '')
|
|
return undefined as T
|
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
|
}
|
|
|
|
|
|
export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> =>
|
|
axios.isAxiosError (err)
|
|
|
|
|
|
// The server acknowledges a complete file before native download starts.
|
|
export const apiDownload = async (
|
|
path: string,
|
|
body: Record<string, string>,
|
|
locale: LocaleCode,
|
|
onError?: () => void,
|
|
): Promise<void> => {
|
|
const { token } = await apiPost<{ token: string }> (`${path}/prepare`, body, { locale })
|
|
if (typeof token !== 'string' || token.length === 0)
|
|
throw new Error ('Missing download token')
|
|
const frame = document.createElement ('iframe')
|
|
frame.hidden = true
|
|
frame.addEventListener ('load', () => {
|
|
const document = frame.contentDocument
|
|
if (document != null && document.URL !== 'about:blank')
|
|
{
|
|
onError?.()
|
|
frame.remove ()
|
|
}
|
|
})
|
|
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
|
|
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}`
|
|
document.body.append (frame)
|
|
// Attachments do not reliably fire load; keep this isolated from page navigation.
|
|
window.setTimeout (() => frame.remove (), 10 * 60_000)
|
|
}
|