Files
donetick/src/utils/ApiClient.js
2026-08-08 13:40:21 -04:00

376 lines
11 KiB
JavaScript

import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import {
recordApiFailure,
recordServerVersionFromResponse,
} from '../service/DiagnosticsSession'
import { logout, RefreshToken } from './Fetcher'
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
import { offlineDB } from './OfflineDB'
import {
clearAllTokens,
isRefreshTokenExpired,
saveTokens,
} from './TokenStorage'
const OAUTH_EXCHANGE_IN_PROGRESS = 'OAuth exchange in progress'
class ApiClient {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.isRefreshing = false
this.failedQueue = []
this.lastRefreshTime = 0
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
}
async init(force = false) {
if (this.initPromise && !force) {
return this.initPromise
}
if (this.initialized && !force) {
return Promise.resolve()
}
this.initPromise = this._doInit().finally(() => {
this.initPromise = null
})
return this.initPromise
}
async _doInit() {
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
async refreshToken() {
// No session exists yet while an OAuth code exchange is in flight, so there
// is nothing to refresh. Callers must treat this as a non-fatal failure
// (see request()) rather than an expired session.
if (isOAuthExchangeInProgress()) {
return { success: false, error: OAUTH_EXCHANGE_IN_PROGRESS }
}
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
console.log('Refresh token expired, forcing logout')
await this.handleLogout()
return { success: false, error: 'Refresh token expired' }
}
if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' }
}
// Check cooldown
const now = Date.now()
if (now - this.lastRefreshTime < this.refreshCooldown) {
return { success: false, error: 'Refresh cooldown active' }
}
this.isRefreshing = true
try {
const refreshReq = await RefreshToken()
if (refreshReq.ok) {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Save all tokens including rotated refresh token
await saveTokens({
accessToken: newToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
// Update last refresh time
this.lastRefreshTime = Date.now()
return { success: true, token: newToken }
} else {
return { success: false, error: 'Refresh failed' }
}
} catch (error) {
return { success: false, error: error.message }
} finally {
this.isRefreshing = false
}
}
getToken() {
return localStorage.getItem('token')
}
getHeaders(customHeaders = {}) {
const headers = {
...customHeaders,
}
const token = this.getToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
const impersonateUserId = localStorage.getItem('impersonatedUserId')
if (impersonateUserId) {
headers['X-Impersonate-User-ID'] = impersonateUserId
}
return headers
}
// Process queued requests after refresh attempt
processQueue(error, token = null) {
this.failedQueue.forEach(({ reject, resolve }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
this.failedQueue = []
}
// Helper to avoid repeating cleanup code
async handleLogout() {
// Backstop for every forced-logout path: never tear down the session while
// an OAuth exchange is running, or we clear the tokens it just saved and
// reload the page out from under it.
if (isOAuthExchangeInProgress()) {
console.log('Skipping forced logout: OAuth exchange in progress')
return
}
// An expired session on an invite link would otherwise drop the code on the
// way to /login. Stash it first so sign-in returns to the join.
try {
const { pathname, search } = window.location
if (pathname === '/circle/join') {
const code = new URLSearchParams(search).get('code')
if (code) {
const { setPendingInvite } = await import('./PendingInvite')
setPendingInvite(code)
}
}
} catch (e) {
console.error('Error preserving pending invite on logout', e)
}
await clearAllTokens()
try {
await offlineDB.clearAll()
} catch (e) {
console.error('Error clearing offline data on logout', e)
}
try {
// Dynamic import sidesteps the ApiClient <-> ImageCache module cycle
const { clearImageCache } = await import('./ImageCache')
await clearImageCache()
} catch (e) {
console.error('Error clearing image cache on logout', e)
}
try {
// Dynamic import sidesteps the ApiClient <-> WidgetService module cycle
const { clearWidgetData } = await import('../service/WidgetService')
await clearWidgetData()
} catch (e) {
console.error('Error clearing widget data on logout', e)
}
try {
await logout()
} catch (e) {
console.error('Error during logout', e)
}
if (window.location.pathname !== '/login') window.location.href = '/login'
// fire and forget
}
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
// Abort after 10s so a dead/unreachable server doesn't hang the UI
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10_000)
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
signal: options.signal ?? controller.signal,
}
try {
// 1. Initial Request
let response = await fetch(url, config)
clearTimeout(timeoutId)
// Passive diagnostics: learn the server build from whatever it already
// answers, and keep the last few refusals for crash reports.
recordServerVersionFromResponse(response)
if (!response.ok) {
recordApiFailure({
endpoint,
method: config.method,
status: response.status,
})
}
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
// Always queue this request first
const queuedPromise = new Promise((resolve, reject) => {
this.failedQueue.push({
resolve: async token => {
if (!token) {
reject(new Error('Token refresh failed'))
return
}
try {
const newHeaders = this.getHeaders(options?.headers)
const retryConfig = {
...config,
headers: newHeaders,
}
const retryResponse = await fetch(url, retryConfig)
resolve(retryResponse)
} catch (error) {
reject(error)
}
},
reject,
})
})
// If already refreshing, just return the queued promise
if (this.isRefreshing) {
console.log('Token refresh already in progress, queueing request')
return queuedPromise
}
// Attempt to refresh the token
const refreshResult = await this.refreshToken()
if (refreshResult.success) {
// Process queue with success - this will retry all queued requests
this.processQueue(null, refreshResult.token)
} else if (refreshResult.error === 'Refresh cooldown active') {
// We're in cooldown - token was just refreshed, retry with current token
console.log('Refresh cooldown - retrying with current token')
const currentToken = this.getToken()
if (currentToken) {
this.processQueue(null, currentToken)
} else {
this.processQueue(new Error('No token available'), null)
this.handleLogout()
return null
}
} else if (refreshResult.error === OAUTH_EXCHANGE_IN_PROGRESS) {
// Expected 401: the code exchange hasn't produced tokens yet. Fail
// just this request — logging out here would wipe storage and hard
// navigate to /login, aborting the exchange fetch mid-flight.
queuedPromise.catch(() => {}) // not returned below; keep it handled
this.processQueue(new Error(refreshResult.error), null)
return response
} else if (refreshResult.error === 'Already refreshing') {
// This shouldn't happen since we check isRefreshing above, but handle it anyway
console.log('Already refreshing - waiting for refresh to complete')
return queuedPromise
} else {
// Actual refresh failure - logout
this.processQueue(new Error(refreshResult.error), null)
this.handleLogout()
return null
}
// Return the queued promise for this request
return queuedPromise
}
return response
} catch (error) {
clearTimeout(timeoutId)
// fetch() threw = network-level failure or timeout — mark server
// unreachable. Caller-initiated aborts (component unmount, query
// cancellation) say nothing about server health, so skip those.
const externalAbort =
error?.name === 'AbortError' && options.signal?.aborted
if (!externalAbort) {
networkManager.setServerUnreachable()
recordApiFailure({ endpoint, method: config.method, status: 'network' })
}
console.error('Request failed', error)
throw error
}
}
async get(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'GET' })
}
async post(endpoint, data, options = {}) {
options.headers = options.headers || {}
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json'
}
return this.request(endpoint, {
...options,
method: 'POST',
body: data ? data : undefined,
})
}
async put(endpoint, data, options = {}) {
options.headers = options.headers || {}
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json'
}
return this.request(endpoint, {
...options,
method: 'PUT',
body: data ? data : undefined,
})
}
async delete(endpoint, options = {}) {
options.headers = options.headers || {}
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json'
}
return this.request(endpoint, { ...options, method: 'DELETE' })
}
async upload(endpoint, formData, options = {}) {
const headers = options.headers || {}
delete headers['Content-Type']
return this.request(endpoint, {
...options,
method: 'POST',
body: formData,
headers,
})
}
getAssetURL(path) {
return `${this.customServerURL}/assets/${path}`
}
}
export const apiClient = new ApiClient()