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 signal?: AbortSignal responseType?: 'blob' } const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true }) type CSRFState = NonNullable 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 => { const token = await csrfToken () const config = withSession (opt) return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } } } const csrfToken = async (): Promise => { 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 ( send: (config: AxiosRequestConfig) => Promise>, opt?: Opt, ): Promise> => { 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 ( method: 'post' | 'put' | 'patch', path: string, body?: unknown, opt?: Opt): Promise => { 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, { deep: true }) as T } export const apiGet = async ( path: string, opt?: Opt): Promise => { 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, { deep: true }) as T } export const apiPost = async ( path: string, body?: unknown, opt?: Opt): Promise => apiP ('post', path, body, opt) export const apiPut = async ( path: string, body?: unknown, opt?: Opt): Promise => apiP ('put', path, body, opt) export const apiPatch = async ( path: string, body?: unknown, opt?: Opt): Promise => apiP ('patch', path, body, opt) export const apiDelete = async ( path: string, opt?: Opt): Promise => { 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, { deep: true }) as T } export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err) // The server acknowledges a complete file before native download starts. export const apiDownload = async ( path: string, body: Record, locale: LocaleCode, onError?: () => void, ): Promise => { 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) }