mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
- Remove nested git repository from bugulma/frontend/.git - Add all frontend files to main repository tracking - Convert from separate frontend/backend repos to unified monorepo - Preserve all frontend code and development history as tracked files - Eliminate nested repository complexity for simpler development workflow This creates a proper monorepo structure with frontend and backend coexisting in the same repository for easier development and deployment.
360 lines
9.3 KiB
TypeScript
360 lines
9.3 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
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 Error class with proper typing
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public status: number,
|
|
public data?: z.infer<typeof ApiErrorResponseSchema>
|
|
) {
|
|
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 = <T extends z.ZodTypeAny>(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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
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<typeof ApiErrorResponseSchema> | 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<T>(endpoint: string): Promise<T> {
|
|
return apiRequest<T>(endpoint, { method: 'GET' });
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*/
|
|
export async function apiPost<T>(
|
|
endpoint: string,
|
|
data?: z.infer<typeof RequestDataSchema>
|
|
): Promise<T> {
|
|
// 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<T>(endpoint, {
|
|
method: 'POST',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*/
|
|
export async function apiDelete<T>(endpoint: string): Promise<T> {
|
|
return apiRequest<T>(endpoint, { method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Login request (no auth token required)
|
|
*/
|
|
export async function login(
|
|
email: string,
|
|
password: string
|
|
): Promise<{ token: string; user: { id: string; email: string; name: string; role: string } }> {
|
|
// 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) {
|
|
let errorData: any;
|
|
try {
|
|
errorData = await response.json();
|
|
} catch {
|
|
errorData = { error: 'Login failed' };
|
|
}
|
|
throw new ApiError(errorData.error || 'Login failed', response.status, errorData);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Sanitize input to prevent XSS attacks
|
|
*/
|
|
export function sanitizeInput(input: string): string {
|
|
return input
|
|
.replace(/</g, '<')
|
|
.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<string, number[]>();
|
|
|
|
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<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
maxCalls = 10,
|
|
windowMs = 60000 // 1 minute
|
|
): Promise<T> {
|
|
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<T>(endpoint, options);
|
|
}
|
|
|
|
/**
|
|
* API client object with convenience methods
|
|
*/
|
|
export const apiClient = {
|
|
get: (endpoint: string) => rateLimitedRequest(endpoint, { method: 'GET' }),
|
|
post: apiPost,
|
|
delete: apiDelete,
|
|
login,
|
|
sanitizeInput,
|
|
isValidEmail,
|
|
validateInput,
|
|
};
|