Spaces:
Paused
Paused
Download apps/web/src/lib/api.ts from nathycodes/presentercoach: direct link, hf CLI and curl.
- Browser
- Download file 8.75 kB
-
https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/web/src/lib/api.ts
- Command line
-
hf download hf://spaces/nathycodes/presentercoach/apps/web/src/lib/api.ts
-
curl -L -o api.ts https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/web/src/lib/api.ts
8.75 kB
| export const API_URL = (process.env.NEXT_PUBLIC_API_URL || "/api/backend").replace(/\/+$/, ""); | |
| const SESSION_TOKEN_KEY = "sm_presenter_access_token"; | |
| const SESSION_TOKEN_EXP_KEY = "sm_presenter_access_exp_ms"; | |
| const REFRESH_TOKEN_KEY = "sm_presenter_refresh_token"; | |
| export type AuthTokenResponse = { | |
| access_token: string; | |
| refresh_token?: string | null; | |
| expires_in?: number | null; | |
| user?: { id: number; email: string; is_admin?: boolean }; | |
| }; | |
| export class ApiError extends Error { | |
| status: number; | |
| constructor(status: number, message: string) { | |
| super(message); | |
| this.name = "ApiError"; | |
| this.status = status; | |
| } | |
| } | |
| let accessToken: string | null = null; | |
| let accessTokenExpMs: number | null = null; | |
| let refreshTokenMem: string | null = null; | |
| let refreshPromise: Promise<string | null> | null = null; | |
| const readStorageItem = (key: string): string | null => { | |
| if (typeof window === "undefined") return null; | |
| try { | |
| const localVal = window.localStorage.getItem(key); | |
| if (localVal) return localVal; | |
| } catch { | |
| // Ignore localStorage sandbox restrictions | |
| } | |
| try { | |
| return window.sessionStorage.getItem(key); | |
| } catch { | |
| return null; | |
| } | |
| }; | |
| const writeStorageItem = (key: string, value: string | null) => { | |
| if (typeof window === "undefined") return; | |
| for (const store of ["localStorage", "sessionStorage"] as const) { | |
| try { | |
| if (value) { | |
| window[store].setItem(key, value); | |
| } else { | |
| window[store].removeItem(key); | |
| } | |
| } catch { | |
| // Ignore storage quota or sandbox restrictions | |
| } | |
| } | |
| }; | |
| const decodeBase64Url = (value: string) => { | |
| const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); | |
| const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); | |
| if (typeof atob === "function") { | |
| return atob(padded); | |
| } | |
| return Buffer.from(padded, "base64").toString("utf8"); | |
| }; | |
| const parseTokenExpMs = (token: string): number | null => { | |
| const parts = token.split("."); | |
| if (parts.length < 2) return null; | |
| try { | |
| const payload = JSON.parse(decodeBase64Url(parts[1])) as { exp?: number }; | |
| return typeof payload.exp === "number" ? payload.exp * 1000 : null; | |
| } catch { | |
| return null; | |
| } | |
| }; | |
| export const getToken = () => { | |
| if (!accessToken) { | |
| const stored = readStorageItem(SESSION_TOKEN_KEY); | |
| if (stored) { | |
| accessToken = stored; | |
| } | |
| } | |
| return accessToken; | |
| }; | |
| const getTokenExpMs = (token: string): number | null => { | |
| if (accessTokenExpMs !== null) { | |
| return accessTokenExpMs; | |
| } | |
| const storedExp = readStorageItem(SESSION_TOKEN_EXP_KEY); | |
| if (storedExp) { | |
| const parsed = Number(storedExp); | |
| if (Number.isFinite(parsed) && parsed > 0) { | |
| accessTokenExpMs = parsed; | |
| return parsed; | |
| } | |
| } | |
| return parseTokenExpMs(token); | |
| }; | |
| export const getRefreshToken = () => { | |
| if (!refreshTokenMem) { | |
| const stored = readStorageItem(REFRESH_TOKEN_KEY); | |
| if (stored) { | |
| refreshTokenMem = stored; | |
| } | |
| } | |
| return refreshTokenMem; | |
| }; | |
| export const hasValidToken = () => { | |
| const token = getToken(); | |
| if (!token) return false; | |
| const expMs = getTokenExpMs(token); | |
| if (!expMs) return false; | |
| return expMs - 5_000 > Date.now(); | |
| }; | |
| export const setToken = ( | |
| token: string, | |
| expiresInSeconds?: number | null, | |
| refreshToken?: string | null | |
| ) => { | |
| accessToken = token; | |
| writeStorageItem(SESSION_TOKEN_KEY, token); | |
| if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) { | |
| accessTokenExpMs = Date.now() + expiresInSeconds * 1000; | |
| writeStorageItem(SESSION_TOKEN_EXP_KEY, String(accessTokenExpMs)); | |
| } else { | |
| accessTokenExpMs = null; | |
| writeStorageItem(SESSION_TOKEN_EXP_KEY, null); | |
| } | |
| if (refreshToken) { | |
| refreshTokenMem = refreshToken; | |
| writeStorageItem(REFRESH_TOKEN_KEY, refreshToken); | |
| } | |
| }; | |
| export const clearToken = () => { | |
| accessToken = null; | |
| accessTokenExpMs = null; | |
| refreshTokenMem = null; | |
| writeStorageItem(SESSION_TOKEN_KEY, null); | |
| writeStorageItem(SESSION_TOKEN_EXP_KEY, null); | |
| writeStorageItem(REFRESH_TOKEN_KEY, null); | |
| }; | |
| const parseErrorMessage = async (response: Response) => { | |
| const contentType = response.headers.get("content-type") || ""; | |
| if (contentType.includes("application/json")) { | |
| const payload = await response.json().catch(() => null); | |
| if (payload && typeof payload.detail === "string") { | |
| return payload.detail; | |
| } | |
| } | |
| const message = await response.text().catch(() => ""); | |
| return message || `Request failed (${response.status})`; | |
| }; | |
| export const refreshAccessToken = async (): Promise<string | null> => { | |
| if (refreshPromise) { | |
| return refreshPromise; | |
| } | |
| refreshPromise = (async () => { | |
| try { | |
| const headers = new Headers(); | |
| const storedRefresh = getRefreshToken(); | |
| let body: string | undefined; | |
| if (storedRefresh) { | |
| headers.set("X-Refresh-Token", storedRefresh); | |
| headers.set("Content-Type", "application/json"); | |
| body = JSON.stringify({ refresh_token: storedRefresh }); | |
| } | |
| const response = await fetch(`${API_URL}/auth/refresh`, { | |
| method: "POST", | |
| headers, | |
| body, | |
| credentials: "include", | |
| }); | |
| if (!response.ok) { | |
| clearToken(); | |
| return null; | |
| } | |
| const payload = (await response.json()) as AuthTokenResponse; | |
| if (!payload.access_token) { | |
| clearToken(); | |
| return null; | |
| } | |
| setToken(payload.access_token, payload.expires_in, payload.refresh_token); | |
| return payload.access_token; | |
| } catch { | |
| clearToken(); | |
| return null; | |
| } finally { | |
| refreshPromise = null; | |
| } | |
| })(); | |
| return refreshPromise; | |
| }; | |
| export const ensureAccessToken = async () => { | |
| const current = getToken(); | |
| if (current && hasValidToken()) { | |
| return current; | |
| } | |
| return refreshAccessToken(); | |
| }; | |
| type FetchMode = "json" | "blob" | "response"; | |
| const performFetch = async ( | |
| path: string, | |
| options: RequestInit = {}, | |
| allowRefresh = true | |
| ): Promise<Response> => { | |
| const headers = new Headers(options.headers || {}); | |
| const skipAuth = | |
| path.startsWith("/auth/login") || | |
| path.startsWith("/auth/signup") || | |
| path.startsWith("/auth/refresh") || | |
| path.startsWith("/setup"); | |
| const token = skipAuth ? null : getToken() || (allowRefresh ? await refreshAccessToken() : null); | |
| let targetUrl = `${API_URL}${path}`; | |
| if (token) { | |
| headers.set("Authorization", `Bearer ${token}`); | |
| headers.set("X-Presenter-Token", token); | |
| const separator = targetUrl.includes("?") ? "&" : "?"; | |
| targetUrl = `${targetUrl}${separator}_pt=${encodeURIComponent(token)}`; | |
| } | |
| if (!headers.has("Content-Type") && !(options.body instanceof FormData) && options.body !== undefined) { | |
| headers.set("Content-Type", "application/json"); | |
| } | |
| const response = await fetch(targetUrl, { | |
| ...options, | |
| headers, | |
| credentials: "include", | |
| }); | |
| if (response.status === 401 && allowRefresh && !skipAuth) { | |
| const refreshed = await refreshAccessToken(); | |
| if (refreshed) { | |
| return performFetch(path, options, false); | |
| } | |
| } | |
| if (!response.ok) { | |
| throw new ApiError(response.status, await parseErrorMessage(response)); | |
| } | |
| return response; | |
| }; | |
| export async function apiFetch<T>(path: string, options: RequestInit = {}, mode: FetchMode = "json"): Promise<T> { | |
| const response = await performFetch(path, options); | |
| if (mode === "response") { | |
| return response as T; | |
| } | |
| if (mode === "blob") { | |
| return (await response.blob()) as T; | |
| } | |
| if (response.status === 204) { | |
| return undefined as T; | |
| } | |
| return (await response.json()) as T; | |
| } | |
| export const authApi = { | |
| setupStatus: () => apiFetch<{ setup_required: boolean }>("/setup/status"), | |
| setup: (email: string, password: string) => | |
| apiFetch<AuthTokenResponse>("/setup", { | |
| method: "POST", | |
| body: JSON.stringify({ email, password }), | |
| }), | |
| login: (email: string, password: string) => | |
| apiFetch<AuthTokenResponse>("/auth/login", { | |
| method: "POST", | |
| body: JSON.stringify({ email, password }), | |
| }), | |
| signup: (email: string, password: string) => | |
| apiFetch<AuthTokenResponse>("/auth/signup", { | |
| method: "POST", | |
| body: JSON.stringify({ email, password }), | |
| }), | |
| logout: () => { | |
| const storedRefresh = getRefreshToken(); | |
| const headers: Record<string, string> = {}; | |
| let body: string | undefined; | |
| if (storedRefresh) { | |
| headers["X-Refresh-Token"] = storedRefresh; | |
| body = JSON.stringify({ refresh_token: storedRefresh }); | |
| } | |
| return apiFetch<{ status: string }>("/auth/logout", { | |
| method: "POST", | |
| headers, | |
| body, | |
| }); | |
| }, | |
| me: () => apiFetch<{ id: number; email: string; is_admin: boolean }>("/auth/me"), | |
| }; | |