From 2e259ed677d9f4e77221874e45a9bd23ea8c7eed Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 9 Aug 2023 12:10:02 -0400 Subject: [PATCH] fix: remove some notices (#649) --- api/.eslintrc.cjs | 1 + api/src/cli/commands/report.ts | 3 - api/src/core/bus.ts | 6 + api/src/core/errors/api-key-error.ts | 11 + api/src/core/errors/app-error.ts | 37 ++++ api/src/core/errors/array-running-error.ts | 10 + api/src/core/errors/atomic-write-error.ts | 10 + api/src/core/errors/em-cmd-error.ts | 11 + api/src/core/errors/fatal-error.ts | 8 + api/src/core/errors/field-missing-error.ts | 11 + api/src/core/errors/file-missing-error.ts | 14 ++ api/src/core/errors/not-implemented-error.ts | 11 + api/src/core/errors/param-invalid-error.ts | 12 ++ api/src/core/errors/param-missing-error.ts | 11 + api/src/core/errors/permission-error.ts | 10 + api/src/core/errors/php-error.ts | 6 + api/src/core/log.ts | 6 - api/src/core/modules/add-license-key.ts | 126 +++++++++++ api/src/core/modules/add-share.ts | 38 ++++ api/src/core/modules/add-user.ts | 84 ++++++++ .../core/modules/array/add-disk-to-array.ts | 49 +++++ .../modules/array/remove-disk-from-array.ts | 54 +++++ api/src/core/modules/array/update-array.ts | 88 ++++++++ .../core/modules/array/update-parity-check.ts | 78 +++++++ api/src/core/modules/debug/get-context.ts | 10 + api/src/core/modules/disks/id/get-disk.ts | 35 ++++ .../modules/docker/get-docker-containers.ts | 5 - .../modules/docker/get-docker-networks.ts | 33 +++ api/src/core/modules/get-all-shares.ts | 30 +++ api/src/core/modules/get-apps.ts | 13 ++ api/src/core/modules/get-devices.ts | 29 +++ api/src/core/modules/get-disks.ts | 121 +++++++++++ api/src/core/modules/get-me.ts | 19 ++ api/src/core/modules/get-parity-history.ts | 59 ++++++ api/src/core/modules/get-permissions.ts | 51 +++++ api/src/core/modules/get-services.ts | 5 - .../core/modules/get-unassigned-devices.ts | 28 +++ api/src/core/modules/get-users.ts | 50 +++++ api/src/core/modules/get-vars.ts | 26 +++ api/src/core/modules/get-welcome.ts | 28 +++ api/src/core/modules/services/get-emhttpd.ts | 41 ++++ .../core/modules/services/get-unraid-api.ts | 48 +++++ api/src/core/modules/settings/update-disk.ts | 150 +++++++++++++ api/src/core/modules/shares/name/get-share.ts | 47 +++++ api/src/core/modules/users/id/add-role.ts | 46 ++++ api/src/core/modules/users/id/delete-user.ts | 51 +++++ api/src/core/modules/users/id/get-user.ts | 44 ++++ api/src/core/modules/vms/get-domains.ts | 74 +++++++ api/src/core/notifiers/console.ts | 30 +++ api/src/core/notifiers/http.ts | 16 ++ api/src/core/notifiers/notifier.ts | 58 ++++++ api/src/core/permission-manager.ts | 54 +++++ api/src/core/permissions.ts | 33 +++ api/src/core/pubsub.ts | 5 - api/src/core/types/pci-device.ts | 18 ++ api/src/core/types/states/network.ts | 33 +++ api/src/core/types/states/share.ts | 32 +++ api/src/core/types/states/user.ts | 13 ++ api/src/core/utils/array/array-is-running.ts | 10 + .../core/utils/authentication/check-auth.ts | 23 ++ .../core/utils/authentication/ensure-auth.ts | 28 +++ api/src/core/utils/clients/docker.ts | 11 + api/src/core/utils/clients/emcmd.ts | 4 - api/src/core/utils/files/file-exists.ts | 13 ++ api/src/core/utils/misc/add-together.ts | 4 + api/src/core/utils/misc/atomic-sleep.ts | 8 + api/src/core/utils/misc/catch-handlers.ts | 29 +++ api/src/core/utils/misc/clean-stdout.ts | 9 + api/src/core/utils/misc/exit-app.ts | 33 +++ .../core/utils/misc/global-error-handler.ts | 23 ++ api/src/core/utils/misc/load-state.ts | 28 +++ api/src/core/utils/misc/parse-config.ts | 4 - .../misc/remove-duplicates-from-array.ts | 6 + api/src/core/utils/misc/sleep.ts | 9 + .../core/utils/misc/uppercase-first-char.ts | 4 + .../utils/permissions/check-permission.ts | 33 +++ .../utils/permissions/ensure-permission.ts | 30 +++ .../core/utils/permissions/get-permissions.ts | 13 ++ api/src/core/utils/plugins/php-loader.ts | 55 +++++ api/src/core/utils/shares/get-shares.ts | 5 - api/src/core/utils/validation/has-fields.ts | 11 + .../core/utils/vms/domain/sanitize-product.ts | 17 ++ .../core/utils/vms/domain/sanitize-vendor.ts | 31 +++ api/src/core/utils/vms/domain/vm-regexps.ts | 24 +++ api/src/core/utils/vms/filter-devices.ts | 31 +++ api/src/core/utils/vms/get-hypervisor.ts | 10 - api/src/core/utils/vms/get-pci-devices.ts | 22 ++ api/src/core/utils/vms/parse-domain.ts | 50 +++++ api/src/core/utils/vms/parse-domains.ts | 7 + .../utils/vms/system-network-interfaces.ts | 3 + api/src/graphql/index.ts | 25 +++ api/src/graphql/mothership/subscriptions.ts | 53 +++++ api/src/graphql/resolvers/mutation/index.ts | 6 + api/src/graphql/resolvers/query/online.ts | 1 + api/src/graphql/resolvers/query/servers.ts | 29 +++ api/src/graphql/resolvers/query/vms.ts | 1 + .../graphql/resolvers/subscription/index.ts | 5 - .../graphql/resolvers/subscription/network.ts | 1 - api/src/graphql/resolvers/user-account.ts | 6 + api/src/graphql/schema/index.ts | 7 + api/src/graphql/schema/utils.ts | 77 +++++++ api/src/index.ts | 4 - api/src/mothership/jobs/api-key-check-jobs.ts | 57 +++++ api/src/remoteAccess/types.ts | 5 + api/src/server.ts | 6 - .../actions/handle-remote-access-event.ts | 50 +++++ api/src/store/modules/dynamix.ts | 2 - api/src/store/modules/emhttp.ts | 197 ++++++++++++++++++ api/src/store/state-parsers/shares.ts | 1 - api/src/store/types.ts | 1 - 110 files changed, 3085 insertions(+), 67 deletions(-) create mode 100644 api/src/core/bus.ts create mode 100644 api/src/core/errors/api-key-error.ts create mode 100644 api/src/core/errors/app-error.ts create mode 100644 api/src/core/errors/array-running-error.ts create mode 100644 api/src/core/errors/atomic-write-error.ts create mode 100644 api/src/core/errors/em-cmd-error.ts create mode 100644 api/src/core/errors/fatal-error.ts create mode 100644 api/src/core/errors/field-missing-error.ts create mode 100644 api/src/core/errors/file-missing-error.ts create mode 100644 api/src/core/errors/not-implemented-error.ts create mode 100644 api/src/core/errors/param-invalid-error.ts create mode 100644 api/src/core/errors/param-missing-error.ts create mode 100644 api/src/core/errors/permission-error.ts create mode 100644 api/src/core/errors/php-error.ts create mode 100644 api/src/core/modules/add-license-key.ts create mode 100644 api/src/core/modules/add-share.ts create mode 100644 api/src/core/modules/add-user.ts create mode 100644 api/src/core/modules/array/add-disk-to-array.ts create mode 100644 api/src/core/modules/array/remove-disk-from-array.ts create mode 100644 api/src/core/modules/array/update-array.ts create mode 100644 api/src/core/modules/array/update-parity-check.ts create mode 100644 api/src/core/modules/debug/get-context.ts create mode 100644 api/src/core/modules/disks/id/get-disk.ts create mode 100644 api/src/core/modules/docker/get-docker-networks.ts create mode 100644 api/src/core/modules/get-all-shares.ts create mode 100644 api/src/core/modules/get-apps.ts create mode 100644 api/src/core/modules/get-devices.ts create mode 100644 api/src/core/modules/get-disks.ts create mode 100644 api/src/core/modules/get-me.ts create mode 100644 api/src/core/modules/get-parity-history.ts create mode 100644 api/src/core/modules/get-permissions.ts create mode 100644 api/src/core/modules/get-unassigned-devices.ts create mode 100644 api/src/core/modules/get-users.ts create mode 100644 api/src/core/modules/get-vars.ts create mode 100644 api/src/core/modules/get-welcome.ts create mode 100644 api/src/core/modules/services/get-emhttpd.ts create mode 100644 api/src/core/modules/services/get-unraid-api.ts create mode 100644 api/src/core/modules/settings/update-disk.ts create mode 100644 api/src/core/modules/shares/name/get-share.ts create mode 100644 api/src/core/modules/users/id/add-role.ts create mode 100644 api/src/core/modules/users/id/delete-user.ts create mode 100644 api/src/core/modules/users/id/get-user.ts create mode 100644 api/src/core/modules/vms/get-domains.ts create mode 100644 api/src/core/notifiers/console.ts create mode 100644 api/src/core/notifiers/http.ts create mode 100644 api/src/core/notifiers/notifier.ts create mode 100644 api/src/core/permission-manager.ts create mode 100644 api/src/core/permissions.ts create mode 100644 api/src/core/types/pci-device.ts create mode 100644 api/src/core/types/states/network.ts create mode 100644 api/src/core/types/states/share.ts create mode 100644 api/src/core/types/states/user.ts create mode 100644 api/src/core/utils/array/array-is-running.ts create mode 100644 api/src/core/utils/authentication/check-auth.ts create mode 100644 api/src/core/utils/authentication/ensure-auth.ts create mode 100644 api/src/core/utils/clients/docker.ts create mode 100644 api/src/core/utils/files/file-exists.ts create mode 100644 api/src/core/utils/misc/add-together.ts create mode 100644 api/src/core/utils/misc/atomic-sleep.ts create mode 100644 api/src/core/utils/misc/catch-handlers.ts create mode 100644 api/src/core/utils/misc/clean-stdout.ts create mode 100644 api/src/core/utils/misc/exit-app.ts create mode 100644 api/src/core/utils/misc/global-error-handler.ts create mode 100644 api/src/core/utils/misc/load-state.ts create mode 100644 api/src/core/utils/misc/remove-duplicates-from-array.ts create mode 100644 api/src/core/utils/misc/sleep.ts create mode 100644 api/src/core/utils/misc/uppercase-first-char.ts create mode 100644 api/src/core/utils/permissions/check-permission.ts create mode 100644 api/src/core/utils/permissions/ensure-permission.ts create mode 100644 api/src/core/utils/permissions/get-permissions.ts create mode 100644 api/src/core/utils/plugins/php-loader.ts create mode 100644 api/src/core/utils/validation/has-fields.ts create mode 100644 api/src/core/utils/vms/domain/sanitize-product.ts create mode 100644 api/src/core/utils/vms/domain/sanitize-vendor.ts create mode 100644 api/src/core/utils/vms/domain/vm-regexps.ts create mode 100644 api/src/core/utils/vms/filter-devices.ts create mode 100644 api/src/core/utils/vms/get-pci-devices.ts create mode 100644 api/src/core/utils/vms/parse-domain.ts create mode 100644 api/src/core/utils/vms/parse-domains.ts create mode 100644 api/src/core/utils/vms/system-network-interfaces.ts create mode 100644 api/src/graphql/index.ts create mode 100644 api/src/graphql/mothership/subscriptions.ts create mode 100644 api/src/graphql/resolvers/mutation/index.ts create mode 100644 api/src/graphql/resolvers/query/online.ts create mode 100644 api/src/graphql/resolvers/query/servers.ts create mode 100644 api/src/graphql/resolvers/query/vms.ts create mode 100644 api/src/graphql/resolvers/user-account.ts create mode 100644 api/src/graphql/schema/index.ts create mode 100644 api/src/graphql/schema/utils.ts create mode 100644 api/src/mothership/jobs/api-key-check-jobs.ts create mode 100644 api/src/remoteAccess/types.ts create mode 100644 api/src/store/actions/handle-remote-access-event.ts create mode 100644 api/src/store/modules/emhttp.ts diff --git a/api/.eslintrc.cjs b/api/.eslintrc.cjs index b47f87500..d518ff561 100644 --- a/api/.eslintrc.cjs +++ b/api/.eslintrc.cjs @@ -26,6 +26,7 @@ module.exports = { ], 'import/no-cycle': 'off', // Change this to "error" to find circular imports '@typescript-eslint/no-use-before-define': ['error'], + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], }, overrides: [ { diff --git a/api/src/cli/commands/report.ts b/api/src/cli/commands/report.ts index 599a15b20..b95c73f91 100644 --- a/api/src/cli/commands/report.ts +++ b/api/src/cli/commands/report.ts @@ -1,11 +1,8 @@ import ipRegex from 'ip-regex'; import readLine from 'readline'; -import { parseConfig } from '@app/core/utils/misc/parse-config'; import { setEnv } from '@app/cli/set-env'; import { getUnraidApiPid } from '@app/cli/get-unraid-api-pid'; -import { existsSync, readFileSync } from 'fs'; import { cliLogger } from '@app/core/log'; -import { resolve } from 'path'; import { getters, store } from '@app/store'; import { stdout } from 'process'; import { loadConfigFile } from '@app/store/modules/config'; diff --git a/api/src/core/bus.ts b/api/src/core/bus.ts new file mode 100644 index 000000000..c6a8e39d8 --- /dev/null +++ b/api/src/core/bus.ts @@ -0,0 +1,6 @@ +import NanoBus from 'nanobus'; + +/** + * Graphql event bus. + */ +export const bus = new NanoBus(); diff --git a/api/src/core/errors/api-key-error.ts b/api/src/core/errors/api-key-error.ts new file mode 100644 index 000000000..6daf81d66 --- /dev/null +++ b/api/src/core/errors/api-key-error.ts @@ -0,0 +1,11 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** +* API key error. +*/ +export class ApiKeyError extends AppError { + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(message: string) { + super(message); + } +} diff --git a/api/src/core/errors/app-error.ts b/api/src/core/errors/app-error.ts new file mode 100644 index 000000000..432c5065f --- /dev/null +++ b/api/src/core/errors/app-error.ts @@ -0,0 +1,37 @@ +/** + * Generic application error. + */ +export class AppError extends Error { + /** The HTTP status associated with this error. */ + public status: number; + + /** Should we kill the application when thrown. */ + public fatal = false; + + constructor(message: string, status?: number) { + // Calling parent constructor of base Error class. + super(message); + + // Saving class name in the property of our custom error as a shortcut. + this.name = this.constructor.name; + + // Capturing stack trace, excluding constructor call from it. + Error.captureStackTrace(this, this.constructor); + + // We're using HTTP status codes with `500` as the default + this.status = status ?? 500; + } + + /** + * Convert error to JSON format. + */ + toJSON() { + return { + error: { + name: this.name, + message: this.message, + stacktrace: this.stack, + }, + }; + } +} diff --git a/api/src/core/errors/array-running-error.ts b/api/src/core/errors/array-running-error.ts new file mode 100644 index 000000000..6d3b33856 --- /dev/null +++ b/api/src/core/errors/array-running-error.ts @@ -0,0 +1,10 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * The attempted operation can only be processed while the array is stopped. + */ +export class ArrayRunningError extends AppError { + constructor() { + super('Array needs to be stopped before any changes can occur.'); + } +} diff --git a/api/src/core/errors/atomic-write-error.ts b/api/src/core/errors/atomic-write-error.ts new file mode 100644 index 000000000..d621c7c36 --- /dev/null +++ b/api/src/core/errors/atomic-write-error.ts @@ -0,0 +1,10 @@ +import { FatalAppError } from '@app/core/errors/fatal-error'; + +/** + * Atomic write error + */ +export class AtomicWriteError extends FatalAppError { + constructor(message: string, private readonly filePath: string, status = 500) { + super(message, status); + } +} diff --git a/api/src/core/errors/em-cmd-error.ts b/api/src/core/errors/em-cmd-error.ts new file mode 100644 index 000000000..b5c80a419 --- /dev/null +++ b/api/src/core/errors/em-cmd-error.ts @@ -0,0 +1,11 @@ +import { FatalAppError } from '@app/core/errors/fatal-error'; + +/** + * Em cmd client error. + */ +export class EmCmdError extends FatalAppError { + constructor(method: string, option: string, options: string[]) { + const message = `Invalid option "${option}" for ${method}, allowed options ${JSON.stringify(options)}`; + super(message); + } +} diff --git a/api/src/core/errors/fatal-error.ts b/api/src/core/errors/fatal-error.ts new file mode 100644 index 000000000..f59dd25b6 --- /dev/null +++ b/api/src/core/errors/fatal-error.ts @@ -0,0 +1,8 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Fatal application error. + */ +export class FatalAppError extends AppError { + fatal = true; +} diff --git a/api/src/core/errors/field-missing-error.ts b/api/src/core/errors/field-missing-error.ts new file mode 100644 index 000000000..b78e72f54 --- /dev/null +++ b/api/src/core/errors/field-missing-error.ts @@ -0,0 +1,11 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Module is missing a needed field + */ +export class FieldMissingError extends AppError { + constructor(private readonly field: string) { + // Overriding both message and status code. + super(`Field missing: ${field}`, 400); + } +} diff --git a/api/src/core/errors/file-missing-error.ts b/api/src/core/errors/file-missing-error.ts new file mode 100644 index 000000000..06df7b5ed --- /dev/null +++ b/api/src/core/errors/file-missing-error.ts @@ -0,0 +1,14 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * The provided file is missing + */ +export class FileMissingError extends AppError { + /** + * @hideconstructor + */ + constructor(private readonly filePath: string) { + // Overriding both message and status code. + super('File missing: ' + filePath, 400); + } +} diff --git a/api/src/core/errors/not-implemented-error.ts b/api/src/core/errors/not-implemented-error.ts new file mode 100644 index 000000000..47dcc3128 --- /dev/null +++ b/api/src/core/errors/not-implemented-error.ts @@ -0,0 +1,11 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Whatever this is attached to isn't yet implemented. + * Sorry about that. 😔 + */ +export class NotImplementedError extends AppError { + constructor() { + super('Not implemented!'); + } +} diff --git a/api/src/core/errors/param-invalid-error.ts b/api/src/core/errors/param-invalid-error.ts new file mode 100644 index 000000000..62e5adec1 --- /dev/null +++ b/api/src/core/errors/param-invalid-error.ts @@ -0,0 +1,12 @@ +import { format } from 'util'; +import { AppError } from '@app/core/errors/app-error'; + +/** + * Invalid param provided to module + */ +export class ParamInvalidError extends AppError { + constructor(parameterName: string, parameter: any) { + // Overriding both message and status code. + super(format('Param invalid: %s = %s', parameterName, parameter), 500); + } +} diff --git a/api/src/core/errors/param-missing-error.ts b/api/src/core/errors/param-missing-error.ts new file mode 100644 index 000000000..a778167d0 --- /dev/null +++ b/api/src/core/errors/param-missing-error.ts @@ -0,0 +1,11 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Required param is missing + */ +export class ParameterMissingError extends AppError { + constructor(parameterName: string) { + // Override both message and status code. + super(`Param missing: ${parameterName}`, 500); + } +} diff --git a/api/src/core/errors/permission-error.ts b/api/src/core/errors/permission-error.ts new file mode 100644 index 000000000..4d791c0b1 --- /dev/null +++ b/api/src/core/errors/permission-error.ts @@ -0,0 +1,10 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Non fatal permission error + */ +export class PermissionError extends AppError { + constructor(message: string) { + super(message || 'Permission denied!'); + } +} diff --git a/api/src/core/errors/php-error.ts b/api/src/core/errors/php-error.ts new file mode 100644 index 000000000..04934514e --- /dev/null +++ b/api/src/core/errors/php-error.ts @@ -0,0 +1,6 @@ +import { AppError } from '@app/core/errors/app-error'; + +/** + * Error bubbled up from a PHP script. + */ +export class PhpError extends AppError {} diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 62087e20e..4cf9636c5 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import chalk from 'chalk'; import { configure, getLogger } from 'log4js'; import { serializeError } from 'serialize-error'; @@ -88,7 +83,6 @@ if (process.env.NODE_ENV !== 'test') { }); } - export const internalLogger = getLogger('internal'); export const logger = getLogger('app'); export const mothershipLogger = getLogger('mothership'); diff --git a/api/src/core/modules/add-license-key.ts b/api/src/core/modules/add-license-key.ts new file mode 100644 index 000000000..bcbaa52ec --- /dev/null +++ b/api/src/core/modules/add-license-key.ts @@ -0,0 +1,126 @@ +// import fs from 'fs'; +// import { log } from '../log'; +import type { CoreContext, CoreResult } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { NotImplementedError } from '@app/core/errors/not-implemented-error'; +import { AppError } from '@app/core/errors/app-error'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + data: { + keyUri?: string; + trial?: boolean; + replacement?: boolean; + email?: string; + keyFile?: string; + }; +} + +interface Result extends CoreResult { + json: { + key?: string; + type?: string; + }; +} + +/** + * Register a license key. + */ +export const addLicenseKey = async (context: Context): Promise => { + ensurePermission(context.user, { + resource: 'license-key', + action: 'create', + possession: 'any', + }); + + // Const { data } = context; + const emhttp = getters.emhttp(); + const guid = emhttp.var.regGuid; + // Const timestamp = new Date(); + + if (!guid) { + throw new AppError('guid missing'); + } + + throw new NotImplementedError(); + + // // Connect to unraid.net to request a trial key + // if (data?.trial) { + // const body = new FormData(); + // body.append('guid', guid); + // body.append('timestamp', timestamp.getTime().toString()); + + // const key = await got('https://keys.lime-technology.com/account/trial', { method: 'POST', body }) + // .then(response => JSON.parse(response.body)) + // .catch(error => { + // log.error(error); + // throw new AppError(`Sorry, a HTTP ${error.status} error occurred while registering USB Flash GUID ${guid}`); + // }); + + // // Update the trial key file + // await fs.promises.writeFile('/boot/config/Trial.key', Buffer.from(key, 'base64')); + + // return { + // text: 'Thank you for registering, your trial key has been accepted.', + // json: { + // key + // } + // }; + // } + + // // Connect to unraid.net to request a new replacement key + // if (data?.replacement) { + // const { email, keyFile } = data; + + // if (!email || !keyFile) { + // throw new AppError('email or keyFile is missing'); + // } + + // const body = new FormData(); + // body.append('guid', guid); + // body.append('timestamp', timestamp.getTime().toString()); + // body.append('email', email); + // body.append('keyfile', keyFile); + + // const { body: key } = await got('https://keys.lime-technology.com/account/license/transfer', { method: 'POST', body }) + // .then(response => JSON.parse(response.body)) + // .catch(error => { + // log.error(error); + // throw new AppError(`Sorry, a HTTP ${error.status} error occurred while issuing a replacement for USB Flash GUID ${guid}`); + // }); + + // // Update the trial key file + // await fs.promises.writeFile('/boot/config/Trial.key', Buffer.from(key, 'base64')); + + // return { + // text: 'Thank you for registering, your trial key has been registered.', + // json: { + // key + // } + // }; + // } + + // // Register a new server + // if (data?.keyUri) { + // const parts = data.keyUri.split('.key')[0].split('/'); + // const { [parts.length - 1]: keyType } = parts; + + // // Download key blob + // const { body: key } = await got(data.keyUri) + // .then(response => JSON.parse(response.body)) + // .catch(error => { + // log.error(error); + // throw new AppError(`Sorry, a HTTP ${error.status} error occurred while registering your key for USB Flash GUID ${guid}`); + // }); + + // // Save key file + // await fs.promises.writeFile(`/boot/config/${keyType}.key`, Buffer.from(key, 'base64')); + + // return { + // text: `Thank you for registering, your ${keyType} key has been accepted.`, + // json: { + // type: keyType + // } + // }; + // } +}; diff --git a/api/src/core/modules/add-share.ts b/api/src/core/modules/add-share.ts new file mode 100644 index 000000000..88c9440b6 --- /dev/null +++ b/api/src/core/modules/add-share.ts @@ -0,0 +1,38 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; +import { NotImplementedError } from '@app/core/errors/not-implemented-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { getters } from '@app/store'; + +export const addShare = async (context: CoreContext): Promise => { + const { user, data } = context; + + if (!data?.name) { + throw new AppError('No name provided'); + } + + // Check permissions + ensurePermission(user, { + resource: 'share', + action: 'create', + possession: 'any', + }); + + const { shares, disks } = getters.emhttp(); + + const { name } = data; + const userShares = shares.map(({ name }) => name); + const diskShares = disks.filter(slot => slot.exportable).filter(({ name }) => name.startsWith('disk')).map(({ name }) => name); + + // Existing share names + const inUseNames = new Set([ + ...userShares, + ...diskShares, + ]); + + if (inUseNames.has(name)) { + throw new AppError(`Share already exists with name: ${name}`, 400); + } + + throw new NotImplementedError(); +}; diff --git a/api/src/core/modules/add-user.ts b/api/src/core/modules/add-user.ts new file mode 100644 index 000000000..d691b71ba --- /dev/null +++ b/api/src/core/modules/add-user.ts @@ -0,0 +1,84 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { bus } from '@app/core/bus'; +import { AppError } from '@app/core/errors/app-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + readonly data: { + /** Display name. */ + readonly name: string; + /** User's password. */ + readonly password: string; + /** Friendly description. */ + readonly description: string; + }; +} + +/** + * Add user account. + */ +export const addUser = async (context: Context): Promise => { + const { data } = context; + + // Check permissions + ensurePermission(context.user, { + resource: 'user', + action: 'create', + possession: 'any', + }); + + // Validation + const { name, description = '', password } = data; + const missingFields = hasFields(data, ['name', 'password']); + + if (missingFields.length !== 0) { + // Only log first error. + throw new FieldMissingError(missingFields[0]); + } + + // Check user name isn't taken + if (getters.emhttp().users.find(user => user.name === name)) { + throw new AppError('A user account with that name already exists.'); + } + + // Create user + await emcmd({ + userName: name, + userDesc: description, + userPassword: password, + userPasswordConf: password, + cmdUserEdit: 'Add', + }); + + // Get fresh copy of Users with the new user + const user = getters.emhttp().users.find(user => user.name === name); + if (!user) { + // User managed to disappear between us creating it and the lookup? + throw new AppError('Internal Server Error!'); + } + + // Update users channel with new user + bus.emit('users', { + users: { + mutation: 'CREATED', + node: [user], + }, + }); + + // Update user channel with new user + bus.emit('user', { + user: { + mutation: 'CREATED', + node: user, + }, + }); + + return { + text: `User created successfully. ${JSON.stringify(user, null, 2)}`, + json: user, + }; +}; diff --git a/api/src/core/modules/array/add-disk-to-array.ts b/api/src/core/modules/array/add-disk-to-array.ts new file mode 100644 index 000000000..1c47e1b4b --- /dev/null +++ b/api/src/core/modules/array/add-disk-to-array.ts @@ -0,0 +1,49 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { arrayIsRunning } from '@app/core/utils/array/array-is-running'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { ArrayRunningError } from '@app/core/errors/array-running-error'; +import { getArrayData } from '@app/core/modules/array/get-array-data'; + +/** + * Add a disk to the array. + */ +export const addDiskToArray = async function (context: CoreContext): Promise { + const { data = {}, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'array', + action: 'create', + possession: 'any', + }); + + const missingFields = hasFields(data, ['id']); + if (missingFields.length !== 0) { + // Just log first error + throw new FieldMissingError(missingFields[0]); + } + + if (arrayIsRunning()) { + throw new ArrayRunningError(); + } + + const { id: diskId, slot: preferredSlot } = data; + const slot = Number.parseInt(preferredSlot as string, 10); + + // Add disk + await emcmd({ + changeDevice: 'apply', + [`slotId.${slot}`]: diskId, + }); + + const array = getArrayData() + + // Disk added successfully + return { + text: `Disk was added to the array in slot ${slot}.`, + json: array, + }; +}; diff --git a/api/src/core/modules/array/remove-disk-from-array.ts b/api/src/core/modules/array/remove-disk-from-array.ts new file mode 100644 index 000000000..ee6ef0d05 --- /dev/null +++ b/api/src/core/modules/array/remove-disk-from-array.ts @@ -0,0 +1,54 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { arrayIsRunning } from '@app/core/utils/array/array-is-running'; +import { ArrayRunningError } from '@app/core/errors/array-running-error'; +import { getArrayData } from '@app/core/modules/array/get-array-data'; + +interface Context extends CoreContext { + data: { + /** The slot the disk is in. */ + slot: string; + }; +} + +/** + * Remove a disk from the array. + * @returns The updated array. + */ +export const removeDiskFromArray = async (context: Context): Promise => { + const { data, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'array', + action: 'create', + possession: 'any', + }); + + const missingFields = hasFields(data, ['id']); + + if (missingFields.length !== 0) { + // Only log first error + throw new FieldMissingError(missingFields[0]); + } + + if (arrayIsRunning()) { + throw new ArrayRunningError(); + } + + const { slot } = data; + + // Error removing disk + // if () { + // } + + const array = getArrayData() + + // Disk removed successfully + return { + text: `Disk was removed from the array in slot ${slot}.`, + json: array, + }; +}; diff --git a/api/src/core/modules/array/update-array.ts b/api/src/core/modules/array/update-array.ts new file mode 100644 index 000000000..d05174f08 --- /dev/null +++ b/api/src/core/modules/array/update-array.ts @@ -0,0 +1,88 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { uppercaseFirstChar } from '@app/core/utils/misc/uppercase-first-char'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { arrayIsRunning } from '@app/core/utils/array/array-is-running'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { ParamInvalidError } from '@app/core/errors/param-invalid-error'; +import { AppError } from '@app/core/errors/app-error'; +import { getArrayData } from '@app/core/modules/array/get-array-data'; + +// @TODO: Fix this not working across node apps +// each app has it's own lock since the var is scoped +// ideally this should have a timeout to prevent it sticking +let locked = false; + +export const updateArray = async (context: CoreContext): Promise => { + const { data = {}, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'array', + action: 'update', + possession: 'any', + }); + + const missingFields = hasFields(data, ['state']); + + if (missingFields.length !== 0) { + // Only log first error + throw new FieldMissingError(missingFields[0]); + } + + const { state: nextState } = data; + const startState = arrayIsRunning() ? 'started' : 'stopped'; + const pendingState = nextState === 'stop' ? 'stopping' : 'starting'; + + if (!['start', 'stop'].includes(nextState)) { + throw new ParamInvalidError('state', nextState); + } + + // Prevent this running multiple times at once + if (locked) { + throw new AppError('Array state is still being updated.'); + } + + // Prevent starting/stopping array when it's already in the same state + if ((arrayIsRunning() && nextState === 'start') || (!arrayIsRunning() && nextState === 'stop')) { + throw new AppError(`The array is already ${startState}`); + } + + // Set lock then start/stop array + locked = true; + const command = { + [`cmd${uppercaseFirstChar(nextState)}`]: uppercaseFirstChar(nextState), + startState: startState.toUpperCase(), + }; + + // `await` has to be used otherwise the catch + // will finish after the return statement below + await emcmd(command).finally(() => { + locked = false; + }); + + // Get new array JSON + const array = getArrayData() + + /** + * Update array details + * + * @memberof Core + * @module array/update-array + * @param {Core~Context} context Context object. + * @param {Object} context.data The data object. + * @param {'start'|'stop'} context.data.state If the array should be started or stopped. + * @param {State~User} context.user The current user. + * @returns {Core~Result} The updated array. + */ + return { + text: `Array was ${startState}, ${pendingState}.`, + json: { + ...array.json, + state: nextState === 'start' ? 'started' : 'stopped', + previousState: startState, + pendingState, + }, + }; +}; diff --git a/api/src/core/modules/array/update-parity-check.ts b/api/src/core/modules/array/update-parity-check.ts new file mode 100644 index 000000000..96210ef7f --- /dev/null +++ b/api/src/core/modules/array/update-parity-check.ts @@ -0,0 +1,78 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { ParamInvalidError } from '@app/core/errors/param-invalid-error'; +import { getters } from '@app/store'; + +type State = 'start' | 'cancel' | 'resume' | 'cancel'; + +interface Context extends CoreContext { + data: { + state?: State; + correct?: boolean; + }; +} + +/** + * Remove a disk from the array. + * @returns The update array. + */ +export const updateParityCheck = async (context: Context): Promise => { + const { user, data } = context; + + // Check permissions + ensurePermission(user, { + resource: 'array', + action: 'update', + possession: 'any', + }); + + // Validation + if (!data.state) { + throw new FieldMissingError('state'); + } + + const { state: wantedState } = data; + const emhttp = getters.emhttp(); + const running = emhttp.var.mdResync !== 0; + const states = { + pause: { + cmdNoCheck: 'Pause', + }, + resume: { + cmdCheck: 'Resume', + }, + cancel: { + cmdNoCheck: 'Cancel', + }, + start: { + cmdCheck: 'Check', + }, + }; + + let allowedStates = Object.keys(states); + + // Only allow starting a check if there isn't already one running + if (running) { + allowedStates = allowedStates.splice(allowedStates.indexOf('start'), 1); + } + + // Only allow states from states object + if (!allowedStates.includes(wantedState)) { + throw new ParamInvalidError('state', wantedState); + } + + // Should we write correction to the parity during the check + const writeCorrectionsToParity = wantedState === 'start' && data.correct; + + await emcmd({ + startState: 'STARTED', + ...states[wantedState], + ...(writeCorrectionsToParity ? { optionCorrect: 'correct' } : {}), + }); + + return { + json: {}, + }; +}; diff --git a/api/src/core/modules/debug/get-context.ts b/api/src/core/modules/debug/get-context.ts new file mode 100644 index 000000000..40a01b221 --- /dev/null +++ b/api/src/core/modules/debug/get-context.ts @@ -0,0 +1,10 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; + +/** + * Get internal context object. + */ +export const getContext = (context: CoreContext): CoreResult => ({ + text: `Context: ${JSON.stringify(context, null, 2)}`, + json: context, + html: `

Context

\n
${JSON.stringify(context, null, 2)}
`, +}); diff --git a/api/src/core/modules/disks/id/get-disk.ts b/api/src/core/modules/disks/id/get-disk.ts new file mode 100644 index 000000000..cd091aa78 --- /dev/null +++ b/api/src/core/modules/disks/id/get-disk.ts @@ -0,0 +1,35 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; + +interface Context extends CoreContext { + params: { + id: string; + }; +} + +/** + * Get a single disk. + */ +export const getDisk = async (context: Context, Disks): Promise => { + const { params, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'disk', + action: 'read', + possession: 'any', + }); + + const { id } = params; + const disk = await Disks.findOne({ id }); + + if (!disk) { + throw new AppError(`No disk found matching ${id}`, 404); + } + + return { + text: `Disk: ${JSON.stringify(disk, null, 2)}`, + json: disk, + }; +}; diff --git a/api/src/core/modules/docker/get-docker-containers.ts b/api/src/core/modules/docker/get-docker-containers.ts index 6c835b58a..894a3ea86 100644 --- a/api/src/core/modules/docker/get-docker-containers.ts +++ b/api/src/core/modules/docker/get-docker-containers.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import fs from 'fs'; import camelCaseKeys from 'camelcase-keys'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers'; diff --git a/api/src/core/modules/docker/get-docker-networks.ts b/api/src/core/modules/docker/get-docker-networks.ts new file mode 100644 index 000000000..fa82ac6a4 --- /dev/null +++ b/api/src/core/modules/docker/get-docker-networks.ts @@ -0,0 +1,33 @@ +import camelCaseKeys from 'camelcase-keys'; +import { docker, ensurePermission } from '@app/core/utils'; +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { catchHandlers } from '@app/core/utils/misc/catch-handlers'; + +export const getDockerNetworks = async (context: CoreContext): Promise => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'docker/network', + action: 'read', + possession: 'any', + }); + + const networks = await docker.listNetworks() + // If docker throws an error return no networks + .catch(catchHandlers.docker) + .then((networks = []) => networks.map(object => camelCaseKeys(object, { deep: true }))); + + /** + * Get all Docker networks + * + * @memberof Core + * @module docker/get-networks + * @param {Core~Context} context + * @returns {Core~Result} All the in/active Docker networks on the system. + */ + return { + text: `Networks: ${JSON.stringify(networks, null, 2)}`, + json: networks, + }; +}; diff --git a/api/src/core/modules/get-all-shares.ts b/api/src/core/modules/get-all-shares.ts new file mode 100644 index 000000000..a4af21521 --- /dev/null +++ b/api/src/core/modules/get-all-shares.ts @@ -0,0 +1,30 @@ +import type { CoreResult, CoreContext } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { getShares } from '@app/core/utils/shares/get-shares'; + +/** + * Get all shares. + */ +export const getAllShares = async (context: CoreContext): Promise => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'share', + action: 'read', + possession: 'any', + }); + + const userShares = getShares('users'); + const diskShares = getShares('disks'); + + const shares = [ + ...userShares, + ...diskShares, + ]; + + return { + text: `Shares: ${JSON.stringify(shares, null, 2)}`, + json: shares, + }; +}; diff --git a/api/src/core/modules/get-apps.ts b/api/src/core/modules/get-apps.ts new file mode 100644 index 000000000..a1064503e --- /dev/null +++ b/api/src/core/modules/get-apps.ts @@ -0,0 +1,13 @@ +import type { CoreResult } from '@app/core/types'; + +/** + * Get all apps. + */ +export const getApps = async (): Promise => { + const apps = []; + + return { + text: `Apps: ${JSON.stringify(apps, null, 2)}`, + json: apps, + }; +}; diff --git a/api/src/core/modules/get-devices.ts b/api/src/core/modules/get-devices.ts new file mode 100644 index 000000000..c41c92166 --- /dev/null +++ b/api/src/core/modules/get-devices.ts @@ -0,0 +1,29 @@ +import type { CoreResult, CoreContext } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; + +/** + * Get all devices. + * @returns All currently connected devices. + */ +export const getDevices = async (context: CoreContext): Promise => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'device', + action: 'read', + possession: 'any', + }); + /* + Const { devices } = getters.emhttp(); + + return { + text: `Devices: ${JSON.stringify(devices, null, 2)}`, + json: devices, + }; + */ + return { + text: 'Disabled Due To Bug With Devs Sub', + json: {}, + }; +}; diff --git a/api/src/core/modules/get-disks.ts b/api/src/core/modules/get-disks.ts new file mode 100644 index 000000000..55c3ee6e4 --- /dev/null +++ b/api/src/core/modules/get-disks.ts @@ -0,0 +1,121 @@ +import { execa } from 'execa'; +import { + type Systeminformation, + blockDevices, + diskLayout, +} from 'systeminformation'; +import { map as asyncMap } from 'p-iteration'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { type Context } from '@app/graphql/schema/utils'; +import { + type Disk, + DiskInterfaceType, + DiskSmartStatus, +} from '@app/graphql/generated/api/types'; +import { DiskFsType } from '@app/graphql/generated/api/types'; +import { graphqlLogger } from '@app/core/log'; + +const getTemperature = async ( + disk: Systeminformation.DiskLayoutData +): Promise => { + try { + const stdout = await execa('smartctl', ['-A', disk.device]) + .then(({ stdout }) => stdout) + .catch(() => ''); + const lines = stdout.split('\n'); + const header = lines.find((line) => line.startsWith('ID#')) ?? ''; + const fields = lines.splice(lines.indexOf(header) + 1, lines.length); + const field = fields.find( + (line) => + line.includes('Temperature_Celsius') || + line.includes('Airflow_Temperature_Cel') + ); + + if (!field) { + return -1; + } + + if (field.includes('Min/Max')) { + return Number.parseInt( + field.split(' - ')[1].trim().split(' ')[0], + 10 + ); + } + + const line = field.split(' '); + return Number.parseInt(line[line.length - 1], 10); + } catch (error) { + graphqlLogger.warn('Caught error fetching disk temperature: %o', error); + return -1; + } +}; + +const parseDisk = async ( + disk: Systeminformation.DiskLayoutData, + partitionsToParse: Systeminformation.BlockDevicesData[], + temperature = false +): Promise => { + const partitions = partitionsToParse + // Only get partitions from this disk + .filter((partition) => + partition.name.startsWith(disk.device.split('/dev/')[1]) + ) + // Remove unneeded fields + .map(({ name, fsType, size }) => ({ + name, + fsType: typeof fsType === 'string' ? DiskFsType[fsType] : undefined, + size, + })); + + return { + ...disk, + smartStatus: + typeof disk.smartStatus === 'string' + ? DiskSmartStatus[disk.smartStatus.toUpperCase()] + : undefined, + interfaceType: + typeof disk.interfaceType === 'string' + ? DiskInterfaceType[disk.interfaceType] + : DiskInterfaceType.UNKNOWN, + temperature: temperature ? await getTemperature(disk) : -1, + partitions, + }; +}; + +/** + * Get all disks. + */ +export const getDisks = async ( + context: Context, + options?: { temperature: boolean } +): Promise => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'disk', + action: 'read', + possession: 'any', + }); + + // Return all fields but temperature + if (options?.temperature === false) { + const partitions = await blockDevices().then((devices) => + devices.filter((device) => device.type === 'part') + ); + const disks = await asyncMap(await diskLayout(), async (disk) => + parseDisk(disk, partitions) + ); + + return disks; + } + + const partitions = await blockDevices().then((devices) => + devices.filter((device) => device.type === 'part') + ); + const disks = await asyncMap(await diskLayout(), async (disk) => + parseDisk(disk, partitions, true) + ); + + return disks; +}; diff --git a/api/src/core/modules/get-me.ts b/api/src/core/modules/get-me.ts new file mode 100644 index 000000000..185c87d76 --- /dev/null +++ b/api/src/core/modules/get-me.ts @@ -0,0 +1,19 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { getPermissions } from '@app/core/utils/permissions/get-permissions'; + +/** + * Get current user. + */ +export const getMe = (context: CoreContext): CoreResult => { + const { user } = context; + + const me = { + ...user, + permissions: getPermissions(user.role), + }; + + return { + text: `Me: ${JSON.stringify(me, null, 2)}`, + json: me, + }; +}; diff --git a/api/src/core/modules/get-parity-history.ts b/api/src/core/modules/get-parity-history.ts new file mode 100644 index 000000000..00ed04d73 --- /dev/null +++ b/api/src/core/modules/get-parity-history.ts @@ -0,0 +1,59 @@ +import { promises as fs } from 'fs'; +import { type CoreResult, type CoreContext } from '@app/core/types'; +import { FileMissingError } from '@app/core/errors/file-missing-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import Table from 'cli-table'; +import { getters } from '@app/store'; + +/** + * Get parity history. + * @returns All parity checks with their respective date, duration, speed, status and errors. + */ +export const getParityHistory = async (context: CoreContext): Promise => { + const { user } = context; + + // Bail if the user doesn't have permission + ensurePermission(user, { + resource: 'parity-history', + action: 'read', + possession: 'any', + }); + + const historyFilePath = getters.paths()['parity-checks']; + const history = await fs.readFile(historyFilePath).catch(() => { + throw new FileMissingError(historyFilePath); + }); + + // Convert checks into array of objects + const lines = history.toString().trim().split('\n').reverse(); + const parityChecks = lines.map(line => { + const [date, duration, speed, status, errors = '0'] = line.split('|'); + return { + date, + duration: Number.parseInt(duration, 10), + speed, + status, + errors: Number.parseInt(errors, 10), + }; + }); + + // Create table for text output + const table = new Table({ + head: ['Date', 'Duration', 'Speed', 'Status', 'Errors'], + }); + // Update raw values with strings + parityChecks.forEach(check => { + const array = Object.values({ + ...check, + speed: check.speed ? check.speed : 'Unavailable', + duration: check.duration >= 0 ? check.duration : 'Unavailable', + status: check.status === '-4' ? 'Cancelled' : 'OK', + }); + table.push(array); + }); + + return { + text: table.toString(), + json: parityChecks, + }; +}; diff --git a/api/src/core/modules/get-permissions.ts b/api/src/core/modules/get-permissions.ts new file mode 100644 index 000000000..e15258f54 --- /dev/null +++ b/api/src/core/modules/get-permissions.ts @@ -0,0 +1,51 @@ +import { ac } from '@app/core/permissions'; +import { getPermissions as getUserPermissions } from '@app/core/utils/permissions/get-permissions'; +import type { CoreContext, CoreResult } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; + +/** + * Get all permissions. + */ +export const getPermissions = async function (context: CoreContext): Promise { + const { user } = context; + + // Bail if the user doesn't have permission + ensurePermission(user, { + resource: 'permission', + action: 'read', + possession: 'any', + }); + + // Get all scopes + const scopes = Object.assign({}, ...Object.values(ac.getGrants()).map(grant => { + // @ts-expect-error - $extend and grants are any + const { $extend, ...grants } = grant; + return { + ...grants, + ...$extend && getUserPermissions($extend), + }; + })); + + // Get all roles and their scopes + const grants = Object.entries(ac.getGrants()) + .map(([name, grant]) => { + // @ts-expect-error - $extend and grants are any + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { $extend: _, ...grants } = grant; + return [name, grants]; + }) + .reduce((object, { + 0: key, + 1: value, + }) => Object.assign(object, { + [key.toString()]: value, + }), {}); + + return { + text: `Scopes: ${JSON.stringify(scopes, null, 2)}`, + json: { + scopes, + grants, + }, + }; +}; diff --git a/api/src/core/modules/get-services.ts b/api/src/core/modules/get-services.ts index 14389b873..91be99724 100644 --- a/api/src/core/modules/get-services.ts +++ b/api/src/core/modules/get-services.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { getEmhttpdService } from '@app/core/modules/services/get-emhttpd'; import { logger } from '@app/core/log'; import type { CoreResult, CoreContext } from '@app/core/types'; diff --git a/api/src/core/modules/get-unassigned-devices.ts b/api/src/core/modules/get-unassigned-devices.ts new file mode 100644 index 000000000..325c7ff3a --- /dev/null +++ b/api/src/core/modules/get-unassigned-devices.ts @@ -0,0 +1,28 @@ +import { AppError } from '@app/core/errors/app-error'; +import type { CoreResult, CoreContext } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; + +/** + * Get all unassigned devices. + */ +export const getUnassignedDevices = async (context: CoreContext): Promise => { + const { user } = context; + + // Bail if the user doesn't have permission + ensurePermission(user, { + resource: 'devices/unassigned', + action: 'read', + possession: 'any', + }); + + const devices = []; + + if (devices.length === 0) { + throw new AppError('No devices found.', 404); + } + + return { + text: `Unassigned devices: ${JSON.stringify(devices, null, 2)}`, + json: devices, + }; +}; diff --git a/api/src/core/modules/get-users.ts b/api/src/core/modules/get-users.ts new file mode 100644 index 000000000..de14795cc --- /dev/null +++ b/api/src/core/modules/get-users.ts @@ -0,0 +1,50 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { AppError } from '@app/core/errors/app-error'; +import { getters } from '@app/store'; +import { type User } from '@app/core/types/states/user'; + +interface Context extends CoreContext { + query: { + /** Should all fields be returned? */ + slim: string; + }; +} + +/** + * Get all users. + */ +export const getUsers = async (context: Context): Promise => { + const { query, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'user', + action: 'read', + possession: 'any', + }); + + // Default to only showing limited fields + const { slim = 'true' } = query; + const { users } = getters.emhttp(); + + if (users.length === 0) { + // This is likely a new install or something went horribly wrong + throw new AppError('No users found.', 404); + } + + const result = slim === 'true' ? users.map((user: User) => { + const { id, name, description, role } = user; + return { + id, + name, + description, + role, + }; + }) : users; + + return { + text: `Users: ${JSON.stringify(result, null, 2)}`, + json: result, + }; +}; diff --git a/api/src/core/modules/get-vars.ts b/api/src/core/modules/get-vars.ts new file mode 100644 index 000000000..18f01d9b3 --- /dev/null +++ b/api/src/core/modules/get-vars.ts @@ -0,0 +1,26 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { getters } from '@app/store'; + +/** + * Get all system vars. + */ +export const getVars = async (context: CoreContext): Promise => { + const { user } = context; + + // Bail if the user doesn't have permission + ensurePermission(user, { + resource: 'vars', + action: 'read', + possession: 'any', + }); + + const emhttp = getters.emhttp(); + + return { + text: `Vars: ${JSON.stringify(emhttp.var, null, 2)}`, + json: { + ...emhttp.var, + }, + }; +}; diff --git a/api/src/core/modules/get-welcome.ts b/api/src/core/modules/get-welcome.ts new file mode 100644 index 000000000..8a829e899 --- /dev/null +++ b/api/src/core/modules/get-welcome.ts @@ -0,0 +1,28 @@ +import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version'; +import type { CoreResult, CoreContext } from '@app/core/types'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; + +/** + * Get welcome message. + * @returns Welcomes a user. + */ +export const getWelcome = async (context: CoreContext): Promise => { + const { user } = context; + + // Bail if the user doesn't have permission + ensurePermission(user, { + resource: 'welcome', + action: 'read', + possession: 'any', + }); + + const version = await getUnraidVersion(); + const message = `Welcome ${user.name} to this Unraid ${version} server`; + + return { + text: message, + json: { + message, + }, + }; +}; diff --git a/api/src/core/modules/services/get-emhttpd.ts b/api/src/core/modules/services/get-emhttpd.ts new file mode 100644 index 000000000..f91578c0e --- /dev/null +++ b/api/src/core/modules/services/get-emhttpd.ts @@ -0,0 +1,41 @@ +import { execa } from 'execa'; +import { ensurePermission } from '@app/core/utils'; +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { cleanStdout } from '@app/core/utils/misc/clean-stdout'; + +interface Result extends CoreResult { + json: { + online: boolean; + uptime: number; + }; +} + +/** + * Get emhttpd service info. + */ +export const getEmhttpdService = async (context: CoreContext): Promise => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'service/emhttpd', + action: 'read', + possession: 'any', + }); + + // Only get uptime if process is online + const uptime = await execa('ps', ['-C', 'emhttpd', '-o', 'etimes', '--no-headers']) + .then(cleanStdout) + .then(uptime => Number.parseInt(uptime, 10)) + .catch(() => -1); + + const online = uptime >= 1; + + return { + text: `Online: ${online}\n Uptime: ${uptime}`, + json: { + online, + uptime, + }, + }; +}; diff --git a/api/src/core/modules/services/get-unraid-api.ts b/api/src/core/modules/services/get-unraid-api.ts new file mode 100644 index 000000000..693ec1ac5 --- /dev/null +++ b/api/src/core/modules/services/get-unraid-api.ts @@ -0,0 +1,48 @@ +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import type { CoreContext, CoreResult } from '@app/core/types'; +import { API_VERSION } from '@app/environment'; + +interface Result extends CoreResult { + json: { + name: string; + online: boolean; + uptime: { + timestamp: string; + seconds: number; + }; + }; +} + +// When this service started +const startTimestamp = new Date(); + +/** + * Get Unraid api service info. + */ +export const getUnraidApiService = async (context: CoreContext): Promise => { + // Check permissions + ensurePermission(context.user, { + resource: 'service/unraid-api', + action: 'read', + possession: 'any', + }); + + const now = new Date(); + const uptimeTimestamp = startTimestamp.toISOString(); + const uptimeSeconds = (now.getTime() - startTimestamp.getTime()); + + const service = { + name: 'unraid-api', + online: true, + uptime: { + timestamp: uptimeTimestamp, + seconds: uptimeSeconds, + }, + version: API_VERSION, + }; + + return { + text: `Service: ${JSON.stringify(service, null, 2)}`, + json: service, + }; +}; diff --git a/api/src/core/modules/settings/update-disk.ts b/api/src/core/modules/settings/update-disk.ts new file mode 100644 index 000000000..dcf0d8b2c --- /dev/null +++ b/api/src/core/modules/settings/update-disk.ts @@ -0,0 +1,150 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { EmCmdError } from '@app/core/errors/em-cmd-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { type Var } from '@app/core/types/states/var'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + data: Var; +} + +interface Result extends CoreResult { + json: { + mdwriteMethod?: number; + startArray?: boolean; + spindownDelay?: number; + defaultFormat?: any; + defaultFsType?: any; + }; +} + +/** + * Update disk settings. + */ +export const updateDisk = async (context: Context): Promise => { + const { data, user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'disk/settings', + action: 'update', + possession: 'any', + }); + + /** + * Check context.data[property] is using an allowed value. + * + * @param property The property of data to check values against. + * @param allowedValues Which values which are allowed. + * @param optional If the value can also be undefined. + */ + const check = (property: string, allowedValues: Record | string[], optional = true): void => { + const value = data[property]; + + // Skip checking if the value isn't needed and it's not set + if (optional && value === undefined) { + return; + } + + // AllowedValues is an object + if (!Array.isArray(allowedValues)) { + allowedValues = Object.keys(allowedValues); + } + + if (!allowedValues.includes(value)) { + throw new EmCmdError(property, value, allowedValues); + } + }; + + // If set to 'Yes' then if the device configuration is correct upon server start - up, the array will be automatically started and shares exported. + // If set to 'No' then you must start the array yourself. + check('startArray', ['yes', 'no']); + + // Define the 'default' time-out for spinning hard drives down after a period of no I/O activity. + // You may also override the default value for an individual disk. + check('spindownDelay', { + + 0: 'Never', + 15: '15 minutes', + 30: '30 minutes', + 45: '45 minutes', + 1: '1 hour', + 2: '2 hours', + 3: '3 hours', + 4: '4 hours', + 5: '5 hours', + 6: '6 hours', + 7: '7 hours', + 8: '8 hours', + 9: '9 hours', + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + // Defines the type of partition layout to create when formatting hard drives 2TB in size and smaller **only**. (All devices larger then 2TB are always set up with GPT partition tables.) + // **MBR: unaligned** setting will create MBR-style partition table, where the single partition 1 will start in the **63rd sector** from the start of the disk. This is the *traditional* setting for virtually all MBR-style partition tables. + // **MBR: 4K-aligned** setting will create an MBR-style partition table, where the single partition 1 will start in the **64th sector** from the start of the disk. Since the sector size is 512 bytes, this will *align* the start of partition 1 on a 4K-byte boundary. This is required for proper support of so-called *Advanced Format* drives. + // Unless you have a specific requirement do not change this setting from the default **MBR: 4K-aligned**. + check('defaultFormat', { + + 1: 'MBR: unaligned', + 2: 'MBR: 4K-aligned', + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + // Selects the method to employ when writing to enabled disk in parity protected array. + check('writeMethod', { + auto: 'Auto - read/modify/write', + + 0: 'read/modify/write', + 1: 'reconstruct write', + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + // Defines the default file system type to create when an * unmountable * array device is formatted. + // The default file system type for a single or multi - device cache is always Btrfs. + check('defaultFsType', { + xfs: 'xfs', + btrfs: 'btrfs', + reiserfs: 'reiserfs', + + 'luks:xfs': 'xfs - encrypted', + 'luks:btrfs': 'btrfs - encrypted', + 'luks:reiserfs': 'reiserfs - encrypted', + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + const { + startArray, + spindownDelay, + defaultFormat, + defaultFsType, + mdWriteMethod, + } = data; + + await emcmd({ + startArray, + spindownDelay, + defaultFormat, + defaultFsType, + + md_write_method: mdWriteMethod, + changeDisk: 'Apply', + }); + + const emhttp = getters.emhttp(); + + // @todo: return all disk settings + const result = { + mdwriteMethod: emhttp.var.mdWriteMethod, + startArray: emhttp.var.startArray, + spindownDelay: emhttp.var.spindownDelay, + defaultFormat: emhttp.var.defaultFormat, + defaultFsType: emhttp.var.defaultFormat, + }; + + return { + text: `Disk settings: ${JSON.stringify(result, null, 2)}`, + json: result, + }; +}; diff --git a/api/src/core/modules/shares/name/get-share.ts b/api/src/core/modules/shares/name/get-share.ts new file mode 100644 index 000000000..11fbcf761 --- /dev/null +++ b/api/src/core/modules/shares/name/get-share.ts @@ -0,0 +1,47 @@ +import type { CoreContext, CoreResult } from '@app/core/types/global'; +import type { UserShare, DiskShare } from '@app/core/types/states/share'; +import { AppError } from '@app/core/errors/app-error'; +import { getShares, ensurePermission } from '@app/core/utils'; + +interface Context extends CoreContext { + params: { + /** Name of the share */ + name: string; + }; +} + +interface Result extends CoreResult { + json: UserShare | DiskShare; +} + +/** + * Get single share. + */ +export const getShare = async function (context: Context): Promise { + const { params, user } = context; + const { name } = params; + + // Check permissions + ensurePermission(user, { + resource: 'share', + action: 'read', + possession: 'any', + }); + + const userShare = getShares('user', { name }); + const diskShare = getShares('disk', { name }); + + const share = [ + userShare, + diskShare, + ].filter(_ => _)[0]; + + if (!share) { + throw new AppError('No share found with that name.', 404); + } + + return { + text: `Share: ${JSON.stringify(share, null, 2)}`, + json: share, + }; +}; diff --git a/api/src/core/modules/users/id/add-role.ts b/api/src/core/modules/users/id/add-role.ts new file mode 100644 index 000000000..6967960da --- /dev/null +++ b/api/src/core/modules/users/id/add-role.ts @@ -0,0 +1,46 @@ +import { type CoreContext, type CoreResult } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + params: { + /** Name of user to add the role to. */ + name: string; + }; +} + +/** + * Add role to user. + */ +export const addRole = async (context: Context): Promise => { + const { user, params } = context; + + // Check permissions + ensurePermission(user, { + resource: 'user', + action: 'update', + possession: 'any', + }); + + // Validation + const { name } = params; + const missingFields = hasFields(params, ['name']); + + if (missingFields.length !== 0) { + throw new FieldMissingError(missingFields[0]); + } + + // Check user exists + if (!getters.emhttp().users.find(user => user.name === name)) { + throw new AppError('No user exists with this name.'); + } + + // @todo: add user role + + return { + text: 'User updated successfully.', + }; +}; diff --git a/api/src/core/modules/users/id/delete-user.ts b/api/src/core/modules/users/id/delete-user.ts new file mode 100644 index 000000000..a1567d0cb --- /dev/null +++ b/api/src/core/modules/users/id/delete-user.ts @@ -0,0 +1,51 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; +import { FieldMissingError } from '@app/core/errors/field-missing-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { hasFields } from '@app/core/utils/validation/has-fields'; +import { emcmd } from '@app/core/utils/clients/emcmd'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + params: { + /** Name of user to delete. */ + name: string; + }; +} + +/** + * Delete user account. + */ +export const deleteUser = async (context: Context): Promise => { + // Check permissions + ensurePermission(context.user, { + resource: 'user', + action: 'delete', + possession: 'any', + }); + + const { params } = context; + const { name } = params; + const missingFields = hasFields(params, ['name']); + + if (missingFields.length !== 0) { + // Just throw the first error + throw new FieldMissingError(missingFields[0]); + } + + // Check user exists + if (!getters.emhttp().users.find(user => user.name === name)) { + throw new AppError('No user exists with this name.'); + } + + // Delete user + await emcmd({ + userName: name, + confirmDelete: 'on', + cmdUserEdit: 'Delete', + }); + + return { + text: 'User deleted successfully.', + }; +}; diff --git a/api/src/core/modules/users/id/get-user.ts b/api/src/core/modules/users/id/get-user.ts new file mode 100644 index 000000000..2d57187eb --- /dev/null +++ b/api/src/core/modules/users/id/get-user.ts @@ -0,0 +1,44 @@ +import type { CoreContext, CoreResult } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; +import { ensureParameter } from '@app/core/utils/validation/context'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { getters } from '@app/store'; + +interface Context extends CoreContext { + params: { + /** User ID */ + id: string; + }; +} + +/** + * Get single user. + * @returns The selected user. + */ +export const getUser = async (context: Context): Promise => { + // Check permissions + ensurePermission(context.user, { + resource: 'user', + action: 'create', + possession: 'any', + }); + + ensureParameter(context, 'id'); + + const id = context?.params?.id; + if (!id) { + throw new AppError('No id passed.'); + } + + const user = getters.emhttp().users.find(user => user.id === id); + + if (!user) { + // This is likely a new install or something went horribly wrong + throw new AppError(`No users found matching ${id}`, 404); + } + + return { + text: `User: ${JSON.stringify(user, null, 2)}`, + json: user, + }; +}; diff --git a/api/src/core/modules/vms/get-domains.ts b/api/src/core/modules/vms/get-domains.ts new file mode 100644 index 000000000..397ea53f2 --- /dev/null +++ b/api/src/core/modules/vms/get-domains.ts @@ -0,0 +1,74 @@ +import { ConnectListAllDomainsFlags } from '@vmngr/libvirt'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { getHypervisor } from '@app/core/utils/vms/get-hypervisor'; +import { VmState, type VmDomain, type VmsResolvers } from '@app/graphql/generated/api/types'; +import { GraphQLError } from 'graphql'; + +const states = { + 0: 'NOSTATE', + 1: 'RUNNING', + 2: 'IDLE', + 3: 'PAUSED', + 4: 'SHUTDOWN', + 5: 'SHUTOFF', + 6: 'CRASHED', + 7: 'PMSUSPENDED', +}; + +/** + * Get vm domains. + */ +export const domainResolver: VmsResolvers['domain'] = async ( + _, + __, + context +) => { + const { user } = context; + + // Check permissions + ensurePermission(user, { + resource: 'vms/domain', + action: 'read', + possession: 'any', + }); + + try { + const hypervisor = await getHypervisor(); + if (!hypervisor) { + throw new GraphQLError('VMs Disabled'); + } + + const autoStartDomains = await hypervisor.connectListAllDomains( + ConnectListAllDomainsFlags.AUTOSTART + ); + + const autoStartDomainNames = await Promise.all( + autoStartDomains.map(async (domain) => + hypervisor.domainGetName(domain) + ) + ); + + // Get all domains + const domains = await hypervisor.connectListAllDomains(); + + const resolvedDomains: Array = await Promise.all( + domains.map(async (domain) => { + const info = await hypervisor.domainGetInfo(domain); + const name = await hypervisor.domainGetName(domain); + const features = {}; + return { + name, + uuid: await hypervisor.domainGetUUIDString(domain), + state: VmState[states[info.state]] ?? VmState.NOSTATE, + autoStart: autoStartDomainNames.includes(name), + features, + }; + }) + ); + + return resolvedDomains; + } catch (error: unknown) { + // If we hit an error expect libvirt to be offline + throw new GraphQLError(`Failed to fetch domains with error: ${error instanceof Error ? error.message : 'Unknown Error'}`); + } +}; diff --git a/api/src/core/notifiers/console.ts b/api/src/core/notifiers/console.ts new file mode 100644 index 000000000..754b17c5c --- /dev/null +++ b/api/src/core/notifiers/console.ts @@ -0,0 +1,30 @@ +import { Notifier, type NotifierOptions, type NotifierSendOptions } from '@app/core/notifiers/notifier'; +import { logger } from '@app/core/log'; + +/** + * Console notifier. + */ +export class ConsoleNotifier extends Notifier { + private readonly log: typeof logger; + + constructor(options: NotifierOptions = {}) { + super(options); + + this.level = options.level ?? 'info'; + this.helpers = options.helpers ?? {}; + this.template = options.template ?? '{{{ data }}}'; + this.log = logger; + } + + /** + * Send notification. + */ + send(options: NotifierSendOptions) { + const { title, data } = options; + const { level, helpers } = this; + // Render template + const template = this.render({ ...data }, helpers); + + this.log[level](title, template); + } +} diff --git a/api/src/core/notifiers/http.ts b/api/src/core/notifiers/http.ts new file mode 100644 index 000000000..c9d8ee2ae --- /dev/null +++ b/api/src/core/notifiers/http.ts @@ -0,0 +1,16 @@ +import { got } from 'got'; +import { Notifier, type NotifierOptions } from '@app/core/notifiers/notifier'; + +export type Options = NotifierOptions + +/** + * HTTP notifier. + */ +export class HttpNotifier extends Notifier { + readonly $http = got; + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(options: Options) { + super(options); + } +} diff --git a/api/src/core/notifiers/notifier.ts b/api/src/core/notifiers/notifier.ts new file mode 100644 index 000000000..26309aa04 --- /dev/null +++ b/api/src/core/notifiers/notifier.ts @@ -0,0 +1,58 @@ +import Mustache from 'mustache'; +import { type LooseObject } from '@app/core/types'; + +export type NotifierLevel = 'info' | 'warn' | 'error'; + +export type NotifierOptions = Partial<{ + level: NotifierLevel; + helpers?: Record; + template?: string; +}>; + +export interface NotifierSendOptions { + /** Which type of notification. */ + type?: string; + /** The notification's title. */ + title: string; + /** Static data passed for rendering. */ + data: LooseObject; + /** Functions to generate dynamic data for rendering. */ + computed?: LooseObject; +} + +/** + * Base notifier. + * @param Alert level. + * @param Helpers to pass to the notifer. + * @param Template for the notifer to render. + * @private + */ +export class Notifier { + template: string; + helpers: LooseObject; + level: string; + + constructor(options: NotifierOptions) { + this.template = options.template ?? '{{ data }}'; + this.helpers = options.helpers ?? {}; + this.level = options.level ?? 'info'; + } + + /** + * Render template. + * @param data Static data for template rendering. + * @param helpers Functions for template rendering. + * @param computed Functions to generate dynamic data for rendering. + */ + render(data: LooseObject): string { + return Mustache.render(this.template, data); + } + + /** + * Generates a mustache helper. + * @param func Function to be wrapped. + */ + generateHelper(func: (text: string) => string) { + return () => (text: string, render: (text: string) => string) => func(render(text)); + } +} diff --git a/api/src/core/permission-manager.ts b/api/src/core/permission-manager.ts new file mode 100644 index 000000000..5fa1244ae --- /dev/null +++ b/api/src/core/permission-manager.ts @@ -0,0 +1,54 @@ +import { validate as validateArgument } from 'bycontract'; +import { type LooseObject } from '@app/core/types'; +import { AppError } from '@app/core/errors/app-error'; + +/** + * Permission manager. + */ +class PermissionManager { + private readonly knownScopes: string[]; + private readonly scopes: LooseObject; + + /** + * @hideconstructor + */ + constructor() { + /** + * Scopes that've been registered + * + * @name PermissionManager.knownScopes + */ + this.knownScopes = []; + + /** + * Keys and what scopes are linked to them. + * + * Note: If this key is linked to a user it'll extend their scopes. + * + * @name PermissionManager.scopes + */ + this.scopes = {}; + } + + /** + * Get scopes based on name or fall back to all scopes + * + * @param apiKey The API key to lookup. + * @memberof PermissionManager + */ + getScopes(apiKey: string) { + if (!apiKey) { + return this.knownScopes; + } + + validateArgument(apiKey, 'string'); + + if (!Object.keys(this.scopes).includes(apiKey)) { + throw new AppError('Invalid key!'); + } + + return this.scopes[apiKey]; + } +} + +export const permissionManager = new PermissionManager(); diff --git a/api/src/core/permissions.ts b/api/src/core/permissions.ts new file mode 100644 index 000000000..3b1257349 --- /dev/null +++ b/api/src/core/permissions.ts @@ -0,0 +1,33 @@ +import { logger } from '@app/core/log'; +import { permissions as defaultPermissions } from '@app/core/default-permissions'; +import { AccessControl } from 'accesscontrol'; + +// Use built in permissions +const getPermissions = () => defaultPermissions; + +// Build permissions array +const roles = getPermissions(); +const permissions = Object.entries(roles).flatMap(([roleName, role]) => [ + ...(role?.permissions ?? []).map(permission => ({ + ...permission, + role: roleName, + })), +]); + +// Grant permissions +const ac = new AccessControl(permissions); + +// Extend roles +Object.entries(getPermissions()).forEach(([roleName, role]) => { + if (role.extends) { + ac.extendRole(roleName, role.extends); + } +}); + +logger.addContext('permissions', permissions); +logger.trace('Loaded permissions'); +logger.removeContext('permissions'); + +export { + ac, +}; diff --git a/api/src/core/pubsub.ts b/api/src/core/pubsub.ts index 69653dff2..35c5eef6f 100644 --- a/api/src/core/pubsub.ts +++ b/api/src/core/pubsub.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { PubSub } from 'graphql-subscriptions'; import EventEmitter from 'events'; diff --git a/api/src/core/types/pci-device.ts b/api/src/core/types/pci-device.ts new file mode 100644 index 000000000..95c6e9a18 --- /dev/null +++ b/api/src/core/types/pci-device.ts @@ -0,0 +1,18 @@ +export type PciDeviceClass = 'vga' | 'audio' | 'gpu' | 'other'; + +/** + * PCI device + */ +export interface PciDevice { + id: string; + allowed: boolean; + class: PciDeviceClass; + vendorname: string; + productname: string; + typeid: string; + serial: string; + product: string; + manufacturer: string; + guid: string; + name: string; +} diff --git a/api/src/core/types/states/network.ts b/api/src/core/types/states/network.ts new file mode 100644 index 000000000..d8e61b6a8 --- /dev/null +++ b/api/src/core/types/states/network.ts @@ -0,0 +1,33 @@ +export type Network = { + dhcpKeepresolv: boolean; + dnsServer1: string; + dnsServer2: string; + dhcp6Keepresolv: boolean; + bonding: boolean; + bondname: string; + bondnics: string[]; + bondingMode: string; + bondingMiimon: string; + bridging: boolean; + brname: string; + brnics: string; + brstp: string; + brfd: string; + 'description': string[]; + 'protocol': string[]; + 'useDhcp': boolean[]; + 'ipaddr': string[]; + 'netmask': string[]; + 'gateway': string[]; + 'metric': string[]; + 'useDhcp6': boolean[]; + 'ipaddr6': string[]; + 'netmask6': string[]; + 'gateway6': string[]; + 'metric6': string[]; + 'privacy6': string[]; + mtu: string[]; + type: string[]; +}; + +export type Networks = Network[]; diff --git a/api/src/core/types/states/share.ts b/api/src/core/types/states/share.ts new file mode 100644 index 000000000..471c24b25 --- /dev/null +++ b/api/src/core/types/states/share.ts @@ -0,0 +1,32 @@ +export type Share = { + /** Share name. */ + name: string; + /** Free space in bytes. */ + free: number; + /** Total space in bytes. */ + size: number; + /** Which disks to include from the share. */ + include: string[]; + /** Which disks to exclude from the share. */ + exclude: string[]; + /** If the share should use the cache. */ + cache: boolean; +}; + +export type Shares = Share[]; + +/** + * Disk share + */ +export interface DiskShare extends Share { + type: 'disk'; +} + +/** + * User share + */ +export interface UserShare extends Share { + type: 'user'; +} + +export type ShareType = 'user' | 'users' | 'disk' | 'disks'; diff --git a/api/src/core/types/states/user.ts b/api/src/core/types/states/user.ts new file mode 100644 index 000000000..f3c947b8b --- /dev/null +++ b/api/src/core/types/states/user.ts @@ -0,0 +1,13 @@ +export type User = { + /** User's ID */ + id: string; + /** Display name */ + name: string; + description: string; + /** If password is set. */ + password: boolean; + /** The main {@link Permissions~Role | role} linked to this account. */ + role: string; +}; + +export type Users = User[]; diff --git a/api/src/core/utils/array/array-is-running.ts b/api/src/core/utils/array/array-is-running.ts new file mode 100644 index 000000000..ac81cadf5 --- /dev/null +++ b/api/src/core/utils/array/array-is-running.ts @@ -0,0 +1,10 @@ +import { ArrayState } from '@app/graphql/generated/api/types'; +import { getters } from '@app/store'; + +/** + * Is the array running? + */ +export const arrayIsRunning = () => { + const emhttp = getters.emhttp(); + return emhttp.var.mdState === ArrayState.STARTED; +}; diff --git a/api/src/core/utils/authentication/check-auth.ts b/api/src/core/utils/authentication/check-auth.ts new file mode 100644 index 000000000..b8cb278ab --- /dev/null +++ b/api/src/core/utils/authentication/check-auth.ts @@ -0,0 +1,23 @@ +import { getters } from '@app/store'; +import htpasswd from 'htpasswd-js'; + +interface Options { + username: string; + password: string; + file?: string; +} + +/** + * Check if the username and password match a htpasswd file. + */ +export const checkAuth = async (options: Options): Promise => { + const { username, password, file } = options; + + // `valid` will be true if and only if + // username and password were correct. + return htpasswd.authenticate({ + username, + password, + file: file ?? getters.paths().htpasswd, + }); +}; diff --git a/api/src/core/utils/authentication/ensure-auth.ts b/api/src/core/utils/authentication/ensure-auth.ts new file mode 100644 index 000000000..b1b7dbdb7 --- /dev/null +++ b/api/src/core/utils/authentication/ensure-auth.ts @@ -0,0 +1,28 @@ +import { PermissionError } from '@app/core/errors/permission-error'; +import { checkAuth } from '@app/core/utils/authentication/check-auth'; +import { getters } from '@app/store'; + +interface Options { + username: string; + password: string; + file: string; +} + +/** + * Check if the username and password match a htpasswd file + */ +export const ensureAuth = async (options: Options) => { + const { username, password, file } = options; + + // `valid` will be true if and only if + // username and password were correct. + const valid = await checkAuth({ + username, + password, + file: file || getters.paths().htpasswd, + }); + + if (!valid) { + throw new PermissionError('Invalid auth!'); + } +}; diff --git a/api/src/core/utils/clients/docker.ts b/api/src/core/utils/clients/docker.ts new file mode 100644 index 000000000..dee3a4cad --- /dev/null +++ b/api/src/core/utils/clients/docker.ts @@ -0,0 +1,11 @@ +import Docker from 'dockerode'; + +const socketPath = '/var/run/docker.sock'; +const client = new Docker({ + socketPath, +}); + +/** + * Docker client + */ +export const docker = client; \ No newline at end of file diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index 5911962f7..d1cdfb685 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -1,7 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ import { got } from 'got' import { logger } from '@app/core/log'; import { type LooseObject } from '@app/core/types'; diff --git a/api/src/core/utils/files/file-exists.ts b/api/src/core/utils/files/file-exists.ts new file mode 100644 index 000000000..ecc47a2cb --- /dev/null +++ b/api/src/core/utils/files/file-exists.ts @@ -0,0 +1,13 @@ +import { access } from 'fs/promises'; +import { accessSync } from 'fs'; +import { F_OK } from 'node:constants'; + +export const fileExists = async (path: string) => access(path, F_OK).then(() => true).catch(() => false); +export const fileExistsSync = (path: string) => { + try { + accessSync(path, F_OK); + return true; + } catch (error: unknown) { + return false; + } +}; diff --git a/api/src/core/utils/misc/add-together.ts b/api/src/core/utils/misc/add-together.ts new file mode 100644 index 000000000..e2221b2e4 --- /dev/null +++ b/api/src/core/utils/misc/add-together.ts @@ -0,0 +1,4 @@ +/** + * Add all numbers in array together. + */ +export const addTogether = (array: number[]) => array.reduce((a, b) => (a + b), 0); diff --git a/api/src/core/utils/misc/atomic-sleep.ts b/api/src/core/utils/misc/atomic-sleep.ts new file mode 100644 index 000000000..b0578fd2f --- /dev/null +++ b/api/src/core/utils/misc/atomic-sleep.ts @@ -0,0 +1,8 @@ +/** + * Atomically sleep for a certain amount of milliseconds. + * @param ms How many milliseconds to sleep for. + */ +export const atomicSleep = async (ms: number): Promise => new Promise(resolve => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + resolve(); +}); diff --git a/api/src/core/utils/misc/catch-handlers.ts b/api/src/core/utils/misc/catch-handlers.ts new file mode 100644 index 000000000..a240af74c --- /dev/null +++ b/api/src/core/utils/misc/catch-handlers.ts @@ -0,0 +1,29 @@ +import { AppError } from '@app/core/errors/app-error'; +import { getters } from '@app/store'; + +interface DockerError extends NodeJS.ErrnoException { + address: string; +} + +/** + * Shared catch handlers. + */ +export const catchHandlers = { + docker(error: DockerError) { + const socketPath = getters.paths()['docker-socket']; + + // Throw custom error for docker socket missing + if (error.code === 'ENOENT' && error.address === socketPath) { + throw new AppError('Docker socket unavailable.'); + } + + throw error; + }, + emhttpd(error: NodeJS.ErrnoException) { + if (error.code === 'ENOENT') { + throw new AppError('emhttpd socket unavailable.'); + } + + throw error; + }, +}; diff --git a/api/src/core/utils/misc/clean-stdout.ts b/api/src/core/utils/misc/clean-stdout.ts new file mode 100644 index 000000000..0fda3b641 --- /dev/null +++ b/api/src/core/utils/misc/clean-stdout.ts @@ -0,0 +1,9 @@ +interface Options { + /** Standard output from execa. */ + stdout: string; +} + +/** + * Execa helper to trim stdout. + */ +export const cleanStdout = (options: Options) => options.stdout.trim(); diff --git a/api/src/core/utils/misc/exit-app.ts b/api/src/core/utils/misc/exit-app.ts new file mode 100644 index 000000000..517b19f81 --- /dev/null +++ b/api/src/core/utils/misc/exit-app.ts @@ -0,0 +1,33 @@ +import { AppError } from '@app/core/errors/app-error'; +import { logger } from '@app/core/log'; + +/** + * Exit application. + */ +export const exitApp = (error?: Error, exitCode?: number) => { + if (!error) { + // Kill application immediately + process.exitCode = exitCode ?? 0; + return; + } + + // Allow non-fatal errors to throw but keep the app running + if (error instanceof AppError) { + if (!error.fatal) { + logger.trace(error.message); + return; + } + + // Log last error + logger.error(error); + + // Kill application + process.exitCode = exitCode; + } else { + // Log last error + logger.error(error); + + // Kill application + process.exitCode = exitCode; + } +}; diff --git a/api/src/core/utils/misc/global-error-handler.ts b/api/src/core/utils/misc/global-error-handler.ts new file mode 100644 index 000000000..bb4e25095 --- /dev/null +++ b/api/src/core/utils/misc/global-error-handler.ts @@ -0,0 +1,23 @@ +import { exitApp } from '@app/core/utils/misc/exit-app'; + +/** + * Handles all global, bubbled and uncaught errors. + * + * @name Utils~globalErrorHandler + * @param {Error} error + * @private + */ +export const globalErrorHandler = (error: Error) => { + console.warn('Uncaught Exception!\nStopping unraid-api!'); + try { + exitApp(error, 1); + } catch (error: unknown) { + // We should only end up here if `Errors` or `Core.log` have an issue loading. + + // Log last error + console.error(error); + + // Kill application + process.exitCode = 1; + } +}; diff --git a/api/src/core/utils/misc/load-state.ts b/api/src/core/utils/misc/load-state.ts new file mode 100644 index 000000000..fef316adb --- /dev/null +++ b/api/src/core/utils/misc/load-state.ts @@ -0,0 +1,28 @@ +import camelCaseKeys from 'camelcase-keys'; +import { logger } from '@app/core/log'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; + +/** + * Loads state from path. + * @param filePath Path to state file. + */ +export const loadState = >(filePath: string): T | undefined => { + try { + const config = camelCaseKeys(parseConfig({ + filePath, + type: 'ini', + }), { + deep: true, + }) as T; + + logger.addContext('config', config); + logger.trace('"%s" was loaded', filePath); + logger.removeContext('config'); + + return config; + } catch (error: unknown) { + logger.trace('Failed loading state file "%s" with "%s"', filePath, error instanceof Error ? error.message : error); + } + + return undefined; +}; diff --git a/api/src/core/utils/misc/parse-config.ts b/api/src/core/utils/misc/parse-config.ts index 1c029c9cc..6fd77cb17 100644 --- a/api/src/core/utils/misc/parse-config.ts +++ b/api/src/core/utils/misc/parse-config.ts @@ -1,7 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ import { parse as parseIni } from 'ini'; import camelCaseKeys from 'camelcase-keys'; import { includeKeys } from 'filter-obj'; diff --git a/api/src/core/utils/misc/remove-duplicates-from-array.ts b/api/src/core/utils/misc/remove-duplicates-from-array.ts new file mode 100644 index 000000000..e1a2a4457 --- /dev/null +++ b/api/src/core/utils/misc/remove-duplicates-from-array.ts @@ -0,0 +1,6 @@ +/** +* Remove duplicate objects from array. +* @param array An array of object to filter through. +* @param prop The property to base the duplication check on. +*/ +export const removeDuplicatesFromArray = (array: T[], prop: string): T[] => array.filter((object, pos, array_) => array_.map(mapObject => mapObject[prop].indexOf(object[prop]) === pos)); diff --git a/api/src/core/utils/misc/sleep.ts b/api/src/core/utils/misc/sleep.ts new file mode 100644 index 000000000..3d0138a3c --- /dev/null +++ b/api/src/core/utils/misc/sleep.ts @@ -0,0 +1,9 @@ +/** +* Sleep for a certain amount of milliseconds. +* @param ms How many milliseconds to sleep for. +*/ +export const sleep = async (ms: number) => new Promise(resolve => { + setTimeout(() => { + resolve(); + }, ms); +}); diff --git a/api/src/core/utils/misc/uppercase-first-char.ts b/api/src/core/utils/misc/uppercase-first-char.ts new file mode 100644 index 000000000..4c3ae484c --- /dev/null +++ b/api/src/core/utils/misc/uppercase-first-char.ts @@ -0,0 +1,4 @@ +/** +* Uppercase first char of string. +*/ +export const uppercaseFirstChar = (string: string) => string.charAt(0).toUpperCase() + string.slice(1); diff --git a/api/src/core/utils/permissions/check-permission.ts b/api/src/core/utils/permissions/check-permission.ts new file mode 100644 index 000000000..a69435292 --- /dev/null +++ b/api/src/core/utils/permissions/check-permission.ts @@ -0,0 +1,33 @@ +import { ParameterMissingError } from '@app/core/errors/param-missing-error'; +import { ac } from '@app/core/permissions'; +import { type User } from '@app/core/types/states/user'; + +export interface AccessControlOptions { + /** Which resource to verify the user's role against. e.g. 'apikeys' */ + resource: string; + /** Which action to verify the user's role against. e.g. 'read' */ + action: 'create' | 'read' | 'update' | 'delete'; + /** If the user can access their own or everyone's. */ + possession: 'own' | 'any'; +} + +/** + * Check if the user has the correct permissions. + * @param user The user to check permissions on. + */ +export const checkPermission = (user: User, options: AccessControlOptions) => { + if (!user) { + throw new ParameterMissingError('user'); + } + + const { resource, action, possession = 'own' } = options; + const permission = ac.permission({ + role: user.role, + resource, + action, + possession, + }); + + // Check if user is allowed + return permission.granted; +}; diff --git a/api/src/core/utils/permissions/ensure-permission.ts b/api/src/core/utils/permissions/ensure-permission.ts new file mode 100644 index 000000000..6686309ae --- /dev/null +++ b/api/src/core/utils/permissions/ensure-permission.ts @@ -0,0 +1,30 @@ +import { PermissionError } from '@app/core/errors/permission-error'; +import { type User } from '@app/core/types/states/user'; +import { checkPermission, type AccessControlOptions } from '@app/core/utils/permissions/check-permission'; +import { logger } from '@app/core/log'; + +/** + * Ensure the user has the correct permissions. + * @param user The user to check permissions on. + * @param options A permissions object. + */ +export const ensurePermission = (user: User | undefined, options: AccessControlOptions) => { + const { resource, action, possession = 'own' } = options; + + // Bail if no user was passed + if (!user) throw new PermissionError(`No user provided for authentication check when trying to access "${resource}".`); + + const permissionGranted = checkPermission(user, { + resource, + action, + possession, + }); + + if (process.env.NODE_ENV === 'development' && process.env.BYPASS_PERMISSION_CHECKS && !permissionGranted) { + logger.warn(`BYPASSING_PERMISSION_CHECK: ${user.name} doesn't have permission to access "${resource}".`); + return; + } + + // Bail if user doesn't have permission + if (!permissionGranted) throw new PermissionError(`${user.name} doesn't have permission to access "${resource}".`); +}; diff --git a/api/src/core/utils/permissions/get-permissions.ts b/api/src/core/utils/permissions/get-permissions.ts new file mode 100644 index 000000000..3806bcf7d --- /dev/null +++ b/api/src/core/utils/permissions/get-permissions.ts @@ -0,0 +1,13 @@ +import { ac } from '@app/core/permissions'; + +/** + * Get permissions from an {@link https://onury.io/accesscontrol/?api=ac#AccessControl AccessControl} role. + * @param role The {@link https://onury.io/accesscontrol/?api=ac#AccessControl AccessControl} role to be looked up. + */ +export const getPermissions = (role: string): Record> => { + const grants: Record> = ac.getGrants(); + const { $extend, ...roles } = grants[role] ?? {}; + const inheritedRoles = Array.isArray($extend) ? $extend.map(role => getPermissions(role))[0] : {}; + // eslint-disable-next-line prefer-object-spread + return Object.assign({}, roles, inheritedRoles); +}; diff --git a/api/src/core/utils/plugins/php-loader.ts b/api/src/core/utils/plugins/php-loader.ts new file mode 100644 index 000000000..b7dd9c043 --- /dev/null +++ b/api/src/core/utils/plugins/php-loader.ts @@ -0,0 +1,55 @@ +import path from 'path'; +import { execa } from 'execa'; +import { FileMissingError } from '@app/core/errors/file-missing-error'; +import { type LooseObject, type LooseStringObject } from '@app/core/types'; +import { PhpError } from '@app/core/errors/php-error'; + +/** + * Encode GET/POST params. + * + * @param params Keys/values to be encoded. + * @ignore + * @private + */ +const encodeParameters = (parameters: LooseObject) => + // Join query params together + Object.entries(parameters).map(kv => + // Encode each section and join + kv.map(encodeURIComponent).join('='), + ).join('&'); +interface Options { + /** File path */ + file: string; + /** HTTP Method GET/POST */ + method?: string; + /** Request query */ + query?: LooseStringObject; + /** Request body */ + body?: LooseObject; +} + +/** + * Load a PHP file. + */ +export const phpLoader = async (options: Options) => { + const { file, method = 'GET', query = {}, body = {} } = options; + const options_ = [ + './wrapper.php', + method, + `${file}${Object.keys(query).length >= 1 ? ('?' + encodeParameters(query)) : ''}`, + encodeParameters(body), + ]; + + return execa('php', options_, { cwd: __dirname }) + .then(({ stdout }) => { + // Missing php file + if (stdout.includes(`Warning: include(${file}): failed to open stream: No such file or directory in ${path.join(__dirname, '/wrapper.php')}`)) { + throw new FileMissingError(file); + } + + return stdout; + }) + .catch(error => { + throw new PhpError(error); + }); +}; diff --git a/api/src/core/utils/shares/get-shares.ts b/api/src/core/utils/shares/get-shares.ts index f792c1f7e..b5f2ac25c 100644 --- a/api/src/core/utils/shares/get-shares.ts +++ b/api/src/core/utils/shares/get-shares.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { processShare } from '@app/core/utils/shares/process-share'; import { AppError } from '@app/core/errors/app-error'; import { getters } from '@app/store'; diff --git a/api/src/core/utils/validation/has-fields.ts b/api/src/core/utils/validation/has-fields.ts new file mode 100644 index 000000000..86f582dc4 --- /dev/null +++ b/api/src/core/utils/validation/has-fields.ts @@ -0,0 +1,11 @@ +import { type LooseObject } from '@app/core/types'; + +/** +* Check if object has fields. +* @param obj Object to check fields on +* @param fields Fields to check +*/ +export const hasFields = (object: LooseObject, fields: string[]) => { + const keys = Object.keys(object); + return keys.length >= 1 ? fields.filter(field => !keys.includes(field)) : fields; +}; diff --git a/api/src/core/utils/vms/domain/sanitize-product.ts b/api/src/core/utils/vms/domain/sanitize-product.ts new file mode 100644 index 000000000..cfc0564ed --- /dev/null +++ b/api/src/core/utils/vms/domain/sanitize-product.ts @@ -0,0 +1,17 @@ +/** + * Sanitize vm product name. + * @param productName The product name to sanitize. + * @returns The sanitized product name. + */ +export const sanitizeProduct = (productName: string): string => { + if (productName === '') { + return ''; + } + + let product = productName; + + product = product.replace(' PCI Express', ' PCIe'); + product = product.replace(' High Definition ', ' HD '); + + return product; +}; diff --git a/api/src/core/utils/vms/domain/sanitize-vendor.ts b/api/src/core/utils/vms/domain/sanitize-vendor.ts new file mode 100644 index 000000000..3d91705cd --- /dev/null +++ b/api/src/core/utils/vms/domain/sanitize-vendor.ts @@ -0,0 +1,31 @@ +/** + * Sanitize vm vendor name. + * @param vendorName The vendor name to sanitize. + * @returns The sanitized vendor name. + */ +export const sanitizeVendor = (vendorName: string): string => { + if (vendorName === '') { + return ''; + } + + let vendor: string = vendorName; + + // Specialized vendor name cleanup + // e.g.: Advanced Micro Devices, Inc. [AMD/ATI] --> Advanced Micro Devices, Inc. + const regex = new RegExp(/(?.+) \[.+]/); + const match = regex.exec(vendor); + if (match?.groups?.gpuvendor) { + vendor = match.groups.gpuvendor; + } + + // Remove un-needed text + const junk = [' Corporation', ' Semiconductor ', ' Technology Group Ltd.', ' System, Inc.', ' Systems, Inc.', ' Co., Ltd.', ', Ltd.', ', Ltd', ', Inc.']; + junk.forEach(item => { + vendor = vendor.replace(item, ''); + }); + + vendor = vendor.replace('Advanced Micro Devices', 'AMD'); + vendor = vendor.replace('Samsung Electronics Co.', 'Samsung'); + + return vendor; +}; diff --git a/api/src/core/utils/vms/domain/vm-regexps.ts b/api/src/core/utils/vms/domain/vm-regexps.ts new file mode 100644 index 000000000..1331e147d --- /dev/null +++ b/api/src/core/utils/vms/domain/vm-regexps.ts @@ -0,0 +1,24 @@ +/** + * Disallowed device ids. +*/ +const disallowedClassId = /^(05|06|08|0a|0b|0c05)/; + +/** + * Allowed audio device ids. + */ +const allowedAudioClassId = /^(0403)/; + +/** + * Allowed GPU device ids. + */ +const allowedGpuClassId = /^(0001|03)/; + +/** + * VM RegExps. + * @note Class IDs come from the bottom of /usr/share/hwdata/pci.ids + */ +export const vmRegExps = { + disallowedClassId, + allowedAudioClassId, + allowedGpuClassId, +}; diff --git a/api/src/core/utils/vms/filter-devices.ts b/api/src/core/utils/vms/filter-devices.ts new file mode 100644 index 000000000..fd3289d12 --- /dev/null +++ b/api/src/core/utils/vms/filter-devices.ts @@ -0,0 +1,31 @@ +import { execa } from 'execa'; +import { map as asyncMap } from 'p-iteration'; +import { sync as commandExistsSync } from 'command-exists'; + +interface Device { + id: string; + allowed: boolean; +} + +/** + * Sets device.allowed to true/false. + * + * @param devices Devices to be checked. + * @returns Processed devices. + */ +export const filterDevices = async (devices: Device[]): Promise => asyncMap(devices, async (device: Device) => { + // Don't run if we don't have the udevadm command available + if (!commandExistsSync('udevadm')) return device; + + const networkDeviceIds = await execa('udevadm', 'info -q path -p /sys/class/net/eth0'.split(' ')) + .then(({ stdout }) => { + const regex = /0{4}:\w{2}:(\w{2}\.\w)/g; + return stdout.match(regex) ?? []; + }) + .catch(() => []); + + const allowed = new Set(networkDeviceIds); + device.allowed = allowed.has(device.id); + + return device; +}); diff --git a/api/src/core/utils/vms/get-hypervisor.ts b/api/src/core/utils/vms/get-hypervisor.ts index 7edd9a88a..bff5c020a 100644 --- a/api/src/core/utils/vms/get-hypervisor.ts +++ b/api/src/core/utils/vms/get-hypervisor.ts @@ -1,15 +1,8 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { access } from 'fs/promises'; import { constants } from 'fs'; -import path from 'path'; import { Hypervisor } from '@vmngr/libvirt'; import { libvirtLogger } from '@app/core/log'; -import { Exception } from 'bycontract'; const uri = process.env.LIBVIRT_URI ?? 'qemu:///system'; @@ -39,9 +32,6 @@ export const getHypervisor = async (): Promise => { throw new Error('Libvirt is not running'); } - - - hypervisor = new Hypervisor({ uri }); await hypervisor.connectOpen().catch((error: unknown) => { libvirtLogger.error( diff --git a/api/src/core/utils/vms/get-pci-devices.ts b/api/src/core/utils/vms/get-pci-devices.ts new file mode 100644 index 000000000..2c245480f --- /dev/null +++ b/api/src/core/utils/vms/get-pci-devices.ts @@ -0,0 +1,22 @@ +import { execa } from 'execa'; +import { type PciDevice } from '@app/core/types'; +import { cleanStdout } from '@app/core/utils/misc/clean-stdout'; + +const regex = new RegExp(/^(?\S+) "(?[^"]+) \[(?[a-f\d]{4})]" "(?[^"]+) \[(?[a-f\d]{4})]" "(?[^"]+) \[(?[a-f\d]{4})]"/); + +/** + * Get pci devices. + * + * @returns Array of PCI devices + */ +export const getPciDevices = async (): Promise => { + const devices = await execa('lspci', ['-m', '-nn']) + .catch(() => ({ stdout: '' })) + .then(cleanStdout); + + if (devices === '') { + return []; + } + + return devices.split('\n').map(line => (regex.exec(line)?.groups as unknown as PciDevice)).filter(Boolean); +}; diff --git a/api/src/core/utils/vms/parse-domain.ts b/api/src/core/utils/vms/parse-domain.ts new file mode 100644 index 000000000..85d25980c --- /dev/null +++ b/api/src/core/utils/vms/parse-domain.ts @@ -0,0 +1,50 @@ +import pProps from 'p-props'; +import { type Domain } from '@app/core/types'; +import { getHypervisor } from '@app/core/utils/vms/get-hypervisor'; + +export type DomainLookupType = 'id' | 'uuid' | 'name'; + +/** + * Parse domain + * + * @param type What lookup type to use. + * @param id The domain's ID, UUID or name. + * @private + */ +export const parseDomain = async (type: DomainLookupType, id: string): Promise => { + const types = { + id: 'lookupDomainByIdAsync', + uuid: 'lookupDomainByUUIDAsync', + name: 'lookupDomainByNameAsync', + }; + + if (!type || !Object.keys(types).includes(type)) { + throw new Error(`Type must be one of [${Object.keys(types).join(', ')}], ${type} given.`); + } + + const client = await getHypervisor(); + const method = types[type]; + const domain = await client[method](id); + const info = await domain.getInfoAsync(); + + const results = await pProps({ + uuid: domain.getUUIDAsync(), + osType: domain.getOSTypeAsync(), + autostart: domain.getAutostartAsync(), + maxMemory: domain.getMaxMemoryAsync(), + schedulerType: domain.getSchedulerTypeAsync(), + schedulerParameters: domain.getSchedulerParametersAsync(), + securityLabel: domain.getSecurityLabelAsync(), + name: domain.getNameAsync(), + ...info, + state: info.state.replace(' ', '_'), + }); + + if (info.state === 'running') { + results.vcpus = await domain.getVcpusAsync(); + results.memoryStats = await domain.getMemoryStatsAsync(); + } + + // @ts-expect-error fix pProps inferred type + return results; +}; diff --git a/api/src/core/utils/vms/parse-domains.ts b/api/src/core/utils/vms/parse-domains.ts new file mode 100644 index 000000000..18df1565c --- /dev/null +++ b/api/src/core/utils/vms/parse-domains.ts @@ -0,0 +1,7 @@ +import { type Domain } from '@app/core/types'; +import { type DomainLookupType, parseDomain } from '@app/core/utils/vms/parse-domain'; + +/** + * Parse domains. + */ +export const parseDomains = async (type: DomainLookupType, domains: string[]): Promise => Promise.all(domains.map(async domain => parseDomain(type, domain))); diff --git a/api/src/core/utils/vms/system-network-interfaces.ts b/api/src/core/utils/vms/system-network-interfaces.ts new file mode 100644 index 000000000..4d5bc2173 --- /dev/null +++ b/api/src/core/utils/vms/system-network-interfaces.ts @@ -0,0 +1,3 @@ +import { networkInterfaces } from 'systeminformation'; + +export const systemNetworkInterfaces = networkInterfaces(); diff --git a/api/src/graphql/index.ts b/api/src/graphql/index.ts new file mode 100644 index 000000000..b6c3d7ad2 --- /dev/null +++ b/api/src/graphql/index.ts @@ -0,0 +1,25 @@ +import { FatalAppError } from '@app/core/errors/fatal-error'; +import { graphqlLogger } from '@app/core/log'; +import { modules } from '@app/core'; +import { getters } from '@app/store'; + +export const getCoreModule = (moduleName: string) => { + if (!Object.keys(modules).includes(moduleName)) { + throw new FatalAppError(`"${moduleName}" is not a valid core module.`); + } + + return modules[moduleName]; +}; + +export const apiKeyToUser = async (apiKey: string) => { + try { + const config = getters.config(); + if (apiKey === config.remote.apikey) return { id: -1, description: 'My servers service account', name: 'my_servers', role: 'my_servers' }; + if (apiKey === config.upc.apikey) return { id: -1, description: 'UPC service account', name: 'upc', role: 'upc' }; + if (apiKey === config.notifier.apikey) return { id: -1, description: 'Notifier service account', name: 'notifier', role: 'notifier' }; + } catch (error: unknown) { + graphqlLogger.debug('Failed looking up API key with "%s"', (error as Error).message); + } + + return { id: -1, description: 'A guest user', name: 'guest', role: 'guest' }; +}; diff --git a/api/src/graphql/mothership/subscriptions.ts b/api/src/graphql/mothership/subscriptions.ts new file mode 100644 index 000000000..c6ff713d3 --- /dev/null +++ b/api/src/graphql/mothership/subscriptions.ts @@ -0,0 +1,53 @@ +import { graphql } from '@app/graphql/generated/client/gql'; + +export const RemoteGraphQL_Fragment = graphql(/* GraphQL */ ` + fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent { + remoteGraphQLEventData: data { + type + body + sha256 + } + } +`); + +export const RemoteAccess_Fragment = graphql(/* GraphQL */ ` + fragment RemoteAccessEventFragment on RemoteAccessEvent { + type + data { + type + url { + type + name + ipv4 + ipv6 + } + apiKey + } + } +`); + +export const EVENTS_SUBSCRIPTION = graphql(/* GraphQL */ ` + subscription events { + events { + __typename + ... on ClientConnectedEvent { + connectedData: data { + type + version + apiKey + } + connectedEvent: type + } + ... on ClientDisconnectedEvent { + disconnectedData: data { + type + version + apiKey + } + disconnectedEvent: type + } + ...RemoteAccessEventFragment + ...RemoteGraphQLEventFragment + } + } +`); diff --git a/api/src/graphql/resolvers/mutation/index.ts b/api/src/graphql/resolvers/mutation/index.ts new file mode 100644 index 000000000..5bafef3e1 --- /dev/null +++ b/api/src/graphql/resolvers/mutation/index.ts @@ -0,0 +1,6 @@ +import { type Resolvers } from '@app/graphql/generated/api/types'; +import { sendNotification } from './notifications'; + +export const Mutation: Resolvers['Mutation'] = { + sendNotification, +}; diff --git a/api/src/graphql/resolvers/query/online.ts b/api/src/graphql/resolvers/query/online.ts new file mode 100644 index 000000000..3c72b2d38 --- /dev/null +++ b/api/src/graphql/resolvers/query/online.ts @@ -0,0 +1 @@ +export default () => true; diff --git a/api/src/graphql/resolvers/query/servers.ts b/api/src/graphql/resolvers/query/servers.ts new file mode 100644 index 000000000..6746a5a10 --- /dev/null +++ b/api/src/graphql/resolvers/query/servers.ts @@ -0,0 +1,29 @@ +import { getServers } from '@app/graphql/schema/utils'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { ServerStatus, type Resolvers } from '../../generated/api/types'; + +export const servers: NonNullable['servers'] = async (_, __, context) => { + ensurePermission(context.user, { + resource: 'servers', + action: 'read', + possession: 'any', + }); + + // All servers + const servers = getServers().map(server => ({ + ...server, + apikey: server.apikey ?? '', + guid: server.guid ?? '', + lanip: server.lanip ?? '', + localurl: server.localurl ?? '', + wanip: server.wanip ?? '', + name: server.name ?? '', + owner: { + ...server.owner, + username: server.owner?.username ?? '' + }, + remoteurl: server.remoteurl ?? '', + status: server.status ?? ServerStatus.OFFLINE + })) + return servers; +}; diff --git a/api/src/graphql/resolvers/query/vms.ts b/api/src/graphql/resolvers/query/vms.ts new file mode 100644 index 000000000..a5da75931 --- /dev/null +++ b/api/src/graphql/resolvers/query/vms.ts @@ -0,0 +1 @@ +export const vmsResolver = () => ({}); diff --git a/api/src/graphql/resolvers/subscription/index.ts b/api/src/graphql/resolvers/subscription/index.ts index b27cf5bcb..25f9280ae 100644 --- a/api/src/graphql/resolvers/subscription/index.ts +++ b/api/src/graphql/resolvers/subscription/index.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub'; import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; import { type Resolvers } from '@app/graphql/generated/api/types'; diff --git a/api/src/graphql/resolvers/subscription/network.ts b/api/src/graphql/resolvers/subscription/network.ts index 8084cfe73..dbb8fd1ab 100644 --- a/api/src/graphql/resolvers/subscription/network.ts +++ b/api/src/graphql/resolvers/subscription/network.ts @@ -267,7 +267,6 @@ export const getServerIps = (state: RootState = store.getState()): { urls: Acces } return acc; }, []); - return { urls: safeUrls, errors }; }; diff --git a/api/src/graphql/resolvers/user-account.ts b/api/src/graphql/resolvers/user-account.ts new file mode 100644 index 000000000..1bda6a9d5 --- /dev/null +++ b/api/src/graphql/resolvers/user-account.ts @@ -0,0 +1,6 @@ +export const UserAccount = { + __resolveType(obj: Record) { + // Only a user has a password field, the current user aka "me" doesn't. + return obj.password ? 'User' : 'Me'; + }, +}; diff --git a/api/src/graphql/schema/index.ts b/api/src/graphql/schema/index.ts new file mode 100644 index 000000000..7e7bc86ac --- /dev/null +++ b/api/src/graphql/schema/index.ts @@ -0,0 +1,7 @@ +import { join } from 'path'; +import { loadFilesSync } from '@graphql-tools/load-files'; +import { mergeTypeDefs } from '@graphql-tools/merge'; + +const files = loadFilesSync(join(__dirname, '../src/graphql/schema/types'), { extensions: ['graphql'] }); + +export const typeDefs = mergeTypeDefs(files); diff --git a/api/src/graphql/schema/utils.ts b/api/src/graphql/schema/utils.ts new file mode 100644 index 000000000..6568cd6ed --- /dev/null +++ b/api/src/graphql/schema/utils.ts @@ -0,0 +1,77 @@ +import { hasSubscribedToChannel } from '@app/ws'; +import { type User } from '@app/core/types/states/user'; +import { AppError } from '@app/core/errors/app-error'; +import { ensurePermission } from '@app/core/utils/permissions/ensure-permission'; +import { pubsub } from '@app/core/pubsub'; +import { store } from '@app/store'; +import { + ServerStatus, + type Server, +} from '@app/graphql/generated/client/graphql'; +import { MinigraphStatus } from '@app/graphql/generated/api/types'; + +export interface Context { + user?: User; + websocketId: string; +} + +/** + * Create a pubsub subscription. + * @param channel The pubsub channel to subscribe to. + * @param resource The access-control permission resource to check against. + */ +export const createSubscription = (channel: string, resource?: string) => ({ + subscribe(_: unknown, __: unknown, context: Context) { + if (!context.user) { + throw new AppError(' No user found in context.', 500); + } + + // Check the user has permission to subscribe to this endpoint + ensurePermission(context.user, { + resource: resource ?? channel, + action: 'read', + possession: 'any', + }); + + hasSubscribedToChannel(context.websocketId, channel); + return pubsub.asyncIterator(channel); + }, +}); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const getLocalServer = (getState = store.getState): Array => { + const { emhttp, config, minigraph } = getState(); + const guid = emhttp.var.regGuid; + const { name } = emhttp.var; + const wanip = ''; + const lanip: string = emhttp.networks[0].ipaddr[0]; + const port = emhttp.var?.port; + const localurl = `http://${lanip}:${port}`; + const remoteurl = ''; + + return [ + { + owner: { + username: config.remote.username ?? 'root', + url: '', + avatar: '', + }, + guid, + apikey: config.remote.apikey ?? '', + name, + status: + minigraph.status === MinigraphStatus.CONNECTED + ? ServerStatus.ONLINE + : ServerStatus.OFFLINE, + wanip, + lanip, + localurl, + remoteurl, + }, + ]; +}; + +export const getServers = (getState = store.getState): Server[] => { + // Check if we have the servers already cached, if so return them + return getLocalServer(getState) ?? []; +}; diff --git a/api/src/index.ts b/api/src/index.ts index d3c818178..9704be8bc 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,7 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ import 'reflect-metadata'; import { am } from 'am'; import http from 'http'; diff --git a/api/src/mothership/jobs/api-key-check-jobs.ts b/api/src/mothership/jobs/api-key-check-jobs.ts new file mode 100644 index 000000000..d493aff7f --- /dev/null +++ b/api/src/mothership/jobs/api-key-check-jobs.ts @@ -0,0 +1,57 @@ +import { isAPIStateDataFullyLoaded } from '@app/mothership/graphql-client'; +import { keyServerLogger } from '@app/core/log'; +import { validateApiKeyWithKeyServer } from '@app/mothership/api-key/validate-api-key-with-keyserver'; +import { type RootState, type AppDispatch } from '@app/store/index'; +import { setApiKeyState } from '@app/store/modules/apikey'; +import { API_KEY_STATUS } from '@app/mothership/api-key/api-key-types'; +import { logoutUser } from '@app/store/modules/config'; +import { isApiKeyValid } from '@app/store/getters/index'; +import { isApiKeyCorrectLength } from '@app/mothership/api-key/is-api-key-correct-length'; +import { NODE_ENV } from '@app/environment'; + +export const apiKeyCheckJob = async (getState: () => RootState, dispatch: AppDispatch, count?: number): Promise => { + keyServerLogger.debug('Running keyserver validation number: %s', count); + const state = getState(); + if (state.apiKey.status === API_KEY_STATUS.NO_API_KEY) { + // Stop Job + return false; + } + + if (isAPIStateDataFullyLoaded(state)) { + if (isApiKeyValid(state)) { + return true; + } + + if (!isApiKeyCorrectLength(state.config.remote.apikey)) { + keyServerLogger.error('API Key has invalid length, logging you out.'); + await dispatch(logoutUser({ reason: 'API Key has invalid length' })); + return false; + } + + if (['development'].includes(NODE_ENV)) { + keyServerLogger.debug('In dev environment, marking API Key as Valid'); + dispatch(setApiKeyState(API_KEY_STATUS.API_KEY_VALID)); + return true; + } + + const validationResponse = await validateApiKeyWithKeyServer({ flashGuid: state.emhttp.var.flashGuid, apiKey: state.config.remote.apikey }); + switch (validationResponse) { + case API_KEY_STATUS.API_KEY_VALID: + keyServerLogger.info('Stopping API Key Job as Keyserver Marked API Key Valid'); + dispatch(setApiKeyState(validationResponse)); + return true; + case API_KEY_STATUS.API_KEY_INVALID: + await dispatch(logoutUser({ reason: 'Invalid API Key' })); + return false; + default: + keyServerLogger.info('Request failed with status:', validationResponse); + dispatch(setApiKeyState(validationResponse)); + throw new Error('Keyserver Failure, must retry'); + } + } else { + keyServerLogger.warn('State Data Has Not Fully Loaded, this should not be possible'); + dispatch(setApiKeyState(API_KEY_STATUS.NO_API_KEY)); + return false; + } +}; + diff --git a/api/src/remoteAccess/types.ts b/api/src/remoteAccess/types.ts new file mode 100644 index 000000000..ee7b76d6b --- /dev/null +++ b/api/src/remoteAccess/types.ts @@ -0,0 +1,5 @@ +export enum DynamicRemoteAccessType { + UPNP = 'UPNP', + STATIC = 'STATIC', + DISABLED = 'DISABLED', +} diff --git a/api/src/server.ts b/api/src/server.ts index 16aa54ae9..45bae4863 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,8 +1,3 @@ -/*! - * Copyright 2019-2022 Lime Technology Inc. All rights reserved. - * Written by: Alexis Tyler - */ - import path from 'path'; import cors from 'cors'; import { watch } from 'chokidar'; @@ -33,7 +28,6 @@ import { useServer } from 'graphql-ws/lib/use/ws'; import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from 'graphql-ws'; import { getLogs } from '@app/graphql/express/get-logs'; - const configFilePath = path.join( getters.paths()['dynamix-base'], 'case-model.cfg' diff --git a/api/src/store/actions/handle-remote-access-event.ts b/api/src/store/actions/handle-remote-access-event.ts new file mode 100644 index 000000000..128214f23 --- /dev/null +++ b/api/src/store/actions/handle-remote-access-event.ts @@ -0,0 +1,50 @@ +import { remoteAccessLogger } from '@app/core/log'; +import { RemoteAccessEventActionType, type RemoteAccessEventFragmentFragment } from '@app/graphql/generated/client/graphql'; +import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller'; +import { DynamicRemoteAccessType } from '@app/remoteAccess/types'; +import { type AppDispatch, type RootState } from '@app/store/index'; +import { setAllowedRemoteAccessUrls } from '@app/store/modules/dynamic-remote-access'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const handleRemoteAccessEvent = createAsyncThunk('dynamicRemoteAccess/handleRemoteAccessEvent', async (event, { getState, dispatch }) => { + const state = getState(); + const pluginApiKey = state.config.remote.apikey; + if (pluginApiKey !== event.data.apiKey) { + remoteAccessLogger.error('Remote Access Event Not For This Client'); + return; + } + + const { dynamicRemoteAccessType } = state.config.remote; + if (!dynamicRemoteAccessType || dynamicRemoteAccessType === DynamicRemoteAccessType.DISABLED) { + remoteAccessLogger.error('Received Remote Access Event, but Dynamic Remote Access is not enabled.'); + return; + } + + switch (event.data.type) { + case RemoteAccessEventActionType.INIT: + remoteAccessLogger.debug('Init Event'); + // Init - Begin listening, transmit an ACK event back from the client. + if (event.data.url) { + // @todo use this URL to set the only allowed access url + dispatch(setAllowedRemoteAccessUrls(event.data.url)); + } + + await RemoteAccessController.instance.beginRemoteAccess({ getState, dispatch }); + // @TODO Move this logic into the remote access manager class + + break; + case RemoteAccessEventActionType.ACK: + // Ack - these events come from the API (this client), so we don't need to respond + break; + case RemoteAccessEventActionType.PING: + // Ping - would continue remote access if necessary; + RemoteAccessController.instance.extendRemoteAccess({ getState, dispatch }); + break; + case RemoteAccessEventActionType.END: + // End + await RemoteAccessController.instance.stopRemoteAccess({ getState, dispatch }); + break; + default: + break; + } +}); diff --git a/api/src/store/modules/dynamix.ts b/api/src/store/modules/dynamix.ts index 4e1ca4c06..b53bcef09 100644 --- a/api/src/store/modules/dynamix.ts +++ b/api/src/store/modules/dynamix.ts @@ -16,8 +16,6 @@ export const initialState: Partial = { status: FileLoadStatus.UNLOADED, }; - - export const dynamix = createSlice({ name: 'dynamix', initialState, diff --git a/api/src/store/modules/emhttp.ts b/api/src/store/modules/emhttp.ts new file mode 100644 index 000000000..1d756a153 --- /dev/null +++ b/api/src/store/modules/emhttp.ts @@ -0,0 +1,197 @@ +import { FileLoadStatus, StateFileKey, type StateFileToIniParserMap } from '@app/store/types'; +import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import merge from 'lodash/merge'; +import { join } from 'path'; +import { emhttpLogger } from '@app/core/log'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; +import { type Devices } from '@app/core/types/states/devices'; +import { type Networks } from '@app/core/types/states/network'; +import { type Nginx } from '@app/core/types/states/nginx'; +import { type Shares } from '@app/core/types/states/share'; +import { type Users } from '@app/core/types/states/user'; +import { type NfsShares } from '@app/core/types/states/nfs'; +import { type SmbShares } from '@app/core/types/states/smb'; +import { type Var } from '@app/core/types/states/var'; +import { parse as parseDevices } from '@app/store/state-parsers/devices'; +import { parse as parseNetwork } from '@app/store/state-parsers/network'; +import { parse as parseNginx } from '@app/store/state-parsers/nginx'; +import { parse as parseNfsShares } from '@app/store/state-parsers/nfs'; +import { parse as parseShares } from '@app/store/state-parsers/shares'; +import { parse as parseSlots } from '@app/store/state-parsers/slots'; +import { parse as parseSmbShares } from '@app/store/state-parsers/smb'; +import { parse as parseUsers } from '@app/store/state-parsers/users'; +import { parse as parseVar } from '@app/store/state-parsers/var'; +import type { RootState } from '@app/store'; +import { type ArrayDisk } from '@app/graphql/generated/api/types'; + +export type SliceState = { + status: FileLoadStatus; + var: Var; + devices: Devices; + networks: Networks; + nginx: Nginx; + shares: Shares; + disks: ArrayDisk[]; + users: Users; + smbShares: SmbShares; + nfsShares: NfsShares; +}; + +const initialState: SliceState = { + status: FileLoadStatus.UNLOADED, + var: {} as unknown as Var, + devices: [], + networks: [], + nginx: {} as unknown as Nginx, + shares: [], + disks: [], + users: [], + smbShares: [], + nfsShares: [], +}; + +export const parsers: StateFileToIniParserMap = { + [StateFileKey.var]: parseVar, + [StateFileKey.devs]: parseDevices, + [StateFileKey.network]: parseNetwork, + [StateFileKey.nginx]: parseNginx, + [StateFileKey.shares]: parseShares, + [StateFileKey.disks]: parseSlots, + [StateFileKey.users]: parseUsers, + [StateFileKey.sec]: parseSmbShares, + [StateFileKey.sec_nfs]: parseNfsShares, +}; + +const getParserFunction = ( + parser: StateFileKey +): StateFileToIniParserMap[StateFileKey] => parsers[parser]; + +const parseState = < + T extends StateFileKey, + Q = ReturnType | null +>( + statesDirectory: string, + parser: T, + defaultValue?: NonNullable +): Q => { + const filePath = join(statesDirectory, `${parser}.ini`); + + try { + emhttpLogger.trace('Loading state file from "%s"', filePath); + const config = parseConfig[0]>({ + filePath, + type: 'ini', + }); + const parserFn = getParserFunction(parser); + // @TODO Not sure why this type doesn't work + return parserFn(config as unknown as any) as Q; + } catch (error: unknown) { + emhttpLogger.error( + 'Failed loading state file from "%s" with "%s"', + filePath, + error instanceof Error ? error.message : String(error) + ); + } + + if (defaultValue) { + return defaultValue as Q; + } + + return null as Q; +}; + +// @TODO Fix the type here Pick | null +export const loadSingleStateFile = createAsyncThunk< + any, + StateFileKey, + { state: RootState } +>('emhttp/load-single-state-file', async (stateFileKey, { getState }) => { + const path = getState().paths.states; + + const config = parseState(path, stateFileKey); + if (config) { + switch (stateFileKey) { + case StateFileKey.var: + return { var: config }; + case StateFileKey.devs: + return { devices: config }; + case StateFileKey.network: + return { networks: config }; + case StateFileKey.nginx: + return { nginx: config }; + case StateFileKey.shares: + return { shares: config }; + case StateFileKey.disks: + return { disks: config }; + case StateFileKey.users: + return { users: config }; + case StateFileKey.sec: + return { smbShares: config }; + case StateFileKey.sec_nfs: + return { nfsShares: config }; + default: + return null; + } + } else { + return null; + } +}); +/** + * Load the emhttp states into the store. + */ +export const loadStateFiles = createAsyncThunk< + Omit, + void, + { state: RootState } +>('emhttp/load-state-file', async (_, { getState }) => { + const path = getState().paths.states; + const state: Omit = { + var: parseState(path, StateFileKey.var, {} as Var), + devices: parseState(path, StateFileKey.devs, []), + networks: parseState(path, StateFileKey.network, []), + nginx: parseState(path, StateFileKey.nginx, {} as Nginx), + shares: parseState(path, StateFileKey.shares, []), + disks: parseState(path, StateFileKey.disks, []), + users: parseState(path, StateFileKey.users, []), + smbShares: parseState(path, StateFileKey.sec, []), + nfsShares: parseState(path, StateFileKey.sec_nfs, []), + }; + + return state; +}); + +export const emhttp = createSlice({ + name: 'emhttp', + initialState, + reducers: { + updateEmhttpState(state, action: PayloadAction<{ field: StateFileKey; state: Partial }>) { + const { field } = action.payload; + return merge(state, { [field]: action.payload.state }); + }, + }, + extraReducers(builder) { + builder.addCase(loadStateFiles.pending, (state) => { + state.status = FileLoadStatus.LOADING; + }); + + builder.addCase(loadStateFiles.fulfilled, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.LOADED }); + }); + + builder.addCase(loadStateFiles.rejected, (state, action) => { + merge(state, action.payload, { status: FileLoadStatus.FAILED_LOADING }); + }); + + builder.addCase(loadSingleStateFile.fulfilled, (state, action) => { + if (action.payload) { + // const changedKey = Object.keys(action.payload)[0] + // emhttpLogger.debug('Key', changedKey, 'Difference in changes', getDiff(action.payload, { [changedKey]: state[changedKey] } )) + merge(state, action.payload); + } else { + emhttpLogger.warn('Invalid payload returned from loadSingleStateFile()'); + } + }); + }, +}); + +export const { updateEmhttpState } = emhttp.actions; diff --git a/api/src/store/state-parsers/shares.ts b/api/src/store/state-parsers/shares.ts index eb366281b..6fd1d1c14 100644 --- a/api/src/store/state-parsers/shares.ts +++ b/api/src/store/state-parsers/shares.ts @@ -1,4 +1,3 @@ - import { toNumberOrNullConvert } from '@app/core/utils/casting'; import { type Share } from '@app/graphql/generated/api/types'; import type { StateFileToIniParserMap } from '@app/store/types'; diff --git a/api/src/store/types.ts b/api/src/store/types.ts index c5ab4a996..86b825c9c 100644 --- a/api/src/store/types.ts +++ b/api/src/store/types.ts @@ -18,7 +18,6 @@ import { type UsersIni } from './state-parsers/users'; import { type VarIni } from './state-parsers/var'; import { type Subscription } from 'zen-observable-ts'; - export enum FileLoadStatus { UNLOADED = 'UNLOADED', LOADING = 'LOADING',