updates
This commit is contained in:
@@ -7,12 +7,44 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
|
||||
? normalized
|
||||
: normalized + '/api';
|
||||
|
||||
// Validate HTTPS in production
|
||||
if (import.meta.env.MODE === 'production' && !normalized.startsWith('https://')) {
|
||||
console.error(
|
||||
'⚠️ SECURITY WARNING: API URL is not using HTTPS in production!',
|
||||
'This exposes sensitive data to interception.',
|
||||
'Please configure VITE_API_URL to use HTTPS.'
|
||||
);
|
||||
|
||||
// Only show warning, don't block - some deployments may use reverse proxy
|
||||
// But log the security concern
|
||||
if (typeof window !== 'undefined') {
|
||||
console.warn('API calls will be made over HTTP. Consider using HTTPS.');
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
// Note: 503 is excluded because it's used for "service unavailable" (like disabled features)
|
||||
// and should not be retried - it's an intentional state, not a transient error
|
||||
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 504];
|
||||
|
||||
// Helper function to get CSRF token from cookies
|
||||
const getCsrfToken = (): string | null => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// CSRF token cookie name (matches backend XSRF-TOKEN)
|
||||
const cookies = document.cookie.split(';');
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'XSRF-TOKEN') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
@@ -22,6 +54,9 @@ const apiClient = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Map to store AbortControllers for cancelable requests
|
||||
const cancelTokenMap = new Map<string, AbortController>();
|
||||
|
||||
const retryRequest = async (
|
||||
error: AxiosError,
|
||||
retryCount: number = 0
|
||||
@@ -71,13 +106,51 @@ apiClient.interceptors.request.use(
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, DELETE, PATCH)
|
||||
// Skip for GET, HEAD, OPTIONS (safe methods)
|
||||
const method = (config.method || 'GET').toUpperCase();
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
// Skip CSRF token for auth endpoints (they're exempt)
|
||||
const path = config.url || '';
|
||||
const isAuthEndpoint = path.startsWith('/auth/') || path.startsWith('/api/auth/');
|
||||
|
||||
if (!isAuthEndpoint) {
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
config.headers['X-XSRF-TOKEN'] = csrfToken;
|
||||
} else {
|
||||
// CSRF token not found - this will be handled in error interceptor
|
||||
// The backend will return 403 and we'll retry after fetching the token
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('CSRF token not found in cookies. Request may fail and retry after fetching token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID ? crypto.randomUUID() :
|
||||
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
config.headers['X-Request-ID'] = requestId;
|
||||
|
||||
|
||||
(config as any).metadata = { startTime: new Date() };
|
||||
// Create AbortController for request cancellation
|
||||
// Skip if signal is already provided
|
||||
if (!config.signal) {
|
||||
const abortController = new AbortController();
|
||||
config.signal = abortController.signal;
|
||||
|
||||
// Store controller for potential cancellation
|
||||
const cancelKey = `${config.method || 'GET'}_${config.url}_${requestId}`;
|
||||
cancelTokenMap.set(cancelKey, abortController);
|
||||
|
||||
// Clean up after request completes (set timeout to avoid memory leaks)
|
||||
setTimeout(() => {
|
||||
cancelTokenMap.delete(cancelKey);
|
||||
}, 60000); // Clean up after 60 seconds
|
||||
}
|
||||
|
||||
|
||||
(config as any).metadata = { startTime: new Date(), requestId };
|
||||
|
||||
return config;
|
||||
},
|
||||
@@ -90,6 +163,13 @@ apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
|
||||
const config = response.config as InternalAxiosRequestConfig & { metadata?: any };
|
||||
|
||||
// Clean up abort controller on successful response
|
||||
if (config.metadata?.requestId && config.url) {
|
||||
const cancelKey = `${config.method || 'GET'}_${config.url}_${config.metadata.requestId}`;
|
||||
cancelTokenMap.delete(cancelKey);
|
||||
}
|
||||
|
||||
if (config.metadata?.startTime) {
|
||||
const duration = new Date().getTime() - config.metadata.startTime.getTime();
|
||||
if (duration > 1000) {
|
||||
@@ -107,6 +187,21 @@ apiClient.interceptors.response.use(
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Clean up abort controller on error
|
||||
if (originalRequest && (originalRequest as any).metadata?.requestId && originalRequest.url) {
|
||||
const cancelKey = `${originalRequest.method || 'GET'}_${originalRequest.url}_${(originalRequest as any).metadata.requestId}`;
|
||||
cancelTokenMap.delete(cancelKey);
|
||||
}
|
||||
|
||||
// Don't process cancellation errors
|
||||
if (error.code === 'ERR_CANCELED' || error.message === 'canceled') {
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: 'Request was cancelled',
|
||||
isCancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!error.response) {
|
||||
@@ -163,6 +258,43 @@ apiClient.interceptors.response.use(
|
||||
|
||||
if (status === 403) {
|
||||
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
|
||||
|
||||
// Handle CSRF token missing/invalid errors - retry after getting token from error response
|
||||
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
|
||||
// The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if we now have the CSRF token
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken && originalRequest.headers) {
|
||||
// Retry the original request with the CSRF token
|
||||
originalRequest._retry = true;
|
||||
originalRequest.headers['X-XSRF-TOKEN'] = csrfToken;
|
||||
return apiClient.request(originalRequest);
|
||||
} else {
|
||||
// If still no token, try fetching it via GET request
|
||||
try {
|
||||
await apiClient.get('/health', {
|
||||
headers: { ...originalRequest.headers },
|
||||
timeout: 5000
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const newToken = getCsrfToken();
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest._retry = true;
|
||||
originalRequest.headers['X-XSRF-TOKEN'] = newToken;
|
||||
return apiClient.request(originalRequest);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors from token fetch attempts
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to fetch CSRF token:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
@@ -236,4 +368,31 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel all pending requests (useful for cleanup on unmount)
|
||||
*/
|
||||
export const cancelAllPendingRequests = (): void => {
|
||||
cancelTokenMap.forEach((controller) => {
|
||||
controller.abort();
|
||||
});
|
||||
cancelTokenMap.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel requests matching a pattern (useful for canceling specific endpoint requests)
|
||||
*/
|
||||
export const cancelRequestsByPattern = (pattern: string | RegExp): void => {
|
||||
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
|
||||
const keysToCancel: string[] = [];
|
||||
|
||||
cancelTokenMap.forEach((controller, key) => {
|
||||
if (regex.test(key)) {
|
||||
controller.abort();
|
||||
keysToCancel.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
keysToCancel.forEach((key) => cancelTokenMap.delete(key));
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
|
||||
Reference in New Issue
Block a user