Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
+136
-9
@@ -2,22 +2,130 @@ import axios from 'axios'
|
||||
import toCamel from 'camelcase-keys'
|
||||
|
||||
import { API_BASE_URL } from '@/config'
|
||||
import { currentRenderContext } from '@/lib/renderContext'
|
||||
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios'
|
||||
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 })
|
||||
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
|
||||
type CSRFState = NonNullable<RenderContext['csrf']>
|
||||
let browserCSRF: CSRFState = { }
|
||||
|
||||
|
||||
const withUserCode = (opt?: Opt): Opt => ({
|
||||
...opt,
|
||||
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
|
||||
...(opt?.headers ?? { }) } })
|
||||
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> (
|
||||
@@ -25,7 +133,11 @@ const apiP = async <T> (
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client[method] (path, body ?? { }, withUserCode (opt))
|
||||
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
|
||||
@@ -35,7 +147,8 @@ const apiP = async <T> (
|
||||
export const apiGet = async <T> (
|
||||
path: string,
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client.get (path, withUserCode (opt))
|
||||
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
|
||||
@@ -63,7 +176,21 @@ export const apiPatch = async <T> (
|
||||
export const apiDelete = async <T = void> (
|
||||
path: string,
|
||||
opt?: Opt): Promise<T> => {
|
||||
const res = await client.delete (path, withUserCode (opt))
|
||||
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
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする