/** * Same-origin API client and defensive wire-format normalizers. * * The backend is authoritative and may evolve response wrappers independently * from a deployed frontend. Normalizers accept those compatible wrappers while * producing strict UI models. Mutations are refused while offline and are never * queued for background replay, which avoids applying an old user's action * after logout or tenant switching. */ import type { AuthUser, ComponentSummary, CookieCloudSource, GiftEffectSettings, GiftCatalogItem, GiftMenuItem, GiftMenuSettings, Invitation, OverlaySettings, Session, SongRequestItem, SongRequestPage, SongRequestSettings, TotpEnrollment, } from './types' import { normalizeThemeId } from './themes' import { normalizeGiftEffectThemeId } from './giftThemes' import { normalizeGiftMenuThemeId } from './giftMenuThemes' import { defaultGiftEffectSettings, defaultGiftMenuSettings, defaultOverlaySettings, defaultSongRequestSettings, } from './types' import { currentLanguage, hasTranslation, translate } from './i18n' export class ApiError extends Error { readonly status: number readonly code?: string readonly messageKey?: string readonly fieldErrors?: Record constructor( status: number, message: string, code?: string, messageKey?: string, fieldErrors?: Record, ) { super(message) this.name = 'ApiError' this.status = status this.code = code this.messageKey = messageKey this.fieldErrors = fieldErrors } } async function parseResponse(response: Response): Promise { if (response.status === 204) return undefined const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) return response.json() const text = await response.text() return text ? { message: text } : undefined } export async function api(path: string, init: RequestInit = {}): Promise { const method = (init.method ?? 'GET').toUpperCase() if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) { throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.offline') } const headers = new Headers(init.headers) if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json') headers.set('accept', 'application/json') headers.set('accept-language', currentLanguage()) const response = await fetch(path, { ...init, headers, credentials: 'same-origin', }) const payload = await parseResponse(response) if (!response.ok) { if (response.status === 401 && !path.startsWith('/api/v1/auth/')) { window.dispatchEvent(new Event('lxc:session-expired')) } const error = payload && typeof payload === 'object' ? (payload as Record) : {} const code = typeof error.code === 'string' ? error.code : undefined const messageKey = typeof error.messageKey === 'string' ? error.messageKey : typeof error.message_key === 'string' ? error.message_key : code ? `api.error.${code}` : undefined const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined throw new ApiError( response.status, localized ?? String( error.message ?? error.error ?? translate('api.http_error', { status: response.status }), ), code, messageKey, error.fieldErrors && typeof error.fieldErrors === 'object' ? (error.fieldErrors as Record) : undefined, ) } return payload as T } export function json(method: string, body?: unknown): RequestInit { return { method, body: body === undefined ? undefined : JSON.stringify(body), } } function object(value: unknown): Record { return value != null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } export function normalizeSession(value: unknown): Session { const root = object(value) const candidate = root.user ?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null) const raw = object(candidate) const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string' const user: AuthUser | null = hasUser ? { id: String(raw.id ?? ''), username: String(raw.username ?? ''), roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined, displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined, role: typeof raw.role === 'string' ? raw.role : 'user', totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined, } : null return { user, setupRequired: root.setupRequired === true } } export function normalizeEnrollment(value: unknown): TotpEnrollment { const root = object(value) const totp = object(root.totp ?? root.enrollment) const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id return { enrollmentToken: String(enrollmentToken ?? ''), qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined, qrDataUrl: typeof totp.qrDataUrl === 'string' ? totp.qrDataUrl : typeof totp.qrCodeDataUrl === 'string' ? totp.qrCodeDataUrl : typeof root.qrDataUrl === 'string' ? root.qrDataUrl : undefined, otpauthUri: typeof totp.otpauthUri === 'string' ? totp.otpauthUri : typeof root.otpauthUri === 'string' ? root.otpauthUri : undefined, manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''), expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined, } } export function normalizeRecoveryCodes(value: unknown): string[] { const root = object(value) const codes = root.recoveryCodes ?? object(root.recovery).codes return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : [] } export function normalizeComponents(value: unknown): ComponentSummary[] { const root = object(value) const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : [] return list .map(entry => { const item = object(entry) return { id: String(item.id ?? ''), publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''), kind: String(item.kind ?? item.type ?? 'danmaku'), name: String(item.name ?? translate('components.danmaku_type')), enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, settings: item.settings as OverlaySettings | undefined, updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined, } }) .filter(item => item.id) } export function normalizeSettings(value: unknown): OverlaySettings { const root = object(value) const settings = object(root.settings ?? value) return { ...defaultOverlaySettings, ...(settings as Partial), themeId: normalizeThemeId(settings.themeId), } } export function normalizeSongRequestSettings(value: unknown): SongRequestSettings { const root = object(value) const settings = object(root.settings ?? value) return { ...defaultSongRequestSettings, ...(settings as Partial), themeId: normalizeThemeId(settings.themeId), } } export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings { const root = object(value) const settings = object(root.settings ?? value) const normal = object(settings.normal) const high = object(settings.high) const featured = object(settings.featured) return { ...defaultGiftEffectSettings, ...(settings as Partial), themeId: normalizeGiftEffectThemeId(settings.themeId), normal: { ...defaultGiftEffectSettings.normal, ...normal }, high: { ...defaultGiftEffectSettings.high, ...high }, featured: { ...defaultGiftEffectSettings.featured, ...featured }, } } function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined { const item = object(value) const trigger = object(item.trigger) if (typeof item.id !== 'string' || typeof item.description !== 'string') return undefined if (trigger.kind === 'gift') { const giftId = Number(trigger.giftId) if (!Number.isSafeInteger(giftId) || giftId <= 0 || typeof trigger.giftName !== 'string') return undefined return { id: item.id, description: item.description, trigger: { kind: 'gift', giftId, giftName: trigger.giftName, unitPrice: Number(trigger.unitPrice ?? 0), imageUrl: typeof trigger.imageUrl === 'string' ? trigger.imageUrl : undefined, }, } } if ( trigger.kind === 'guard' && ['captain', 'admiral', 'governor'].includes(String(trigger.level)) ) { return { id: item.id, description: item.description, trigger: { kind: 'guard', level: trigger.level as 'captain' | 'admiral' | 'governor', }, } } const amount = Number(trigger.amount) if (trigger.kind === 'battery' && Number.isSafeInteger(amount) && amount > 0) { return { id: item.id, description: item.description, trigger: { kind: 'battery', amount }, } } return undefined } export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings { const root = object(value) const settings = object(root.settings ?? value) const items = Array.isArray(settings.items) ? settings.items.map(normalizeGiftMenuItem).filter(item => item !== undefined) : [] return { ...defaultGiftMenuSettings, ...(settings as Partial), themeId: normalizeGiftMenuThemeId(settings.themeId), items, } } export function normalizeGiftCatalog(value: unknown): GiftCatalogItem[] { const root = object(value) if (!Array.isArray(root.gifts)) return [] return root.gifts.flatMap(candidate => { const gift = object(candidate) const id = Number(gift.id) if (!Number.isSafeInteger(id) || id <= 0 || typeof gift.name !== 'string') return [] return [ { id, name: gift.name, coinType: String(gift.coinType ?? 'gold'), batteryValue: Number(gift.batteryValue ?? Number(gift.unitPrice ?? 0) / 100), unitPrice: Number(gift.unitPrice ?? 0), imageUrl: typeof gift.imageUrl === 'string' ? gift.imageUrl : typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined, animationUrl: typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined, }, ] }) } export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined { const item = object(value) const requester = object(item.requester) if (typeof item.id !== 'string' || typeof item.title !== 'string') return undefined const status = String(item.status) if (!['current', 'queued', 'completed', 'cancelled'].includes(status)) return undefined return { id: item.id, title: item.title, requester: { uid: String(requester.uid ?? ''), name: String(requester.name ?? translate('common.viewer')), }, status: status as SongRequestItem['status'], queuePosition: Number(item.queuePosition ?? 0), requestedAt: String(item.requestedAt ?? ''), startedAt: typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined, finishedAt: typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined, averageScore: typeof item.averageScore === 'number' || item.averageScore === null ? item.averageScore : undefined, ratingCount: Number(item.ratingCount ?? 0), } } export function normalizeSongRequestPage(value: unknown): SongRequestPage { const root = object(value) const current = normalizeSongRequestItem(root.current) const summary = object(root.summary) return { revision: Number(root.revision ?? 0), current: current ?? null, items: (Array.isArray(root.items) ? root.items : []) .map(normalizeSongRequestItem) .filter((item): item is SongRequestItem => Boolean(item)), nextCursor: typeof root.nextCursor === 'number' ? root.nextCursor : null, summary: { activeCount: Number(summary.activeCount ?? 0), queuedCount: Number(summary.queuedCount ?? 0), completedCount: Number(summary.completedCount ?? 0), cancelledCount: Number(summary.cancelledCount ?? 0), ratingCount: Number(summary.ratingCount ?? 0), }, } } export function normalizeSource(value: unknown): CookieCloudSource { const root = object(value) const source = object(root.source ?? root) const cookieCloud = object( source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud, ) const status = object(source.status) return { roomId: String(source.roomId ?? root.roomId ?? ''), cookieCloud: { host: String(cookieCloud.host ?? ''), key: '', keyConfigured: cookieCloud.keyConfigured === true || (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0), passwordConfigured: cookieCloud.passwordConfigured === true || cookieCloud.configured === true || (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0), }, connected: typeof source.connected === 'boolean' ? source.connected : typeof status.connected === 'boolean' ? status.connected : undefined, detail: typeof source.detail === 'string' ? source.detail : typeof status.detail === 'string' ? status.detail : undefined, updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined, } } export function normalizeInvitations(value: unknown): Invitation[] { const root = object(value) const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : [] return list .map(entry => { const item = object(entry) return { id: String(item.id ?? ''), code: typeof item.code === 'string' ? item.code : undefined, codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined, roomId: String(item.roomId ?? ''), createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined, createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined, expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined, consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined, revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined, } }) .filter(item => item.id) } export function errorMessage(error: unknown, fallback = translate('common.failed')): string { if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey)) return translate(error.messageKey) return error instanceof Error && error.message ? error.message : fallback } export async function copyToClipboard(text: string): Promise { if (window.isSecureContext && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text) return true } catch { // Fall through to the compatibility path used by OBS' embedded browser. } } const input = document.createElement('textarea') input.value = text input.readOnly = true input.style.position = 'fixed' input.style.left = '-9999px' input.style.opacity = '0' document.body.appendChild(input) input.focus() input.select() let copied = false try { copied = document.execCommand('copy') } finally { input.remove() } return copied }