fix: remove some notices (#649)

This commit is contained in:
Eli Bosley
2023-08-09 12:10:02 -04:00
committed by GitHub
parent 2f5c690bd6
commit 2e259ed677
110 changed files with 3085 additions and 67 deletions

View File

@@ -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: [
{

View File

@@ -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
View File

@@ -0,0 +1,6 @@
import NanoBus from 'nanobus';
/**
* Graphql event bus.
*/
export const bus = new NanoBus();

View 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);
}
}

View 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,
},
};
}
}

View 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.');
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,8 @@
import { AppError } from '@app/core/errors/app-error';
/**
* Fatal application error.
*/
export class FatalAppError extends AppError {
fatal = true;
}

View 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);
}
}

View 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);
}
}

View 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!');
}
}

View 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);
}
}

View 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);
}
}

View 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!');
}
}

View 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 {}

View File

@@ -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');

View 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
// }
// };
// }
};

View 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();
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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,
},
};
};

View 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: {},
};
};

View 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>`,
});

View 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,
};
};

View File

@@ -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';

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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: {},
};
};

View 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;
};

View 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,
};
};

View 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,
};
};

View 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,
},
};
};

View File

@@ -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';

View 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,
};
};

View 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,
};
};

View 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,
},
};
};

View 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,
},
};
};

View 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,
},
};
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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.',
};
};

View 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.',
};
};

View 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,
};
};

View 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'}`);
}
};

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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();

View 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,
};

View File

@@ -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';

View 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;
}

View 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[];

View 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';

View 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[];

View 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;
};

View 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,
});
};

View 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!');
}
};

View 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;

View File

@@ -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';

View 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;
}
};

View File

@@ -0,0 +1,4 @@
/**
* Add all numbers in array together.
*/
export const addTogether = (array: number[]) => array.reduce((a, b) => (a + b), 0);

View 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();
});

View 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;
},
};

View 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();

View 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;
}
};

View 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;
}
};

View 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;
};

View File

@@ -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';

View 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));

View 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);
});

View File

@@ -0,0 +1,4 @@
/**
* Uppercase first char of string.
*/
export const uppercaseFirstChar = (string: string) => string.charAt(0).toUpperCase() + string.slice(1);

View 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;
};

View 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}".`);
};

View 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);
};

View 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);
});
};

View File

@@ -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';

View 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;
};

View 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;
};

View 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;
};

View 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,
};

View 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;
});

View File

@@ -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(

View 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);
};

View 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;
};

View 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)));

View File

@@ -0,0 +1,3 @@
import { networkInterfaces } from 'systeminformation';
export const systemNetworkInterfaces = networkInterfaces();

25
api/src/graphql/index.ts Normal file
View 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' };
};

View 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
}
}
`);

View File

@@ -0,0 +1,6 @@
import { type Resolvers } from '@app/graphql/generated/api/types';
import { sendNotification } from './notifications';
export const Mutation: Resolvers['Mutation'] = {
sendNotification,
};

View File

@@ -0,0 +1 @@
export default () => true;

View 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;
};

View File

@@ -0,0 +1 @@
export const vmsResolver = () => ({});

View File

@@ -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';

View File

@@ -267,7 +267,6 @@ export const getServerIps = (state: RootState = store.getState()): { urls: Acces
}
return acc;
}, []);
return { urls: safeUrls, errors };
};

View 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';
},
};

View 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