#34 Added server hook for cors, rate limiting and security headers.

#34 Added health service route
#34 Added OpenAPI routes and infrastructure
This commit is contained in:
Hendrik Belitz
2025-06-22 13:38:36 +02:00
parent dd30338923
commit 25b98e39ed
5 changed files with 376 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { type Handle } from '@sveltejs/kit';
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
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;
};
+117
View File
@@ -0,0 +1,117 @@
export type JsonSchema = {
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
required?: string[];
format?: string;
description?: string;
example?: unknown;
enum?: unknown[];
$ref?: string;
};
export interface OpenApiResponse {
description: string;
content?: Record<string, {
schema: JsonSchema;
example?: unknown;
}>;
}
export interface OpenApiParameter {
name: string;
in: 'query' | 'path' | 'header';
description?: string;
required?: boolean;
schema: JsonSchema;
}
export interface OpenApiRequestBody {
description?: string;
content: Record<string, {
schema: JsonSchema;
example?: unknown;
}>;
}
export interface OpenApiOperation {
summary?: string;
description?: string;
tags?: string[];
responses?: Record<string, OpenApiResponse>;
parameters?: OpenApiParameter[];
requestBody?: OpenApiRequestBody;
}
const apiOperationsRegistry = new Map<string, OpenApiOperation>();
export function registerOpenAPIRoute(path: string, method: string, operation: OpenApiOperation) {
const key = `${method.toUpperCase()} ${path}`;
apiOperationsRegistry.set(key, operation);
}
export function getApiOperations(): Map<string, OpenApiOperation> {
return apiOperationsRegistry;
}
export function generateOpenApiSpec() {
const paths: Record<string, Record<string, OpenApiOperation>> = {};
// 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' }
]
};
}
+61
View File
@@ -0,0 +1,61 @@
export async function GET() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Open Reception API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
const ui = SwaggerUIBundle({
url: '/api/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
tryItOutEnabled: true,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
docExpansion: 'list',
filter: true,
showRequestHeaders: true,
showCommonExtensions: true,
validatorUrl: null
});
};
</script>
</body>
</html>`;
return new Response(html, {
headers: {
'Content-Type': 'text/html'
}
});
}
+69
View File
@@ -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);
}
+10
View File
@@ -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);
}