CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
import type {
|
||
AsEntriesResponse,
|
||
CdnSourcesResponse,
|
||
DomainEntriesResponse,
|
||
IpRangeEntriesResponse,
|
||
JobRow,
|
||
ModuleType,
|
||
RevisionPrefix,
|
||
RevisionPrefixesResponse,
|
||
} from '@/types/api'
|
||
|
||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||
|
||
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
|
||
export const DEV_API_TOKEN = 'dev'
|
||
|
||
export type Problem = {
|
||
type?: string
|
||
title?: string
|
||
status?: number
|
||
detail?: string
|
||
}
|
||
|
||
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
|
||
export function normalizeApiToken(raw: string): string {
|
||
let t = raw.trim()
|
||
if (/^bearer\s+/i.test(t)) {
|
||
t = t.replace(/^bearer\s+/i, '').trim()
|
||
}
|
||
return t
|
||
}
|
||
|
||
function getToken(): string | null {
|
||
if (typeof window === 'undefined') return null
|
||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||
if (!raw) return null
|
||
const normalized = normalizeApiToken(raw)
|
||
return normalized || null
|
||
}
|
||
|
||
export function setToken(token: string | null): void {
|
||
if (typeof window === 'undefined') return
|
||
if (!token) {
|
||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||
return
|
||
}
|
||
const normalized = normalizeApiToken(token)
|
||
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
|
||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||
}
|
||
|
||
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
|
||
const h = new Headers(init?.headers)
|
||
if (!h.has('Accept')) h.set('Accept', 'application/json')
|
||
const t = getToken()
|
||
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
|
||
if (extraHeaders) {
|
||
for (const [k, v] of Object.entries(extraHeaders)) {
|
||
if (!h.has(k)) h.set(k, v)
|
||
}
|
||
}
|
||
return h
|
||
}
|
||
|
||
/**
|
||
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
|
||
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
|
||
*/
|
||
function newIdempotencyKey(): string {
|
||
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
|
||
if (c?.randomUUID) return c.randomUUID()
|
||
if (c?.getRandomValues) {
|
||
const buf = new Uint8Array(16)
|
||
c.getRandomValues(buf)
|
||
buf[6] = (buf[6]! & 0x0f) | 0x40
|
||
buf[8] = (buf[8]! & 0x3f) | 0x80
|
||
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||
}
|
||
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
message: string,
|
||
public readonly problem?: Problem,
|
||
) {
|
||
super(message)
|
||
}
|
||
}
|
||
|
||
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
|
||
return fetch(path, { ...init, headers: mergeHeaders(init) })
|
||
}
|
||
|
||
/** GET / DELETE без тела */
|
||
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await apiFetch(path, init)
|
||
return parseResponse<T>(res)
|
||
}
|
||
|
||
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
|
||
export async function apiMutate<T = void>(
|
||
path: string,
|
||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||
body?: unknown,
|
||
opts?: { idempotent?: boolean },
|
||
): Promise<T> {
|
||
const headers: Record<string, string> = {}
|
||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||
if (opts?.idempotent !== false) {
|
||
headers['Idempotency-Key'] = newIdempotencyKey()
|
||
}
|
||
const res = await fetch(path, {
|
||
method,
|
||
headers: mergeHeaders({ headers }, headers),
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
})
|
||
return parseResponse<T>(res)
|
||
}
|
||
|
||
async function parseResponse<T>(res: Response): Promise<T> {
|
||
if (res.status === 204 || res.status === 205) return undefined as T
|
||
const text = await res.text()
|
||
if (!res.ok) {
|
||
let problem: Problem | undefined
|
||
let detail = `HTTP ${res.status}`
|
||
try {
|
||
problem = JSON.parse(text) as Problem
|
||
detail = problem.detail ?? problem.title ?? detail
|
||
} catch {
|
||
if (text) detail = text
|
||
}
|
||
throw new ApiError(res.status, detail, problem)
|
||
}
|
||
if (!text) return undefined as T
|
||
return JSON.parse(text) as T
|
||
}
|
||
|
||
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
|
||
|
||
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
|
||
export async function waitForJob(
|
||
jobId: string,
|
||
opts?: { pollMs?: number; timeoutMs?: number },
|
||
): Promise<JobRow> {
|
||
const pollMs = opts?.pollMs ?? 400
|
||
const timeoutMs = opts?.timeoutMs ?? 120000
|
||
const deadline = Date.now() + timeoutMs
|
||
while (Date.now() < deadline) {
|
||
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
|
||
if (terminalJobStatuses.has(j.status)) return j
|
||
await new Promise((r) => setTimeout(r, pollMs))
|
||
}
|
||
throw new Error(`Таймаут ожидания задачи ${jobId}`)
|
||
}
|
||
|
||
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
|
||
const items: T[] = []
|
||
let cursor: string | null = null
|
||
while (true) {
|
||
const [basePath, rawQuery = ''] = path.split('?')
|
||
const query = new URLSearchParams(rawQuery)
|
||
if (!query.has('limit')) query.set('limit', String(limit))
|
||
if (cursor) query.set('cursor', cursor)
|
||
else query.delete('cursor')
|
||
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
|
||
`${basePath}?${query.toString()}`,
|
||
)
|
||
items.push(...(page.items ?? []))
|
||
if (!page.has_more || !page.next_cursor) break
|
||
cursor = page.next_cursor
|
||
}
|
||
return items
|
||
}
|
||
|
||
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
|
||
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
|
||
return items
|
||
}
|
||
|
||
export async function fetchRevisionPrefixesResponse(
|
||
revisionId: string,
|
||
cursor?: string | null,
|
||
limit = 500,
|
||
): Promise<RevisionPrefixesResponse> {
|
||
const query = new URLSearchParams({ limit: String(limit) })
|
||
if (cursor) query.set('cursor', cursor)
|
||
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
|
||
}
|
||
|
||
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
|
||
if (moduleType === 'DOMAINS') {
|
||
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/domain-entries`,
|
||
)
|
||
return { domains: entries }
|
||
}
|
||
if (moduleType === 'AS_PREFIXES') {
|
||
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/as-entries`,
|
||
)
|
||
return { asns: entries }
|
||
}
|
||
if (moduleType === 'CDN_CIDRS') {
|
||
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/cdn-sources`,
|
||
)
|
||
return { cdnSources: entries }
|
||
}
|
||
if (moduleType === 'IP_RANGES') {
|
||
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
|
||
`/v1/modules/${moduleId}/ip-range-entries`,
|
||
)
|
||
return { ipRanges: entries }
|
||
}
|
||
return {}
|
||
}
|