/** * Base API client for backend communication * Handles authentication, error handling, and request/response transformation */ import { z } from 'zod'; // @ts-expect-error - Dynamic import for environment-specific config - Vite env types const isProduction = import.meta.env.PROD; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (isProduction ? 'https://api.bugulma.city' : ''); // Security: Ensure HTTPS in production const isSecureUrl = API_BASE_URL.startsWith('https://') || API_BASE_URL.startsWith('localhost') || API_BASE_URL.startsWith('/') || API_BASE_URL === '/api'; // Type for API error responses (derived from Zod schema) export type ApiErrorResponse = z.infer; if (isProduction && !isSecureUrl) { console.error('🚨 SECURITY WARNING: API_BASE_URL must use HTTPS in production!'); // In production, throw error to prevent insecure connections if (typeof window === 'undefined') { throw new Error('API_BASE_URL must use HTTPS in production environment'); } } // API Error Response Schema export const ApiErrorResponseSchema = z .object({ error: z.string(), message: z.string().optional(), details: z.record(z.unknown()).optional(), }) .partial(); // API Success Response Schemas export const UserSchema = z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), }); export const AuthResponseSchema = z.object({ token: z.string(), user: UserSchema, }); // Helper function for safe error response parsing function parseApiError(response: Response): Promise { return response .json() .then((data) => { // Try to validate with Zod schema const result = ApiErrorResponseSchema.safeParse(data); if (result.success) { return result.data; } // If validation fails, create a fallback error with the raw data return { error: typeof data?.error === 'string' ? data.error : 'Request failed', message: typeof data?.message === 'string' ? data.message : undefined, details: data, }; }) .catch(() => { // If JSON parsing fails, create a generic error return { error: `HTTP ${response.status}: ${response.statusText}`, }; }); } // Helper function for safe success response parsing function parseApiResponse(response: Response, schema: z.ZodSchema): Promise { return response.json().then((data) => { const result = schema.safeParse(data); if (result.success) { return result.data; } throw new Error(`Invalid API response format: ${result.error.message}`); }); } /** * Schema-validated API GET request */ export async function apiGetValidated(endpoint: string, schema: z.ZodSchema): Promise { const data = await apiGet(endpoint); const result = schema.safeParse(data); if (result.success) { return result.data; } throw new ApiError(`Invalid API response format: ${result.error.message}`, 0); } /** * Schema-validated API POST request */ export async function apiPostValidated( endpoint: string, data?: z.infer, responseSchema?: z.ZodSchema ): Promise { const responseData = await apiPost(endpoint, data); if (responseSchema) { const result = responseSchema.safeParse(responseData); if (result.success) { return result.data; } throw new ApiError(`Invalid API response format: ${result.error.message}`, 0); } return responseData as T; } // API Error class with proper typing export class ApiError extends Error { constructor( message: string, public status: number, public data?: z.infer ) { super(message); this.name = 'ApiError'; } } // Request data validation schema export const RequestDataSchema = z.union([ z.record(z.unknown()), z.instanceof(FormData), z.undefined(), ]); // Generic API response wrapper export const ApiResponseSchema = (dataSchema: T) => z.object({ data: dataSchema, success: z.boolean().default(true), message: z.string().optional(), timestamp: z.string().datetime().optional(), }); /** * Get the authentication token from localStorage */ function getAuthToken(): string | null { return localStorage.getItem('auth_token'); } /** * Make an authenticated API request */ async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { const token = getAuthToken(); // Automatically add API version prefix to all endpoints const apiEndpoint = endpoint.startsWith('/api/v1/') ? endpoint : `/api/v1${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`; const url = `${API_BASE_URL}${apiEndpoint}`; const headers: HeadersInit = { 'Content-Type': 'application/json', ...options.headers, }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(url, { ...options, headers, }); if (!response.ok) { let errorData: z.infer | string; try { // Read response as text first to avoid consuming the stream const responseText = await response.text(); // Try to parse as JSON const jsonData = JSON.parse(responseText); errorData = ApiErrorResponseSchema.parse(jsonData); } catch { // If JSON parsing fails, use the raw text try { errorData = await response.text(); } catch { errorData = `HTTP ${response.status}: ${response.statusText}`; } } // SECURITY: Prevent information disclosure in production const isDev = import.meta.env.DEV; let errorMessage: string; if (response.status === 401) { errorMessage = 'Authentication failed. Please log in again.'; } else if (response.status === 403) { errorMessage = 'You do not have permission to perform this action.'; } else if (response.status === 404) { errorMessage = 'The requested resource was not found.'; } else if (response.status >= 500) { errorMessage = 'A server error occurred. Please try again later.'; } else if (typeof errorData === 'string') { errorMessage = isDev ? errorData : 'An error occurred. Please try again.'; } else { errorMessage = isDev ? errorData.error || `HTTP ${response.status}: ${response.statusText}` : 'An error occurred. Please try again.'; } throw new ApiError( errorMessage, response.status, isDev ? (typeof errorData === 'object' ? errorData : undefined) : undefined ); } // Handle 204 No Content if (response.status === 204) { return undefined as T; } return response.json(); } /** * GET request */ export async function apiGet(endpoint: string): Promise { return apiRequest(endpoint, { method: 'GET' }); } /** * POST request */ export async function apiPost( endpoint: string, data?: z.infer ): Promise { // Handle FormData (for file uploads) if (data instanceof FormData) { const token = getAuthToken(); const url = `${API_BASE_URL}${endpoint}`; const headers: HeadersInit = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } // Don't set Content-Type for FormData - browser will set it with boundary const response = await fetch(url, { method: 'POST', headers, body: data, }); if (!response.ok) { let errorData: unknown; try { errorData = await response.json(); } catch { errorData = await response.text(); } throw new ApiError( (errorData as { error?: string })?.error || `HTTP ${response.status}: ${response.statusText}`, response.status, errorData ); } if (response.status === 204) { return undefined as T; } return response.json(); } // Regular JSON request return apiRequest(endpoint, { method: 'POST', body: data ? JSON.stringify(data) : undefined, }); } /** * DELETE request */ export async function apiDelete(endpoint: string): Promise { return apiRequest(endpoint, { method: 'DELETE' }); } /** * Login request (no auth token required) */ export async function login( email: string, password: string ): Promise> { // Use apiRequest for consistent API versioning, but without auth token const url = `${API_BASE_URL}/api/v1/auth/login`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), }); if (!response.ok) { const errorData = await parseApiError(response); throw new ApiError(errorData.error || 'Login failed', response.status, errorData); } return parseApiResponse(response, AuthResponseSchema); } /** * Signup request (no auth token required) */ export async function signup( email: string, password: string, name: string, role: 'user' | 'admin' | 'content_manager' | 'viewer' ): Promise> { const url = `${API_BASE_URL}/api/v1/auth/register`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password, name, role }), }); if (!response.ok) { const errorData = await parseApiError(response); throw new ApiError(errorData.error || 'Signup failed', response.status, errorData); } return parseApiResponse(response, AuthResponseSchema); } /** * Sanitize input to prevent XSS attacks */ export function sanitizeInput(input: string): string { return input .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'); } /** * Validate email format */ export function isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email) && email.length <= 254; } /** * Validate input length and content */ export function validateInput( input: string, options: { minLength?: number; maxLength?: number; allowSpecialChars?: boolean; allowNumbers?: boolean; } = {} ): { isValid: boolean; error?: string } { const { minLength = 1, maxLength = 1000, allowSpecialChars = true, allowNumbers = true, } = options; if (!input || typeof input !== 'string') { return { isValid: false, error: 'Input must be a non-empty string' }; } if (input.length < minLength) { return { isValid: false, error: `Input must be at least ${minLength} characters` }; } if (input.length > maxLength) { return { isValid: false, error: `Input must be no more than ${maxLength} characters` }; } if (!allowSpecialChars && /[^a-zA-Z\s]/.test(input)) { return { isValid: false, error: 'Special characters are not allowed' }; } if (!allowNumbers && /\d/.test(input)) { return { isValid: false, error: 'Numbers are not allowed' }; } return { isValid: true }; } /** * Rate limiting for API calls to prevent abuse */ class RateLimiter { private calls = new Map(); isAllowed(key: string, maxCalls: number, windowMs: number): boolean { const now = Date.now(); const calls = this.calls.get(key) || []; // Remove old calls outside the window const validCalls = calls.filter((call) => now - call < windowMs); this.calls.set(key, validCalls); if (validCalls.length >= maxCalls) { return false; } validCalls.push(now); return true; } reset(key: string): void { this.calls.delete(key); } } const rateLimiter = new RateLimiter(); /** * Rate-limited API request wrapper */ async function rateLimitedRequest( endpoint: string, options: RequestInit = {}, maxCalls = 10, windowMs = 60000 // 1 minute ): Promise { const rateLimitKey = `${options.method || 'GET'}:${endpoint}`; if (!rateLimiter.isAllowed(rateLimitKey, maxCalls, windowMs)) { throw new ApiError('Too many requests. Please wait before trying again.', 429); } return apiRequest(endpoint, options); } /** * API client object with convenience methods */ export const apiClient = { get: (endpoint: string) => rateLimitedRequest(endpoint, { method: 'GET' }), post: apiPost, delete: apiDelete, login, signup, sanitizeInput, isValidEmail, validateInput, };