225 lines
5.5 KiB
TypeScript
225 lines
5.5 KiB
TypeScript
import { clearAuth, getAuth, getToken, setAuth } from "@/lib/auth"
|
|
|
|
export const API_URL = import.meta.env.VITE_API_URL ?? ""
|
|
|
|
export function posterUrl(path: string | undefined | null): string | undefined {
|
|
if (!path) return undefined
|
|
if (path.startsWith("http://") || path.startsWith("https://")) return path
|
|
const clean = path.startsWith("/") ? path.slice(1) : path
|
|
return `${API_URL}/images/${clean}`
|
|
}
|
|
|
|
export function tmdbProfileUrl(path: string | undefined | null): string | undefined {
|
|
if (!path) return undefined
|
|
return `https://image.tmdb.org/t/p/w185${path}`
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number
|
|
body: string
|
|
constructor(status: number, body: string) {
|
|
super(`API ${status}: ${body}`)
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
function authHeaders(): HeadersInit {
|
|
const token = getToken()
|
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
}
|
|
|
|
function buildUrl(
|
|
path: string,
|
|
params?: Record<string, unknown>,
|
|
): string {
|
|
const base = `${API_URL}/api/v1${path}`
|
|
if (!params) return base
|
|
const sp = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v != null) sp.set(k, String(v))
|
|
}
|
|
const qs = sp.toString()
|
|
return qs ? `${base}?${qs}` : base
|
|
}
|
|
|
|
let refreshPromise: Promise<boolean> | null = null
|
|
|
|
async function tryRefresh(): Promise<boolean> {
|
|
const auth = getAuth()
|
|
if (!auth?.refresh_token) return false
|
|
try {
|
|
const res = await fetch(buildUrl("/auth/refresh"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
|
})
|
|
if (!res.ok) return false
|
|
const data = await res.json()
|
|
setAuth({
|
|
...auth,
|
|
token: data.token,
|
|
refresh_token: data.refresh_token,
|
|
expires_at: data.expires_at,
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function isInvalidCredentials(res: Response): Promise<boolean> {
|
|
try {
|
|
const body = await res.clone().json()
|
|
return body?.code === "INVALID_CREDENTIALS"
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function request<T = void>(
|
|
url: string,
|
|
options?: RequestInit,
|
|
): Promise<T> {
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...authHeaders(),
|
|
...options?.headers,
|
|
},
|
|
})
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
// A failed re-auth check (wrong password) is not an expired session:
|
|
// no refresh, no retry, no logout.
|
|
if (await isInvalidCredentials(res)) {
|
|
throw new ApiError(res.status, await res.text())
|
|
}
|
|
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
|
|
clearAuth()
|
|
window.location.href = "/app/login"
|
|
throw new ApiError(res.status, await res.text())
|
|
}
|
|
|
|
if (!refreshPromise) {
|
|
refreshPromise = tryRefresh().finally(() => {
|
|
refreshPromise = null
|
|
})
|
|
}
|
|
const ok = await refreshPromise
|
|
if (!ok) {
|
|
clearAuth()
|
|
window.location.href = "/app/login"
|
|
throw new ApiError(res.status, await res.text())
|
|
}
|
|
|
|
const retryRes = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...authHeaders(),
|
|
...options?.headers,
|
|
},
|
|
})
|
|
if (!retryRes.ok) {
|
|
throw new ApiError(retryRes.status, await retryRes.text())
|
|
}
|
|
const retryText = await retryRes.text()
|
|
return retryText ? JSON.parse(retryText) : (undefined as T)
|
|
}
|
|
throw new ApiError(res.status, await res.text())
|
|
}
|
|
const text = await res.text()
|
|
return text ? JSON.parse(text) : (undefined as T)
|
|
}
|
|
|
|
export async function get<T>(
|
|
path: string,
|
|
params?: Record<string, unknown>,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path, params))
|
|
}
|
|
|
|
export async function post<T = void>(
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
}
|
|
|
|
export async function put<T = void>(
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path), {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
}
|
|
|
|
export async function putForm<T = void>(
|
|
path: string,
|
|
form: FormData,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path), {
|
|
method: "PUT",
|
|
body: form,
|
|
})
|
|
}
|
|
|
|
export async function patch<T = void>(
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path), {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
}
|
|
|
|
export async function del<T = void>(path: string): Promise<T> {
|
|
return request<T>(buildUrl(path), { method: "DELETE" })
|
|
}
|
|
|
|
export async function delWithBody<T = void>(
|
|
path: string,
|
|
body: unknown,
|
|
): Promise<T> {
|
|
return request<T>(buildUrl(path), {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
export async function upload<T>(
|
|
path: string,
|
|
file: File,
|
|
): Promise<T> {
|
|
const form = new FormData()
|
|
form.append("file", file)
|
|
return request<T>(buildUrl(path), {
|
|
method: "POST",
|
|
body: form,
|
|
})
|
|
}
|
|
|
|
export async function uploadWithFields<T>(
|
|
path: string,
|
|
file: File,
|
|
fields: Record<string, string>,
|
|
): Promise<T> {
|
|
const form = new FormData()
|
|
form.append("file", file)
|
|
for (const [k, v] of Object.entries(fields)) form.append(k, v)
|
|
return request<T>(buildUrl(path), {
|
|
method: "POST",
|
|
body: form,
|
|
})
|
|
}
|