UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

375 lines (374 loc) 15.8 kB
import { useEnv } from '@directus/env'; import { toArray, toBoolean } from '@directus/utils'; import { version } from 'directus/version'; import { merge } from 'lodash-es'; import { Readable } from 'node:stream'; import { performance } from 'perf_hooks'; import { getCache } from '../cache.js'; import { RESUMABLE_UPLOADS } from '../constants.js'; import getDatabase, { hasDatabaseConnection } from '../database/index.js'; import { useLogger } from '../logger/index.js'; import getMailer from '../mailer.js'; import { rateLimiterGlobal } from '../middleware/rate-limiter-global.js'; import { rateLimiter } from '../middleware/rate-limiter-ip.js'; import { SERVER_ONLINE } from '../server.js'; import { getStorage } from '../storage/index.js'; import { getAllowedLogLevels } from '../utils/get-allowed-log-levels.js'; import { SettingsService } from './settings.js'; const env = useEnv(); const logger = useLogger(); export class ServerService { knex; accountability; settingsService; schema; constructor(options) { this.knex = options.knex || getDatabase(); this.accountability = options.accountability || null; this.schema = options.schema; this.settingsService = new SettingsService({ knex: this.knex, schema: this.schema }); } async isSetupCompleted() { return Boolean(await this.knex('directus_users').first()); } async serverInfo() { const info = {}; const setupComplete = await this.isSetupCompleted(); const projectInfo = await this.settingsService.readSingleton({ fields: [ 'project_name', 'project_descriptor', 'project_logo', 'project_color', 'default_appearance', 'default_theme_light', 'default_theme_dark', 'theme_light_overrides', 'theme_dark_overrides', 'default_language', 'public_foreground', 'public_background.id', 'public_background.type', 'public_favicon', 'public_note', 'custom_css', 'public_registration', 'public_registration_verify_email', ], }); info['project'] = projectInfo; info['mcp_enabled'] = toBoolean(env['MCP_ENABLED'] ?? true); info['setupCompleted'] = setupComplete; if (this.accountability?.user) { if (env['RATE_LIMITER_ENABLED']) { info['rateLimit'] = { points: env['RATE_LIMITER_POINTS'], duration: env['RATE_LIMITER_DURATION'], }; } else { info['rateLimit'] = false; } if (env['RATE_LIMITER_GLOBAL_ENABLED']) { info['rateLimitGlobal'] = { points: env['RATE_LIMITER_GLOBAL_POINTS'], duration: env['RATE_LIMITER_GLOBAL_DURATION'], }; } else { info['rateLimitGlobal'] = false; } info['extensions'] = { limit: env['EXTENSIONS_LIMIT'] ?? null, }; info['queryLimit'] = { default: env['QUERY_LIMIT_DEFAULT'], max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1, }; if (toBoolean(env['WEBSOCKETS_ENABLED'])) { info['websocket'] = {}; info['websocket'].rest = toBoolean(env['WEBSOCKETS_REST_ENABLED']) ? { authentication: env['WEBSOCKETS_REST_AUTH'], path: env['WEBSOCKETS_REST_PATH'], } : false; info['websocket'].graphql = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED']) ? { authentication: env['WEBSOCKETS_GRAPHQL_AUTH'], path: env['WEBSOCKETS_GRAPHQL_PATH'], } : false; info['websocket'].heartbeat = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) ? env['WEBSOCKETS_HEARTBEAT_PERIOD'] : false; info['websocket'].logs = toBoolean(env['WEBSOCKETS_LOGS_ENABLED']) && this.accountability.admin ? { allowedLogLevels: getAllowedLogLevels(env['WEBSOCKETS_LOGS_LEVEL'] || 'info'), } : false; } else { info['websocket'] = false; } if (RESUMABLE_UPLOADS.ENABLED) { info['uploads'] = { chunkSize: RESUMABLE_UPLOADS.CHUNK_SIZE, }; } } if (this.accountability?.user || !setupComplete) info['version'] = version; return info; } async health() { const { nanoid } = await import('nanoid'); const checkID = nanoid(5); const data = { status: 'ok', releaseId: version, serviceId: env['PUBLIC_URL'], checks: merge(...(await Promise.all([ testDatabase(), testCache(), testRateLimiter(), testRateLimiterGlobal(), testStorage(), testEmail(), ]))), }; if (SERVER_ONLINE === false) { data.status = 'error'; } for (const [service, healthData] of Object.entries(data.checks)) { for (const healthCheck of healthData) { if (healthCheck.status === 'warn' && data.status === 'ok') { logger.warn(`${service} in WARN state, the observed value ${healthCheck.observedValue} is above the threshold of ${healthCheck.threshold}${healthCheck.observedUnit}`); data.status = 'warn'; continue; } if (healthCheck.status === 'error' && (data.status === 'ok' || data.status === 'warn')) { logger.error(healthCheck.output, '%s in ERROR state', service); data.status = 'error'; break; } } // No need to continue checking if parent status is already error if (data.status === 'error') break; } if (this.accountability?.admin !== true) { return { status: data.status }; } else { return data; } async function testDatabase() { const database = getDatabase(); const client = env['DB_CLIENT']; const checks = {}; // Response time // ---------------------------------------------------------------------------------------- checks[`${client}:responseTime`] = [ { status: 'ok', componentType: 'datastore', observedUnit: 'ms', observedValue: 0, threshold: env['DB_HEALTHCHECK_THRESHOLD'] ? +env['DB_HEALTHCHECK_THRESHOLD'] : 150, }, ]; const startTime = performance.now(); if (await hasDatabaseConnection()) { checks[`${client}:responseTime`][0].status = 'ok'; } else { checks[`${client}:responseTime`][0].status = 'error'; checks[`${client}:responseTime`][0].output = `Can't connect to the database.`; } const endTime = performance.now(); checks[`${client}:responseTime`][0].observedValue = +(endTime - startTime).toFixed(3); if (Number(checks[`${client}:responseTime`][0].observedValue) > checks[`${client}:responseTime`][0].threshold && checks[`${client}:responseTime`][0].status !== 'error') { checks[`${client}:responseTime`][0].status = 'warn'; } checks[`${client}:connectionsAvailable`] = [ { status: 'ok', componentType: 'datastore', observedValue: database.client.pool.numFree(), }, ]; checks[`${client}:connectionsUsed`] = [ { status: 'ok', componentType: 'datastore', observedValue: database.client.pool.numUsed(), }, ]; return checks; } async function testCache() { if (env['CACHE_ENABLED'] !== true) { return {}; } const { cache } = getCache(); const checks = { 'cache:responseTime': [ { status: 'ok', componentType: 'cache', observedValue: 0, observedUnit: 'ms', threshold: env['CACHE_HEALTHCHECK_THRESHOLD'] ? +env['CACHE_HEALTHCHECK_THRESHOLD'] : 150, }, ], }; const startTime = performance.now(); try { await cache.set(`directus-health-${checkID}`, true, 5); await cache.delete(`directus-health-${checkID}`); } catch (err) { checks['cache:responseTime'][0].status = 'error'; checks['cache:responseTime'][0].output = err; } finally { const endTime = performance.now(); checks['cache:responseTime'][0].observedValue = +(endTime - startTime).toFixed(3); if (checks['cache:responseTime'][0].observedValue > checks['cache:responseTime'][0].threshold && checks['cache:responseTime'][0].status !== 'error') { checks['cache:responseTime'][0].status = 'warn'; } } return checks; } async function testRateLimiter() { if (env['RATE_LIMITER_ENABLED'] !== true) { return {}; } const checks = { 'rateLimiter:responseTime': [ { status: 'ok', componentType: 'ratelimiter', observedValue: 0, observedUnit: 'ms', threshold: env['RATE_LIMITER_HEALTHCHECK_THRESHOLD'] ? +env['RATE_LIMITER_HEALTHCHECK_THRESHOLD'] : 150, }, ], }; const startTime = performance.now(); try { await rateLimiter.consume(`directus-health-${checkID}`, 1); await rateLimiter.delete(`directus-health-${checkID}`); } catch (err) { checks['rateLimiter:responseTime'][0].status = 'error'; checks['rateLimiter:responseTime'][0].output = err; } finally { const endTime = performance.now(); checks['rateLimiter:responseTime'][0].observedValue = +(endTime - startTime).toFixed(3); if (checks['rateLimiter:responseTime'][0].observedValue > checks['rateLimiter:responseTime'][0].threshold && checks['rateLimiter:responseTime'][0].status !== 'error') { checks['rateLimiter:responseTime'][0].status = 'warn'; } } return checks; } async function testRateLimiterGlobal() { if (env['RATE_LIMITER_GLOBAL_ENABLED'] !== true) { return {}; } const checks = { 'rateLimiterGlobal:responseTime': [ { status: 'ok', componentType: 'ratelimiter', observedValue: 0, observedUnit: 'ms', threshold: env['RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD'] ? +env['RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD'] : 150, }, ], }; const startTime = performance.now(); try { await rateLimiterGlobal.consume(`directus-health-${checkID}`, 1); await rateLimiterGlobal.delete(`directus-health-${checkID}`); } catch (err) { checks['rateLimiterGlobal:responseTime'][0].status = 'error'; checks['rateLimiterGlobal:responseTime'][0].output = err; } finally { const endTime = performance.now(); checks['rateLimiterGlobal:responseTime'][0].observedValue = +(endTime - startTime).toFixed(3); if (checks['rateLimiterGlobal:responseTime'][0].observedValue > checks['rateLimiterGlobal:responseTime'][0].threshold && checks['rateLimiterGlobal:responseTime'][0].status !== 'error') { checks['rateLimiterGlobal:responseTime'][0].status = 'warn'; } } return checks; } async function testStorage() { const storage = await getStorage(); const checks = {}; for (const location of toArray(env['STORAGE_LOCATIONS'])) { const disk = storage.location(location); const envThresholdKey = `STORAGE_${location}_HEALTHCHECK_THRESHOLD`.toUpperCase(); checks[`storage:${location}:responseTime`] = [ { status: 'ok', componentType: 'objectstore', observedValue: 0, observedUnit: 'ms', threshold: env[envThresholdKey] ? +env[envThresholdKey] : 750, }, ]; const startTime = performance.now(); try { await disk.write('directus-health-file', Readable.from([checkID])); } catch (err) { checks[`storage:${location}:responseTime`][0].status = 'error'; checks[`storage:${location}:responseTime`][0].output = err; } finally { const endTime = performance.now(); checks[`storage:${location}:responseTime`][0].observedValue = +(endTime - startTime).toFixed(3); if (Number(checks[`storage:${location}:responseTime`][0].observedValue) > checks[`storage:${location}:responseTime`][0].threshold && checks[`storage:${location}:responseTime`][0].status !== 'error') { checks[`storage:${location}:responseTime`][0].status = 'warn'; } } } return checks; } async function testEmail() { const checks = { 'email:connection': [ { status: 'ok', componentType: 'email', }, ], }; const mailer = getMailer(); try { await mailer.verify(); } catch (err) { checks['email:connection'][0].status = 'error'; checks['email:connection'][0].output = err; } return checks; } } }