diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..e89b06e --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,119 @@ +import { type Handle } from '@sveltejs/kit'; + +const rateLimitStore = new Map(); + +const RATE_LIMIT_WINDOW = 1000; // ms +const RATE_LIMIT_MAX_REQUESTS = 10; + +function getClientIP(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + + const realIP = request.headers.get('x-real-ip'); + if (realIP) { + return realIP; + } + + return 'unknown'; +} + +function isRateLimited(clientIP: string): boolean { + const now = Date.now(); + const key = clientIP; + + const record = rateLimitStore.get(key); + + if (!record || now > record.resetTime) { + // Reset or create new record + rateLimitStore.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return false; + } + + if (record.count >= RATE_LIMIT_MAX_REQUESTS) { + return true; + } + + record.count++; + return false; +} + +/** + * Clean up limit store every minute + */ +setInterval(() => { + const now = Date.now(); + for (const [key, record] of rateLimitStore.entries()) { + if (now > record.resetTime) { + rateLimitStore.delete(key); + } + } +}, 60000); + +/** + * Hook to add CORS and security headers and to perform rate limiting + * @param event Event containing a request + */ +export const handle: Handle = async ({ event, resolve }) => { + const { request } = event; + const clientIP = getClientIP(request); + + if (isRateLimited(clientIP)) { + return new Response('Too Many Requests', { + status: 429, + headers: { + 'Retry-After': '1', + 'Content-Type': 'text/plain' + } + }); + } + + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', // TODO: Should be limited to own server and registered webhooks etc. + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', + 'Access-Control-Max-Age': '86400' + } + }); + } + + const response = await resolve(event); + + // TODO: These has to be rechecked and coordinated with caddy's configuration + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('X-XSS-Protection', '1; mode=block'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + if (event.url.protocol === 'https:') { + response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + } + + const cspDirectives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger) + "style-src 'self' 'unsafe-inline' https://unpkg.com", + "img-src 'self' data: https:", + "font-src 'self' data: https://unpkg.com", + "connect-src 'self'", + "media-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests" + ]; + response.headers.set('Content-Security-Policy', cspDirectives.join('; ')); + + if (event.url.pathname.startsWith('/api/')) { + response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Should be limited to own server and registered webhooks etc. + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + } + + return response; +}; \ No newline at end of file diff --git a/src/lib/server/openapi.ts b/src/lib/server/openapi.ts new file mode 100644 index 0000000..31aa141 --- /dev/null +++ b/src/lib/server/openapi.ts @@ -0,0 +1,117 @@ +export type JsonSchema = { + type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; + properties?: Record; + items?: JsonSchema; + required?: string[]; + format?: string; + description?: string; + example?: unknown; + enum?: unknown[]; + $ref?: string; +}; + +export interface OpenApiResponse { + description: string; + content?: Record; +} + +export interface OpenApiParameter { + name: string; + in: 'query' | 'path' | 'header'; + description?: string; + required?: boolean; + schema: JsonSchema; +} + +export interface OpenApiRequestBody { + description?: string; + content: Record; +} + +export interface OpenApiOperation { + summary?: string; + description?: string; + tags?: string[]; + responses?: Record; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; +} + +const apiOperationsRegistry = new Map(); + +export function registerOpenAPIRoute(path: string, method: string, operation: OpenApiOperation) { + const key = `${method.toUpperCase()} ${path}`; + apiOperationsRegistry.set(key, operation); +} + +export function getApiOperations(): Map { + return apiOperationsRegistry; +} + +export function generateOpenApiSpec() { + const paths: Record> = {}; + + // Convert registered operations to OpenAPI paths + for (const [key, operation] of apiOperationsRegistry.entries()) { + const [method, path] = key.split(' ', 2); + if (!paths[path]) { + paths[path] = {}; + } + paths[path][method.toLowerCase()] = operation; + } + + return { + openapi: '3.0.0', + info: { + title: 'Open Reception API', + description: 'End-to-end encrypted appointment booking platform', + version: '0.0.1', + license: { + name: 'AGPL-3.0', + url: 'https://www.gnu.org/licenses/agpl-3.0.html' + } + }, + servers: [ + { + url: '/api', + description: 'API Server' + } + ], + paths, + components: { + schemas: { + Error: { + type: 'object', + properties: { + error: { type: 'string', description: 'Error message' }, + code: { type: 'string', description: 'Error code' } + }, + required: ['error'] + }, + HealthStatus: { + type: 'object', + properties: { + core: { type: 'boolean', description: 'Core service status' }, + database: { type: 'boolean', description: 'Database connectivity status' }, + memory: { type: 'integer', description: 'Free memory in megabytes' }, + load: { type: 'number', format: 'float', description: 'Average CPU load' } + }, + required: ['core', 'database', 'memory', 'load'] + } + } + }, + tags: [ + { name: 'Health', description: 'Health check and monitoring endpoints' }, + { name: 'Appointments', description: 'Appointment management endpoints' }, + { name: 'Clients', description: 'Client management endpoints' }, + { name: 'Channels', description: 'Channel management endpoints' }, + { name: 'Questionnaires', description: 'Questionnaire management endpoints' } + ] + }; +} \ No newline at end of file diff --git a/src/routes/api/docs/+server.ts b/src/routes/api/docs/+server.ts new file mode 100644 index 0000000..3da17ad --- /dev/null +++ b/src/routes/api/docs/+server.ts @@ -0,0 +1,61 @@ + +export async function GET() { + const html = ` + + + + + Open Reception API Documentation + + + + +
+ + + + +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html' + } + }); +} \ No newline at end of file diff --git a/src/routes/api/health/services/+server.ts b/src/routes/api/health/services/+server.ts new file mode 100644 index 0000000..bf22f54 --- /dev/null +++ b/src/routes/api/health/services/+server.ts @@ -0,0 +1,69 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { sql } from 'drizzle-orm'; +import os from 'os'; +import { registerOpenAPIRoute } from '$lib/server/openapi'; + +registerOpenAPIRoute('/health/services', 'GET', { + summary: 'Get service health status', + description: 'Returns the health status of core services including database connectivity, memory usage, and CPU load', + tags: ['Health'], + responses: { + '200': { + description: 'Service health status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthStatus' }, + example: { + core: true, + database: true, + memory: 2048, + load: 0.75 + } + } + } + }, + '429': { + description: 'Too Many Requests - Rate limit exceeded', + content: { + 'text/plain': { + schema: { type: 'string' }, + example: 'Too Many Requests' + } + } + }, + '500': { + description: 'Internal Server Error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + } + } +}); + +export class ServiceHealthResponse { + core: boolean = true; + database: boolean = false; + memory: number = 0; + load: number = 0; +} + +export async function GET() { + const serviceHealth = new ServiceHealthResponse() + + try { + await db.execute(sql`SELECT 1`); + serviceHealth.database = true; + } catch { + serviceHealth.database = false; + } + + serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024); + + const loadAvg = os.loadavg(); + serviceHealth.load = Math.round(loadAvg[0] * 100) / 100; + + return json(serviceHealth); +} \ No newline at end of file diff --git a/src/routes/api/openapi.json/+server.ts b/src/routes/api/openapi.json/+server.ts new file mode 100644 index 0000000..c203418 --- /dev/null +++ b/src/routes/api/openapi.json/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import { generateOpenApiSpec } from '$lib/server/openapi.js'; + +// ATTENTION: All routes need to be imported here to make sure they're added to the OpenAPI spec +import '../health/services/+server.js'; + +export async function GET() { + const spec = generateOpenApiSpec(); + return json(spec); +} \ No newline at end of file