1
import { useEnv } from '@directus/env';
2
import type { Accountability, SchemaOverview } from '@directus/types';
3
import { toArray, toBoolean } from '@directus/utils';
4
import { version } from 'directus/version';
5
import type { Knex } from 'knex';
6
import { merge } from 'lodash-es';
7
import { Readable } from 'node:stream';
8
import { performance } from 'perf_hooks';
9
import { getCache } from '../cache.js';
10
import getDatabase, { hasDatabaseConnection } from '../database/index.js';
11
import { useLogger } from '../logger.js';
12
import getMailer from '../mailer.js';
13
import { rateLimiterGlobal } from '../middleware/rate-limiter-global.js';
14
import { rateLimiter } from '../middleware/rate-limiter-ip.js';
15
import { SERVER_ONLINE } from '../server.js';
16
import { getStorage } from '../storage/index.js';
17
import type { AbstractServiceOptions } from '../types/index.js';
18
import { SettingsService } from './settings.js';
21
const logger = useLogger();
23
export class ServerService {
25
accountability: Accountability | null;
26
settingsService: SettingsService;
27
schema: SchemaOverview;
29
constructor(options: AbstractServiceOptions) {
30
this.knex = options.knex || getDatabase();
31
this.accountability = options.accountability || null;
32
this.schema = options.schema;
33
this.settingsService = new SettingsService({ knex: this.knex, schema: this.schema });
36
async serverInfo(): Promise<Record<string, any>> {
37
const info: Record<string, any> = {};
39
const projectInfo = await this.settingsService.readSingleton({
46
'default_theme_light',
48
'theme_light_overrides',
49
'theme_dark_overrides',
52
'public_background.id',
53
'public_background.type',
57
'public_registration',
58
'public_registration_verify_email',
62
info['project'] = projectInfo;
64
if (this.accountability?.user) {
65
if (env['RATE_LIMITER_ENABLED']) {
67
points: env['RATE_LIMITER_POINTS'],
68
duration: env['RATE_LIMITER_DURATION'],
71
info['rateLimit'] = false;
74
if (env['RATE_LIMITER_GLOBAL_ENABLED']) {
75
info['rateLimitGlobal'] = {
76
points: env['RATE_LIMITER_GLOBAL_POINTS'],
77
duration: env['RATE_LIMITER_GLOBAL_DURATION'],
80
info['rateLimitGlobal'] = false;
83
info['extensions'] = {
84
limit: env['EXTENSIONS_LIMIT'] ?? null,
87
info['queryLimit'] = {
88
default: env['QUERY_LIMIT_DEFAULT'],
89
max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
92
if (toBoolean(env['WEBSOCKETS_ENABLED'])) {
93
info['websocket'] = {};
95
info['websocket'].rest = toBoolean(env['WEBSOCKETS_REST_ENABLED'])
97
authentication: env['WEBSOCKETS_REST_AUTH'],
98
path: env['WEBSOCKETS_REST_PATH'],
102
info['websocket'].graphql = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
104
authentication: env['WEBSOCKETS_GRAPHQL_AUTH'],
105
path: env['WEBSOCKETS_GRAPHQL_PATH'],
109
info['websocket'].heartbeat = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED'])
110
? env['WEBSOCKETS_HEARTBEAT_PERIOD']
113
info['websocket'] = false;
116
info['version'] = version;
122
async health(): Promise<Record<string, any>> {
123
const { nanoid } = await import('nanoid');
125
const checkID = nanoid(5);
127
// Based on https://tools.ietf.org/id/draft-inadarei-api-health-check-05.html#name-componenttype
129
status: 'ok' | 'warn' | 'error';
133
[service: string]: HealthCheck[];
138
componentType: 'system' | 'datastore' | 'objectstore' | 'email' | 'cache' | 'ratelimiter';
139
observedValue?: number | string | boolean;
140
observedUnit?: string;
141
status: 'ok' | 'warn' | 'error';
146
const data: HealthData = {
149
serviceId: env['PUBLIC_URL'] as string,
151
...(await Promise.all([
155
testRateLimiterGlobal(),
162
if (SERVER_ONLINE === false) {
163
data.status = 'error';
166
for (const [service, healthData] of Object.entries(data.checks)) {
167
for (const healthCheck of healthData) {
168
if (healthCheck.status === 'warn' && data.status === 'ok') {
170
`${service} in WARN state, the observed value ${healthCheck.observedValue} is above the threshold of ${healthCheck.threshold}${healthCheck.observedUnit}`,
173
data.status = 'warn';
177
if (healthCheck.status === 'error' && (data.status === 'ok' || data.status === 'warn')) {
178
logger.error(healthCheck.output, '%s in ERROR state', service);
179
data.status = 'error';
184
// No need to continue checking if parent status is already error
185
if (data.status === 'error') break;
188
if (this.accountability?.admin !== true) {
189
return { status: data.status };
194
async function testDatabase(): Promise<Record<string, HealthCheck[]>> {
195
const database = getDatabase();
196
const client = env['DB_CLIENT'];
198
const checks: Record<string, HealthCheck[]> = {};
201
// ----------------------------------------------------------------------------------------
202
checks[`${client}:responseTime`] = [
205
componentType: 'datastore',
208
threshold: env['DB_HEALTHCHECK_THRESHOLD'] ? +env['DB_HEALTHCHECK_THRESHOLD'] : 150,
212
const startTime = performance.now();
214
if (await hasDatabaseConnection()) {
215
checks[`${client}:responseTime`]![0]!.status = 'ok';
217
checks[`${client}:responseTime`]![0]!.status = 'error';
218
checks[`${client}:responseTime`]![0]!.output = `Can't connect to the database.`;
221
const endTime = performance.now();
222
checks[`${client}:responseTime`]![0]!.observedValue = +(endTime - startTime).toFixed(3);
225
Number(checks[`${client}:responseTime`]![0]!.observedValue!) >
226
checks[`${client}:responseTime`]![0]!.threshold! &&
227
checks[`${client}:responseTime`]![0]!.status !== 'error'
229
checks[`${client}:responseTime`]![0]!.status = 'warn';
232
checks[`${client}:connectionsAvailable`] = [
235
componentType: 'datastore',
236
observedValue: database.client.pool.numFree(),
240
checks[`${client}:connectionsUsed`] = [
243
componentType: 'datastore',
244
observedValue: database.client.pool.numUsed(),
251
async function testCache(): Promise<Record<string, HealthCheck[]>> {
252
if (env['CACHE_ENABLED'] !== true) {
256
const { cache } = getCache();
258
const checks: Record<string, HealthCheck[]> = {
259
'cache:responseTime': [
262
componentType: 'cache',
265
threshold: env['CACHE_HEALTHCHECK_THRESHOLD'] ? +env['CACHE_HEALTHCHECK_THRESHOLD'] : 150,
270
const startTime = performance.now();
273
await cache!.set(`health-${checkID}`, true, 5);
274
await cache!.delete(`health-${checkID}`);
276
checks['cache:responseTime']![0]!.status = 'error';
277
checks['cache:responseTime']![0]!.output = err;
279
const endTime = performance.now();
280
checks['cache:responseTime']![0]!.observedValue = +(endTime - startTime).toFixed(3);
283
checks['cache:responseTime']![0]!.observedValue > checks['cache:responseTime']![0]!.threshold! &&
284
checks['cache:responseTime']![0]!.status !== 'error'
286
checks['cache:responseTime']![0]!.status = 'warn';
293
async function testRateLimiter(): Promise<Record<string, HealthCheck[]>> {
294
if (env['RATE_LIMITER_ENABLED'] !== true) {
298
const checks: Record<string, HealthCheck[]> = {
299
'rateLimiter:responseTime': [
302
componentType: 'ratelimiter',
305
threshold: env['RATE_LIMITER_HEALTHCHECK_THRESHOLD'] ? +env['RATE_LIMITER_HEALTHCHECK_THRESHOLD'] : 150,
310
const startTime = performance.now();
313
await rateLimiter.consume(`health-${checkID}`, 1);
314
await rateLimiter.delete(`health-${checkID}`);
316
checks['rateLimiter:responseTime']![0]!.status = 'error';
317
checks['rateLimiter:responseTime']![0]!.output = err;
319
const endTime = performance.now();
320
checks['rateLimiter:responseTime']![0]!.observedValue = +(endTime - startTime).toFixed(3);
323
checks['rateLimiter:responseTime']![0]!.observedValue > checks['rateLimiter:responseTime']![0]!.threshold! &&
324
checks['rateLimiter:responseTime']![0]!.status !== 'error'
326
checks['rateLimiter:responseTime']![0]!.status = 'warn';
333
async function testRateLimiterGlobal(): Promise<Record<string, HealthCheck[]>> {
334
if (env['RATE_LIMITER_GLOBAL_ENABLED'] !== true) {
338
const checks: Record<string, HealthCheck[]> = {
339
'rateLimiterGlobal:responseTime': [
342
componentType: 'ratelimiter',
345
threshold: env['RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD']
346
? +env['RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD']
352
const startTime = performance.now();
355
await rateLimiterGlobal.consume(`health-${checkID}`, 1);
356
await rateLimiterGlobal.delete(`health-${checkID}`);
358
checks['rateLimiterGlobal:responseTime']![0]!.status = 'error';
359
checks['rateLimiterGlobal:responseTime']![0]!.output = err;
361
const endTime = performance.now();
362
checks['rateLimiterGlobal:responseTime']![0]!.observedValue = +(endTime - startTime).toFixed(3);
365
checks['rateLimiterGlobal:responseTime']![0]!.observedValue >
366
checks['rateLimiterGlobal:responseTime']![0]!.threshold! &&
367
checks['rateLimiterGlobal:responseTime']![0]!.status !== 'error'
369
checks['rateLimiterGlobal:responseTime']![0]!.status = 'warn';
376
async function testStorage(): Promise<Record<string, HealthCheck[]>> {
377
const storage = await getStorage();
379
const checks: Record<string, HealthCheck[]> = {};
381
for (const location of toArray(env['STORAGE_LOCATIONS'] as string)) {
382
const disk = storage.location(location);
383
const envThresholdKey = `STORAGE_${location}_HEALTHCHECK_THRESHOLD`.toUpperCase();
385
checks[`storage:${location}:responseTime`] = [
388
componentType: 'objectstore',
391
threshold: env[envThresholdKey] ? +(env[envThresholdKey] as string) : 750,
395
const startTime = performance.now();
398
await disk.write(`health-${checkID}`, Readable.from(['check']));
399
const fileStream = await disk.read(`health-${checkID}`);
401
fileStream.on('data', async () => {
402
fileStream.destroy();
403
await disk.delete(`health-${checkID}`);
406
checks[`storage:${location}:responseTime`]![0]!.status = 'error';
407
checks[`storage:${location}:responseTime`]![0]!.output = err;
409
const endTime = performance.now();
410
checks[`storage:${location}:responseTime`]![0]!.observedValue = +(endTime - startTime).toFixed(3);
413
Number(checks[`storage:${location}:responseTime`]![0]!.observedValue!) >
414
checks[`storage:${location}:responseTime`]![0]!.threshold! &&
415
checks[`storage:${location}:responseTime`]![0]!.status !== 'error'
417
checks[`storage:${location}:responseTime`]![0]!.status = 'warn';
425
async function testEmail(): Promise<Record<string, HealthCheck[]>> {
426
const checks: Record<string, HealthCheck[]> = {
427
'email:connection': [
430
componentType: 'email',
435
const mailer = getMailer();
438
await mailer.verify();
440
checks['email:connection']![0]!.status = 'error';
441
checks['email:connection']![0]!.output = err;