Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
157 lines
4.6 KiB
TypeScript
157 lines
4.6 KiB
TypeScript
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
|
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
|
|
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
|
|
import { isSameSession } from "@/lib/session-request-guard"
|
|
import { translateCurrentMessage } from "@/i18n/messages"
|
|
|
|
const API_BASE_URL =
|
|
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
|
|
|
type JsonResult<T> = {
|
|
errorCode: number
|
|
message: string
|
|
data: T
|
|
success: boolean
|
|
}
|
|
|
|
type RequestOptions = RequestInit & {
|
|
skipAuth?: boolean
|
|
baseUrl?: string
|
|
onResponse?: (response: Response) => void
|
|
}
|
|
|
|
export type BlobResponse = {
|
|
blob: Blob
|
|
filename: string
|
|
}
|
|
|
|
export class ApiRequestError extends Error {
|
|
errorCode?: number
|
|
data?: unknown
|
|
|
|
constructor(message: string, errorCode?: number, data?: unknown, options?: ErrorOptions) {
|
|
super(message, options)
|
|
this.name = "ApiRequestError"
|
|
this.errorCode = errorCode
|
|
this.data = data
|
|
}
|
|
}
|
|
|
|
export function isApiRequestError(error: unknown): error is ApiRequestError {
|
|
return error instanceof ApiRequestError
|
|
}
|
|
|
|
function normalizeApiRequestError(error: unknown) {
|
|
if (isApiRequestError(error)) {
|
|
return error
|
|
}
|
|
return new ApiRequestError(
|
|
translateCurrentMessage("api.requestFailed"),
|
|
undefined,
|
|
undefined,
|
|
{ cause: error }
|
|
)
|
|
}
|
|
|
|
async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
|
|
const payload = (await response.json()) as JsonResult<T>
|
|
if (!response.ok || !payload.success) {
|
|
if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
|
|
expireSession()
|
|
}
|
|
throw new ApiRequestError(
|
|
payload.message || translateCurrentMessage("api.requestFailed"),
|
|
payload.errorCode,
|
|
payload.data
|
|
)
|
|
}
|
|
return payload.data
|
|
}
|
|
|
|
function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
|
|
const authHeaders = new Headers(headers)
|
|
|
|
if (session?.accessToken) {
|
|
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
|
|
}
|
|
if (
|
|
!authHeaders.has("Content-Type") &&
|
|
body &&
|
|
!(typeof FormData !== "undefined" && body instanceof FormData)
|
|
) {
|
|
authHeaders.set("Content-Type", "application/json")
|
|
}
|
|
return authHeaders
|
|
}
|
|
|
|
function parseFilename(contentDisposition: string | null) {
|
|
if (!contentDisposition) {
|
|
return ""
|
|
}
|
|
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
|
if (utf8Match?.[1]) {
|
|
return decodeURIComponent(utf8Match[1])
|
|
}
|
|
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
|
|
return match?.[1] ? decodeURIComponent(match[1]) : ""
|
|
}
|
|
|
|
export async function request<T>(
|
|
path: string,
|
|
options: RequestOptions = {}
|
|
): Promise<T> {
|
|
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
|
delete (rest as RequestOptions).baseUrl
|
|
delete (rest as RequestOptions).onResponse
|
|
const requestSession = skipAuth ? null : readSession()
|
|
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
|
|
|
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
|
try {
|
|
const response = await fetch(`${requestBaseUrl}${path}`, {
|
|
...rest,
|
|
headers: authHeaders,
|
|
cache: "no-store",
|
|
})
|
|
onResponse?.(response)
|
|
return await parseResult<T>(response, requestSession)
|
|
} catch (error) {
|
|
throw normalizeApiRequestError(error)
|
|
}
|
|
}
|
|
|
|
export async function requestBlob(
|
|
path: string,
|
|
options: RequestOptions = {}
|
|
): Promise<BlobResponse> {
|
|
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
|
delete (rest as RequestOptions).baseUrl
|
|
delete (rest as RequestOptions).onResponse
|
|
const requestSession = skipAuth ? null : readSession()
|
|
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
|
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
|
const response = await fetch(`${requestBaseUrl}${path}`, {
|
|
...rest,
|
|
headers: authHeaders,
|
|
cache: "no-store",
|
|
})
|
|
onResponse?.(response)
|
|
|
|
const contentType = response.headers.get("Content-Type") ?? ""
|
|
if (contentType.includes("application/json")) {
|
|
try {
|
|
await parseResult<never>(response, requestSession)
|
|
} catch (error) {
|
|
throw error
|
|
}
|
|
throw new ApiRequestError(translateCurrentMessage("api.requestFailed"))
|
|
}
|
|
if (!response.ok) {
|
|
throw new ApiRequestError(response.statusText || translateCurrentMessage("api.requestFailed"))
|
|
}
|
|
return {
|
|
blob: await response.blob(),
|
|
filename: parseFilename(response.headers.get("Content-Disposition")),
|
|
}
|
|
}
|