This commit is contained in:
Iliyan Angelov
2025-11-28 02:40:05 +02:00
parent 627959f52b
commit 312f85530c
246 changed files with 23535 additions and 3428 deletions

View File

@@ -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;