ファイル
btrc-hub/frontend/src/lib/api.ts
T
2026-10-26 12:44:22 +09:00

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)
}