mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
fix: remove some notices (#649)
This commit is contained in:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
6
api/src/core/bus.ts
Normal file
6
api/src/core/bus.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NanoBus from 'nanobus';
|
||||
|
||||
/**
|
||||
* Graphql event bus.
|
||||
*/
|
||||
export const bus = new NanoBus();
|
||||
11
api/src/core/errors/api-key-error.ts
Normal file
11
api/src/core/errors/api-key-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
api/src/core/errors/app-error.ts
Normal file
37
api/src/core/errors/app-error.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
10
api/src/core/errors/array-running-error.ts
Normal file
10
api/src/core/errors/array-running-error.ts
Normal file
@@ -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.');
|
||||
}
|
||||
}
|
||||
10
api/src/core/errors/atomic-write-error.ts
Normal file
10
api/src/core/errors/atomic-write-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
11
api/src/core/errors/em-cmd-error.ts
Normal file
11
api/src/core/errors/em-cmd-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
8
api/src/core/errors/fatal-error.ts
Normal file
8
api/src/core/errors/fatal-error.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
|
||||
/**
|
||||
* Fatal application error.
|
||||
*/
|
||||
export class FatalAppError extends AppError {
|
||||
fatal = true;
|
||||
}
|
||||
11
api/src/core/errors/field-missing-error.ts
Normal file
11
api/src/core/errors/field-missing-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
api/src/core/errors/file-missing-error.ts
Normal file
14
api/src/core/errors/file-missing-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
11
api/src/core/errors/not-implemented-error.ts
Normal file
11
api/src/core/errors/not-implemented-error.ts
Normal file
@@ -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!');
|
||||
}
|
||||
}
|
||||
12
api/src/core/errors/param-invalid-error.ts
Normal file
12
api/src/core/errors/param-invalid-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
11
api/src/core/errors/param-missing-error.ts
Normal file
11
api/src/core/errors/param-missing-error.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
10
api/src/core/errors/permission-error.ts
Normal file
10
api/src/core/errors/permission-error.ts
Normal file
@@ -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!');
|
||||
}
|
||||
}
|
||||
6
api/src/core/errors/php-error.ts
Normal file
6
api/src/core/errors/php-error.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AppError } from '@app/core/errors/app-error';
|
||||
|
||||
/**
|
||||
* Error bubbled up from a PHP script.
|
||||
*/
|
||||
export class PhpError extends AppError {}
|
||||
@@ -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');
|
||||
|
||||
126
api/src/core/modules/add-license-key.ts
Normal file
126
api/src/core/modules/add-license-key.ts
Normal file
@@ -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<Result | void> => {
|
||||
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
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
};
|
||||
38
api/src/core/modules/add-share.ts
Normal file
38
api/src/core/modules/add-share.ts
Normal file
@@ -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<unknown, { name: string }>): Promise<CoreResult> => {
|
||||
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();
|
||||
};
|
||||
84
api/src/core/modules/add-user.ts
Normal file
84
api/src/core/modules/add-user.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
49
api/src/core/modules/array/add-disk-to-array.ts
Normal file
49
api/src/core/modules/array/add-disk-to-array.ts
Normal file
@@ -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<CoreResult> {
|
||||
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,
|
||||
};
|
||||
};
|
||||
54
api/src/core/modules/array/remove-disk-from-array.ts
Normal file
54
api/src/core/modules/array/remove-disk-from-array.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
88
api/src/core/modules/array/update-array.ts
Normal file
88
api/src/core/modules/array/update-array.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
78
api/src/core/modules/array/update-parity-check.ts
Normal file
78
api/src/core/modules/array/update-parity-check.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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: {},
|
||||
};
|
||||
};
|
||||
10
api/src/core/modules/debug/get-context.ts
Normal file
10
api/src/core/modules/debug/get-context.ts
Normal file
@@ -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: `<h1>Context</h1>\n<pre>${JSON.stringify(context, null, 2)}</pre>`,
|
||||
});
|
||||
35
api/src/core/modules/disks/id/get-disk.ts
Normal file
35
api/src/core/modules/disks/id/get-disk.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
33
api/src/core/modules/docker/get-docker-networks.ts
Normal file
33
api/src/core/modules/docker/get-docker-networks.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
30
api/src/core/modules/get-all-shares.ts
Normal file
30
api/src/core/modules/get-all-shares.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
13
api/src/core/modules/get-apps.ts
Normal file
13
api/src/core/modules/get-apps.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CoreResult } from '@app/core/types';
|
||||
|
||||
/**
|
||||
* Get all apps.
|
||||
*/
|
||||
export const getApps = async (): Promise<CoreResult> => {
|
||||
const apps = [];
|
||||
|
||||
return {
|
||||
text: `Apps: ${JSON.stringify(apps, null, 2)}`,
|
||||
json: apps,
|
||||
};
|
||||
};
|
||||
29
api/src/core/modules/get-devices.ts
Normal file
29
api/src/core/modules/get-devices.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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: {},
|
||||
};
|
||||
};
|
||||
121
api/src/core/modules/get-disks.ts
Normal file
121
api/src/core/modules/get-disks.ts
Normal file
@@ -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<number> => {
|
||||
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<Disk> => {
|
||||
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<Disk[]> => {
|
||||
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;
|
||||
};
|
||||
19
api/src/core/modules/get-me.ts
Normal file
19
api/src/core/modules/get-me.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
59
api/src/core/modules/get-parity-history.ts
Normal file
59
api/src/core/modules/get-parity-history.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
51
api/src/core/modules/get-permissions.ts
Normal file
51
api/src/core/modules/get-permissions.ts
Normal file
@@ -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<CoreResult> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
28
api/src/core/modules/get-unassigned-devices.ts
Normal file
28
api/src/core/modules/get-unassigned-devices.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
50
api/src/core/modules/get-users.ts
Normal file
50
api/src/core/modules/get-users.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
26
api/src/core/modules/get-vars.ts
Normal file
26
api/src/core/modules/get-vars.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
28
api/src/core/modules/get-welcome.ts
Normal file
28
api/src/core/modules/get-welcome.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
41
api/src/core/modules/services/get-emhttpd.ts
Normal file
41
api/src/core/modules/services/get-emhttpd.ts
Normal file
@@ -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<Result> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
48
api/src/core/modules/services/get-unraid-api.ts
Normal file
48
api/src/core/modules/services/get-unraid-api.ts
Normal file
@@ -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<Result> => {
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
150
api/src/core/modules/settings/update-disk.ts
Normal file
150
api/src/core/modules/settings/update-disk.ts
Normal file
@@ -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<Result> => {
|
||||
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, string> | 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,
|
||||
};
|
||||
};
|
||||
47
api/src/core/modules/shares/name/get-share.ts
Normal file
47
api/src/core/modules/shares/name/get-share.ts
Normal file
@@ -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<Result> {
|
||||
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,
|
||||
};
|
||||
};
|
||||
46
api/src/core/modules/users/id/add-role.ts
Normal file
46
api/src/core/modules/users/id/add-role.ts
Normal file
@@ -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<CoreResult> => {
|
||||
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.',
|
||||
};
|
||||
};
|
||||
51
api/src/core/modules/users/id/delete-user.ts
Normal file
51
api/src/core/modules/users/id/delete-user.ts
Normal file
@@ -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<CoreResult> => {
|
||||
// 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.',
|
||||
};
|
||||
};
|
||||
44
api/src/core/modules/users/id/get-user.ts
Normal file
44
api/src/core/modules/users/id/get-user.ts
Normal file
@@ -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<CoreResult> => {
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
74
api/src/core/modules/vms/get-domains.ts
Normal file
74
api/src/core/modules/vms/get-domains.ts
Normal file
@@ -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<VmDomain> = 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'}`);
|
||||
}
|
||||
};
|
||||
30
api/src/core/notifiers/console.ts
Normal file
30
api/src/core/notifiers/console.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
16
api/src/core/notifiers/http.ts
Normal file
16
api/src/core/notifiers/http.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
58
api/src/core/notifiers/notifier.ts
Normal file
58
api/src/core/notifiers/notifier.ts
Normal file
@@ -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<string, unknown>;
|
||||
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));
|
||||
}
|
||||
}
|
||||
54
api/src/core/permission-manager.ts
Normal file
54
api/src/core/permission-manager.ts
Normal file
@@ -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();
|
||||
33
api/src/core/permissions.ts
Normal file
33
api/src/core/permissions.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
18
api/src/core/types/pci-device.ts
Normal file
18
api/src/core/types/pci-device.ts
Normal file
@@ -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;
|
||||
}
|
||||
33
api/src/core/types/states/network.ts
Normal file
33
api/src/core/types/states/network.ts
Normal file
@@ -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[];
|
||||
32
api/src/core/types/states/share.ts
Normal file
32
api/src/core/types/states/share.ts
Normal file
@@ -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';
|
||||
13
api/src/core/types/states/user.ts
Normal file
13
api/src/core/types/states/user.ts
Normal file
@@ -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[];
|
||||
10
api/src/core/utils/array/array-is-running.ts
Normal file
10
api/src/core/utils/array/array-is-running.ts
Normal file
@@ -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;
|
||||
};
|
||||
23
api/src/core/utils/authentication/check-auth.ts
Normal file
23
api/src/core/utils/authentication/check-auth.ts
Normal file
@@ -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<unknown> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
28
api/src/core/utils/authentication/ensure-auth.ts
Normal file
28
api/src/core/utils/authentication/ensure-auth.ts
Normal file
@@ -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!');
|
||||
}
|
||||
};
|
||||
11
api/src/core/utils/clients/docker.ts
Normal file
11
api/src/core/utils/clients/docker.ts
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
13
api/src/core/utils/files/file-exists.ts
Normal file
13
api/src/core/utils/files/file-exists.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
4
api/src/core/utils/misc/add-together.ts
Normal file
4
api/src/core/utils/misc/add-together.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Add all numbers in array together.
|
||||
*/
|
||||
export const addTogether = (array: number[]) => array.reduce((a, b) => (a + b), 0);
|
||||
8
api/src/core/utils/misc/atomic-sleep.ts
Normal file
8
api/src/core/utils/misc/atomic-sleep.ts
Normal file
@@ -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<any> => new Promise<void>(resolve => {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
resolve();
|
||||
});
|
||||
29
api/src/core/utils/misc/catch-handlers.ts
Normal file
29
api/src/core/utils/misc/catch-handlers.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
9
api/src/core/utils/misc/clean-stdout.ts
Normal file
9
api/src/core/utils/misc/clean-stdout.ts
Normal file
@@ -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();
|
||||
33
api/src/core/utils/misc/exit-app.ts
Normal file
33
api/src/core/utils/misc/exit-app.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
23
api/src/core/utils/misc/global-error-handler.ts
Normal file
23
api/src/core/utils/misc/global-error-handler.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
28
api/src/core/utils/misc/load-state.ts
Normal file
28
api/src/core/utils/misc/load-state.ts
Normal file
@@ -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 = <T extends Record<string, unknown>>(filePath: string): T | undefined => {
|
||||
try {
|
||||
const config = camelCaseKeys(parseConfig<T>({
|
||||
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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
6
api/src/core/utils/misc/remove-duplicates-from-array.ts
Normal file
6
api/src/core/utils/misc/remove-duplicates-from-array.ts
Normal file
@@ -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 = <T>(array: T[], prop: string): T[] => array.filter((object, pos, array_) => array_.map(mapObject => mapObject[prop].indexOf(object[prop]) === pos));
|
||||
9
api/src/core/utils/misc/sleep.ts
Normal file
9
api/src/core/utils/misc/sleep.ts
Normal file
@@ -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<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
4
api/src/core/utils/misc/uppercase-first-char.ts
Normal file
4
api/src/core/utils/misc/uppercase-first-char.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Uppercase first char of string.
|
||||
*/
|
||||
export const uppercaseFirstChar = (string: string) => string.charAt(0).toUpperCase() + string.slice(1);
|
||||
33
api/src/core/utils/permissions/check-permission.ts
Normal file
33
api/src/core/utils/permissions/check-permission.ts
Normal file
@@ -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;
|
||||
};
|
||||
30
api/src/core/utils/permissions/ensure-permission.ts
Normal file
30
api/src/core/utils/permissions/ensure-permission.ts
Normal file
@@ -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}".`);
|
||||
};
|
||||
13
api/src/core/utils/permissions/get-permissions.ts
Normal file
13
api/src/core/utils/permissions/get-permissions.ts
Normal file
@@ -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<string, Record<string, string[]>> => {
|
||||
const grants: Record<string, Record<string, string[]>> = 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);
|
||||
};
|
||||
55
api/src/core/utils/plugins/php-loader.ts
Normal file
55
api/src/core/utils/plugins/php-loader.ts
Normal file
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
11
api/src/core/utils/validation/has-fields.ts
Normal file
11
api/src/core/utils/validation/has-fields.ts
Normal file
@@ -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;
|
||||
};
|
||||
17
api/src/core/utils/vms/domain/sanitize-product.ts
Normal file
17
api/src/core/utils/vms/domain/sanitize-product.ts
Normal file
@@ -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;
|
||||
};
|
||||
31
api/src/core/utils/vms/domain/sanitize-vendor.ts
Normal file
31
api/src/core/utils/vms/domain/sanitize-vendor.ts
Normal file
@@ -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(/(?<gpuvendor>.+) \[.+]/);
|
||||
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;
|
||||
};
|
||||
24
api/src/core/utils/vms/domain/vm-regexps.ts
Normal file
24
api/src/core/utils/vms/domain/vm-regexps.ts
Normal file
@@ -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,
|
||||
};
|
||||
31
api/src/core/utils/vms/filter-devices.ts
Normal file
31
api/src/core/utils/vms/filter-devices.ts
Normal file
@@ -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<Device[]> => 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;
|
||||
});
|
||||
@@ -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<Hypervisor> => {
|
||||
throw new Error('Libvirt is not running');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
hypervisor = new Hypervisor({ uri });
|
||||
await hypervisor.connectOpen().catch((error: unknown) => {
|
||||
libvirtLogger.error(
|
||||
|
||||
22
api/src/core/utils/vms/get-pci-devices.ts
Normal file
22
api/src/core/utils/vms/get-pci-devices.ts
Normal file
@@ -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(/^(?<id>\S+) "(?<type>[^"]+) \[(?<typeid>[a-f\d]{4})]" "(?<vendorname>[^"]+) \[(?<vendorid>[a-f\d]{4})]" "(?<productname>[^"]+) \[(?<productid>[a-f\d]{4})]"/);
|
||||
|
||||
/**
|
||||
* Get pci devices.
|
||||
*
|
||||
* @returns Array of PCI devices
|
||||
*/
|
||||
export const getPciDevices = async (): Promise<PciDevice[]> => {
|
||||
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);
|
||||
};
|
||||
50
api/src/core/utils/vms/parse-domain.ts
Normal file
50
api/src/core/utils/vms/parse-domain.ts
Normal file
@@ -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<Domain> => {
|
||||
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;
|
||||
};
|
||||
7
api/src/core/utils/vms/parse-domains.ts
Normal file
7
api/src/core/utils/vms/parse-domains.ts
Normal file
@@ -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<Domain[]> => Promise.all(domains.map(async domain => parseDomain(type, domain)));
|
||||
3
api/src/core/utils/vms/system-network-interfaces.ts
Normal file
3
api/src/core/utils/vms/system-network-interfaces.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { networkInterfaces } from 'systeminformation';
|
||||
|
||||
export const systemNetworkInterfaces = networkInterfaces();
|
||||
25
api/src/graphql/index.ts
Normal file
25
api/src/graphql/index.ts
Normal file
@@ -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' };
|
||||
};
|
||||
53
api/src/graphql/mothership/subscriptions.ts
Normal file
53
api/src/graphql/mothership/subscriptions.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
6
api/src/graphql/resolvers/mutation/index.ts
Normal file
6
api/src/graphql/resolvers/mutation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type Resolvers } from '@app/graphql/generated/api/types';
|
||||
import { sendNotification } from './notifications';
|
||||
|
||||
export const Mutation: Resolvers['Mutation'] = {
|
||||
sendNotification,
|
||||
};
|
||||
1
api/src/graphql/resolvers/query/online.ts
Normal file
1
api/src/graphql/resolvers/query/online.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default () => true;
|
||||
29
api/src/graphql/resolvers/query/servers.ts
Normal file
29
api/src/graphql/resolvers/query/servers.ts
Normal file
@@ -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<Resolvers['Query']>['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;
|
||||
};
|
||||
1
api/src/graphql/resolvers/query/vms.ts
Normal file
1
api/src/graphql/resolvers/query/vms.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const vmsResolver = () => ({});
|
||||
@@ -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';
|
||||
|
||||
@@ -267,7 +267,6 @@ export const getServerIps = (state: RootState = store.getState()): { urls: Acces
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
|
||||
return { urls: safeUrls, errors };
|
||||
};
|
||||
|
||||
6
api/src/graphql/resolvers/user-account.ts
Normal file
6
api/src/graphql/resolvers/user-account.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const UserAccount = {
|
||||
__resolveType(obj: Record<string, unknown>) {
|
||||
// Only a user has a password field, the current user aka "me" doesn't.
|
||||
return obj.password ? 'User' : 'Me';
|
||||
},
|
||||
};
|
||||
7
api/src/graphql/schema/index.ts
Normal file
7
api/src/graphql/schema/index.ts
Normal file
@@ -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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user