mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-22 08:54:52 +02:00
#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:
@@ -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;
|
||||
};
|
||||
@@ -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' }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user