mirror of
https://github.com/unraid/api.git
synced 2026-01-07 00:59:48 -06:00
feat: move external plugins into api
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { config } from '@unraid/core';
|
||||
|
||||
const internalWsAddress = () => {
|
||||
const port = config.get('node-api-port');
|
||||
const port = config.get('port');
|
||||
return isNaN(port as any)
|
||||
// Unix Socket
|
||||
? `ws+unix:${port}`
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
import get from 'lodash.get';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as core from '@unraid/core';
|
||||
import { apiManager, errors, log, states, config, pluginManager, modules } from '@unraid/core';
|
||||
import { bus, apiManager, errors, log, states, config, pluginManager, modules } from '@unraid/core';
|
||||
import { makeExecutableSchema, SchemaDirectiveVisitor } from 'graphql-tools';
|
||||
import { mergeTypes } from 'merge-graphql-schemas';
|
||||
import gql from 'graphql-tag';
|
||||
import { typeDefs, resolvers } from './schema';
|
||||
import dee from '@gridplus/docker-events';
|
||||
import { setIntervalAsync } from 'set-interval-async/dynamic';
|
||||
import { run, publish } from '../run';
|
||||
import { typeDefs } from './schema';
|
||||
import * as resolvers from './resolvers';
|
||||
import { wsHasConnected, wsHasDisconnected } from '../ws';
|
||||
|
||||
const { AppError, FatalAppError, PluginError } = errors;
|
||||
@@ -257,23 +261,19 @@ const schema = makeExecutableSchema({
|
||||
});
|
||||
|
||||
const ensureApiKey = (apiKeyToCheck: string) => {
|
||||
try {
|
||||
// Check there is atleast one valid key
|
||||
if (core.apiManager.getValidKeys().length !== 0) {
|
||||
if (!apiKeyToCheck) {
|
||||
throw new AppError('Missing API key.');
|
||||
}
|
||||
|
||||
if (!apiManager.isValid(apiKeyToCheck)) {
|
||||
throw new AppError('Invalid API key.');
|
||||
}
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new AppError('No valid API keys active.');
|
||||
}
|
||||
// Check there is atleast one valid key
|
||||
if (core.apiManager.getValidKeys().length !== 0) {
|
||||
if (!apiKeyToCheck) {
|
||||
throw new AppError('Missing API key.');
|
||||
}
|
||||
|
||||
if (!apiManager.isValid(apiKeyToCheck)) {
|
||||
throw new AppError('Invalid API key.');
|
||||
}
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new AppError('No valid API keys active.');
|
||||
}
|
||||
} catch {
|
||||
throw new AppError('Invalid Api key.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -295,6 +295,69 @@ const apiKeyToUser = (apiKey: string) => {
|
||||
return { name: 'guest', role: 'guest' };
|
||||
};
|
||||
|
||||
// Update array values when slots change
|
||||
bus.on('slots', async () => {
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
await run('array', 'UPDATED', {
|
||||
moduleToRun: modules.getArray,
|
||||
context: {
|
||||
user
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update info/hostname when hostname changes
|
||||
bus.on('varstate', async (data) => {
|
||||
const hostname = data.varstate.node.name;
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
if (user) {
|
||||
publish('info', 'UPDATED', {
|
||||
os: {
|
||||
hostname
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// On Docker event update info with { apps: { installed, started } }
|
||||
dee.on('*', async (data: { Type: string }) => {
|
||||
// Only listen to container events
|
||||
if (data.Type !== 'container') {
|
||||
return;
|
||||
}
|
||||
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
if (user) {
|
||||
const { json } = await modules.getAppCount({
|
||||
user
|
||||
});
|
||||
publish('info', 'UPDATED', {
|
||||
apps: json
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dee.listen();
|
||||
|
||||
// This needs to be fixed to run from events
|
||||
setIntervalAsync(async () => {
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
await run('services', 'UPDATED', {
|
||||
moduleToRun: modules.getServices,
|
||||
context: {
|
||||
user
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
export const graphql = {
|
||||
introspection: debug,
|
||||
playground: debug ? {
|
||||
@@ -359,6 +422,7 @@ export const graphql = {
|
||||
if (req) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const user = apiKeyToUser(apiKey);
|
||||
|
||||
return {
|
||||
user
|
||||
};
|
||||
|
||||
21
app/graphql/resolvers/index.ts
Normal file
21
app/graphql/resolvers/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import GraphQLLong from 'graphql-type-long';
|
||||
import GraphQLUUID from 'graphql-type-uuid';
|
||||
import { Query } from './query';
|
||||
import { Subscription } from './subscription';
|
||||
import { UserAccount } from './user-account';
|
||||
|
||||
export const JSON = GraphQLJSON;
|
||||
export const Long = GraphQLLong;
|
||||
export const UUID = GraphQLUUID;
|
||||
|
||||
export {
|
||||
Query,
|
||||
Subscription,
|
||||
UserAccount
|
||||
};
|
||||
154
app/graphql/resolvers/query/display.ts
Normal file
154
app/graphql/resolvers/query/display.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { promises as fs, statSync, existsSync } from 'fs';
|
||||
import { paths, log } from '@unraid/core';
|
||||
|
||||
// Consts
|
||||
const ONE_BYTE = 1;
|
||||
const ONE_KILOBYTE = ONE_BYTE * 1000;
|
||||
const ONE_MEGABYTE = ONE_KILOBYTE * 1000;
|
||||
const FIVE_MEGABYTE = ONE_MEGABYTE * 5;
|
||||
|
||||
const isOverFileSizeLimit = (filePath, limit = FIVE_MEGABYTE) => {
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
const fileSizeInBytes = stats.size;
|
||||
return fileSizeInBytes > limit;
|
||||
} catch {
|
||||
// File likely doesn't exist or there was another error
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const states = {
|
||||
// Success
|
||||
custom: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: '',
|
||||
base64: ''
|
||||
},
|
||||
default: {
|
||||
url: '',
|
||||
icon: 'default',
|
||||
error: '',
|
||||
base64: ''
|
||||
},
|
||||
|
||||
// Errors
|
||||
couldNotReadConfigFile: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-config-file',
|
||||
base64: ''
|
||||
},
|
||||
couldNotReadImage: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'could-not-read-image',
|
||||
base64: ''
|
||||
},
|
||||
imageMissing: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-missing',
|
||||
base64: ''
|
||||
},
|
||||
imageTooBig: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-too-big',
|
||||
base64: ''
|
||||
},
|
||||
imageCorrupt: {
|
||||
url: '',
|
||||
icon: 'custom',
|
||||
error: 'image-corrupt',
|
||||
base64: ''
|
||||
}
|
||||
};
|
||||
|
||||
export default async () => {
|
||||
const dynamixBasePath = paths.get('dynamix-base')!;
|
||||
const configFilePath = join(dynamixBasePath, 'case-model.cfg');
|
||||
const customImageFilePath = join(dynamixBasePath, 'case-model.png');
|
||||
|
||||
// If the config file doesn't exist then it's a new OS install
|
||||
// Default to "default"
|
||||
if (!existsSync(configFilePath)) {
|
||||
return { case: states.default };
|
||||
}
|
||||
|
||||
// Attempt to get case from file
|
||||
const serverCase = await fs.readFile(configFilePath)
|
||||
.then(buffer => buffer.toString().split('\n')[0])
|
||||
.catch(() => 'error_reading_config_file');
|
||||
|
||||
// Config file can't be read, maybe a permissions issue?
|
||||
if (serverCase === 'error_reading_config_file') {
|
||||
return { case: states.couldNotReadConfigFile };
|
||||
}
|
||||
|
||||
// Custom icon
|
||||
if (serverCase.includes('.')) {
|
||||
// Ensure image exists
|
||||
if (!existsSync(customImageFilePath)) {
|
||||
return { case: states.imageMissing };
|
||||
}
|
||||
|
||||
// Ensure we're within size limits for the proxy
|
||||
if (isOverFileSizeLimit(customImageFilePath)) {
|
||||
log.debug('"custom-case.png" is too big to send to mothership.');
|
||||
return { case: states.imageTooBig };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get image buffer
|
||||
const fileBuffer = await fs.readFile(customImageFilePath);
|
||||
|
||||
// Likely not an actual image
|
||||
// 73 bytes is close to the smallest we can get https://garethrees.org/2007/11/14/pngcrush/
|
||||
if (fileBuffer.length <= 25) {
|
||||
return {
|
||||
case: states.couldNotReadImage
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
case: {
|
||||
...states.custom,
|
||||
base64: fileBuffer.toString('base64'),
|
||||
url: serverCase
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
case: states.couldNotReadImage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blank cfg file?
|
||||
if (serverCase.trim().length === 0) {
|
||||
return {
|
||||
case: states.default
|
||||
};
|
||||
}
|
||||
|
||||
// Non-custom icon
|
||||
return {
|
||||
case: {
|
||||
...states.default,
|
||||
icon: serverCase
|
||||
}
|
||||
};
|
||||
};
|
||||
19
app/graphql/resolvers/query/index.ts
Normal file
19
app/graphql/resolvers/query/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
import display from './display';
|
||||
import info from './info';
|
||||
import online from './online';
|
||||
import server from './server';
|
||||
import servers from './servers';
|
||||
import vms from './vms';
|
||||
|
||||
export const Query = {
|
||||
display,
|
||||
info,
|
||||
online,
|
||||
vms,
|
||||
server,
|
||||
servers
|
||||
};
|
||||
1
app/graphql/resolvers/query/info.ts
Normal file
1
app/graphql/resolvers/query/info.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default () => ({});
|
||||
6
app/graphql/resolvers/query/online.ts
Normal file
6
app/graphql/resolvers/query/online.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
export default () => true;
|
||||
23
app/graphql/resolvers/query/server.ts
Normal file
23
app/graphql/resolvers/query/server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { utils } from '@unraid/core';
|
||||
import type { Context } from '../../schema/utils';
|
||||
import { getServers } from '../../schema/utils';
|
||||
|
||||
const { ensurePermission } = utils;
|
||||
|
||||
export default async (_: unknown, { name }, context: Context) => {
|
||||
ensurePermission(context.user, {
|
||||
resource: 'servers',
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
const servers = await getServers().catch(() => []);
|
||||
|
||||
// Single server
|
||||
return servers.find(server => server.name === name);
|
||||
};
|
||||
21
app/graphql/resolvers/query/servers.ts
Normal file
21
app/graphql/resolvers/query/servers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { utils } from '@unraid/core';
|
||||
import type { Context } from '../../schema/utils';
|
||||
import { getServers } from '../../schema/utils';
|
||||
|
||||
const { ensurePermission } = utils;
|
||||
|
||||
export default (_: unknown, __: unknown, context: Context) => {
|
||||
ensurePermission(context.user, {
|
||||
resource: 'servers',
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
// All servers
|
||||
return getServers().catch(() => []);
|
||||
};
|
||||
6
app/graphql/resolvers/query/vms.ts
Normal file
6
app/graphql/resolvers/query/vms.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
export default () => ({});
|
||||
86
app/graphql/resolvers/subscription/index.ts
Normal file
86
app/graphql/resolvers/subscription/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { pluginManager, pubsub, errors } from '@unraid/core';
|
||||
import { hasSubscribedToChannel } from '../../../ws';
|
||||
import { createSubscription, Context } from '../../schema/utils';
|
||||
|
||||
const { PluginError } = errors;
|
||||
|
||||
export const Subscription = {
|
||||
display: {
|
||||
...createSubscription('display')
|
||||
},
|
||||
apikeys: {
|
||||
// Not sure how we're going to secure this
|
||||
// ...createSubscription('apikeys')
|
||||
},
|
||||
array: {
|
||||
...createSubscription('array')
|
||||
},
|
||||
// devices: {
|
||||
// ...createSubscription('device')
|
||||
// },
|
||||
dockerContainers: {
|
||||
...createSubscription('docker/container')
|
||||
},
|
||||
dockerNetworks: {
|
||||
...createSubscription('docker/network')
|
||||
},
|
||||
info: {
|
||||
...createSubscription('info')
|
||||
},
|
||||
ping: {
|
||||
// subscribe: (_, __, context) => {
|
||||
// // startPing();
|
||||
// hasSubscribedToChannel(context.websocketId, 'ping');
|
||||
// return pubsub.asyncIterator('ping');
|
||||
// }
|
||||
},
|
||||
services: {
|
||||
...createSubscription('services')
|
||||
},
|
||||
servers: {
|
||||
...createSubscription('servers')
|
||||
},
|
||||
shares: {
|
||||
...createSubscription('shares')
|
||||
},
|
||||
unassignedDevices: {
|
||||
...createSubscription('devices/unassigned')
|
||||
},
|
||||
users: {
|
||||
...createSubscription('users')
|
||||
},
|
||||
vars: {
|
||||
...createSubscription('vars')
|
||||
},
|
||||
vms: {
|
||||
...createSubscription('vms/domains')
|
||||
},
|
||||
pluginModule: {
|
||||
subscribe: async (_: unknown, directiveArgs, context: Context) => {
|
||||
const { plugin: pluginName, module: pluginModuleName } = directiveArgs;
|
||||
const channel = `${pluginName}/${pluginModuleName}`;
|
||||
|
||||
// Verify plugin is installed and active
|
||||
if (!pluginManager.isInstalled(pluginName, pluginModuleName)) {
|
||||
throw new PluginError('Plugin not installed.', 500);
|
||||
}
|
||||
|
||||
if (!pluginManager.isActive(pluginName, pluginModuleName)) {
|
||||
throw new PluginError('Plugin disabled.', 500);
|
||||
}
|
||||
|
||||
// It's up to the plugin to publish new data as needed
|
||||
// so we'll just return the Iterator
|
||||
hasSubscribedToChannel(context.websocketId, channel);
|
||||
return pubsub.asyncIterator(channel);
|
||||
}
|
||||
},
|
||||
online: {
|
||||
...createSubscription('online')
|
||||
}
|
||||
};
|
||||
15
app/graphql/resolvers/user-account.ts
Normal file
15
app/graphql/resolvers/user-account.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
export const UserAccount = {
|
||||
__resolveType(obj) {
|
||||
// Only a user has a password field, the current user aka "me" doesn't.
|
||||
if (obj.password) {
|
||||
return 'User';
|
||||
}
|
||||
|
||||
return 'Me';
|
||||
}
|
||||
};
|
||||
@@ -1,2 +1,13 @@
|
||||
export * from './resolvers';
|
||||
export * from './type-defs';
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { fileLoader, mergeTypes } from 'merge-graphql-schemas';
|
||||
|
||||
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
|
||||
|
||||
export const typeDefs = mergeTypes(files, {
|
||||
all: true
|
||||
});
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { pluginManager, pubsub, utils, bus, errors, states, modules, apiManager, log } from '@unraid/core';
|
||||
import dee from '@gridplus/docker-events';
|
||||
import { setIntervalAsync } from 'set-interval-async/dynamic';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import GraphQLLong from 'graphql-type-long';
|
||||
import GraphQLUUID from 'graphql-type-uuid';
|
||||
import { run, publish } from '../../run';
|
||||
import { userCache, CachedServer, CachedServers } from '../../cache';
|
||||
import { hasSubscribedToChannel } from '../../ws';
|
||||
import { getServers as getUserServers } from '../../utils'
|
||||
|
||||
const { ensurePermission } = utils;
|
||||
const { usersState, varState, networkState } = states;
|
||||
const { AppError, PluginError } = errors;
|
||||
|
||||
// Update array values when slots change
|
||||
bus.on('slots', async () => {
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
await run('array', 'UPDATED', {
|
||||
moduleToRun: modules.getArray,
|
||||
context: {
|
||||
user
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update info/hostname when hostname changes
|
||||
bus.on('varstate', async (data) => {
|
||||
const hostname = data.varstate.node.name;
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
if (user) {
|
||||
publish('info', 'UPDATED', {
|
||||
os: {
|
||||
hostname
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// On Docker event update info with { apps: { installed, started } }
|
||||
dee.on('*', async (data: { Type: string }) => {
|
||||
// Only listen to container events
|
||||
if (data.Type !== 'container') {
|
||||
return;
|
||||
}
|
||||
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
if (user) {
|
||||
const { json } = await modules.getAppCount({
|
||||
user
|
||||
});
|
||||
publish('info', 'UPDATED', {
|
||||
apps: json
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dee.listen();
|
||||
|
||||
// This needs to be fixed to run from events
|
||||
setIntervalAsync(async () => {
|
||||
// @todo: Create a system user for this
|
||||
const user = usersState.findOne({ name: 'root' });
|
||||
|
||||
await run('services', 'UPDATED', {
|
||||
moduleToRun: modules.getServices,
|
||||
context: {
|
||||
user
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
interface Context {
|
||||
user: any;
|
||||
websocketId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
* @param resource The access-control permission resource to check against.
|
||||
*/
|
||||
const createSubscription = (channel: string, resource?: string) => ({
|
||||
subscribe(_: unknown, __: unknown, context: Context) {
|
||||
if (!context.user) {
|
||||
throw new AppError('<ws> No user found in context.', 500);
|
||||
}
|
||||
|
||||
// Check the user has permissison to subscribe to this endpoint
|
||||
ensurePermission(context.user, {
|
||||
resource: resource || channel,
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
hasSubscribedToChannel(context.websocketId, channel);
|
||||
return pubsub.asyncIterator(channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Add null to types
|
||||
type makeNullUndefinedAndOptional<T> = {
|
||||
[K in keyof T]?: T[K] | null | undefined;
|
||||
};
|
||||
|
||||
type Server = makeNullUndefinedAndOptional<CachedServer>;
|
||||
|
||||
const getServers = async (): Promise<Server[]> => {
|
||||
const cachedServers = userCache.get<CachedServers>('mine')?.servers;
|
||||
if (cachedServers) {
|
||||
return cachedServers;
|
||||
}
|
||||
|
||||
// For now use the my_servers key
|
||||
// Later we should return the correct one for the current user with the correct scope, etc.
|
||||
const apiKey = apiManager.getValidKeys().find(key => key.name === 'my_servers')?.key.toString()!;
|
||||
|
||||
// No cached servers found
|
||||
if (!cachedServers) {
|
||||
// Fetch servers from mothership
|
||||
const servers = await getUserServers(apiKey);
|
||||
|
||||
log.debug('Using upstream for /servers endpoint');
|
||||
|
||||
// No servers found
|
||||
if (!servers || servers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Cache servers
|
||||
userCache.set<CachedServers>('mine', {
|
||||
servers
|
||||
});
|
||||
|
||||
// Return servers from mothership
|
||||
return servers;
|
||||
}
|
||||
|
||||
log.debug('Falling back to local state for /servers endpoint');
|
||||
const guid = varState?.data?.regGuid;
|
||||
const name = varState?.data?.name;
|
||||
const wanip = null;
|
||||
const lanip = networkState.data[0].ipaddr[0];
|
||||
const localurl = `http://${lanip}:${varState?.data?.port}`;
|
||||
const remoteurl = null;
|
||||
|
||||
return [{
|
||||
owner: {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: ''
|
||||
},
|
||||
guid,
|
||||
apikey: apiKey,
|
||||
name,
|
||||
status: 'online',
|
||||
wanip,
|
||||
lanip,
|
||||
localurl,
|
||||
remoteurl
|
||||
}];
|
||||
};
|
||||
|
||||
export const resolvers = {
|
||||
Query: {
|
||||
online: () => true,
|
||||
info: () => ({}),
|
||||
vms: () => ({}),
|
||||
async server(_: unknown, { name }, context: Context) {
|
||||
ensurePermission(context.user, {
|
||||
resource: 'servers',
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
// Single server
|
||||
return getServers().then(server => server.find(server => server.name === name));
|
||||
},
|
||||
servers(_: unknown, __: unknown, context: Context) {
|
||||
ensurePermission(context.user, {
|
||||
resource: 'servers',
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
// All servers
|
||||
return getServers();
|
||||
}
|
||||
},
|
||||
Subscription: {
|
||||
apikeys: {
|
||||
// Not sure how we're going to secure this
|
||||
// ...createSubscription('apikeys')
|
||||
},
|
||||
array: {
|
||||
...createSubscription('array')
|
||||
},
|
||||
// devices: {
|
||||
// ...createSubscription('device')
|
||||
// },
|
||||
dockerContainers: {
|
||||
...createSubscription('docker/container')
|
||||
},
|
||||
dockerNetworks: {
|
||||
...createSubscription('docker/network')
|
||||
},
|
||||
info: {
|
||||
...createSubscription('info')
|
||||
},
|
||||
ping: {
|
||||
// subscribe: (_, __, context) => {
|
||||
// // startPing();
|
||||
// hasSubscribedToChannel(context.websocketId, 'ping');
|
||||
// return pubsub.asyncIterator('ping');
|
||||
// }
|
||||
},
|
||||
services: {
|
||||
...createSubscription('services')
|
||||
},
|
||||
servers: {
|
||||
...createSubscription('servers')
|
||||
},
|
||||
shares: {
|
||||
...createSubscription('shares')
|
||||
},
|
||||
unassignedDevices: {
|
||||
...createSubscription('devices/unassigned')
|
||||
},
|
||||
users: {
|
||||
...createSubscription('users')
|
||||
},
|
||||
vars: {
|
||||
...createSubscription('vars')
|
||||
},
|
||||
vms: {
|
||||
...createSubscription('vms/domains')
|
||||
},
|
||||
pluginModule: {
|
||||
subscribe: async (_: unknown, directiveArgs, context: Context) => {
|
||||
const {plugin: pluginName, module: pluginModuleName} = directiveArgs;
|
||||
const channel = `${pluginName}/${pluginModuleName}`;
|
||||
|
||||
// Verify plugin is installed and active
|
||||
if (!pluginManager.isInstalled(pluginName, pluginModuleName)) {
|
||||
throw new PluginError('Plugin not installed.', 500);
|
||||
}
|
||||
|
||||
if (!pluginManager.isActive(pluginName, pluginModuleName)) {
|
||||
throw new PluginError('Plugin disabled.', 500);
|
||||
}
|
||||
|
||||
// It's up to the plugin to publish new data as needed
|
||||
// so we'll just return the Iterator
|
||||
hasSubscribedToChannel(context.websocketId, channel);
|
||||
return pubsub.asyncIterator(channel);
|
||||
}
|
||||
},
|
||||
online: {
|
||||
...createSubscription('online')
|
||||
}
|
||||
},
|
||||
JSON: GraphQLJSON,
|
||||
Long: GraphQLLong,
|
||||
UUID: GraphQLUUID,
|
||||
UserAccount: {
|
||||
__resolveType(obj) {
|
||||
// Only a user has a password field, the current user aka "me" doesn't.
|
||||
if (obj.password) {
|
||||
return 'User';
|
||||
}
|
||||
|
||||
return 'Me';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { fileLoader, mergeTypes } from 'merge-graphql-schemas';
|
||||
|
||||
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
|
||||
|
||||
export const typeDefs = mergeTypes(files, {
|
||||
all: true
|
||||
});
|
||||
18
app/graphql/schema/types/display/icons.graphql
Normal file
18
app/graphql/schema/types/display/icons.graphql
Normal file
@@ -0,0 +1,18 @@
|
||||
type Query {
|
||||
display: Display
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
display: Display
|
||||
}
|
||||
|
||||
type Display {
|
||||
case: Case
|
||||
}
|
||||
|
||||
type Case {
|
||||
icon: String
|
||||
url: String
|
||||
error: String
|
||||
base64: String
|
||||
}
|
||||
105
app/graphql/schema/utils.ts
Normal file
105
app/graphql/schema/utils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { pubsub, utils, errors, states, apiManager, log } from '@unraid/core';
|
||||
import { hasSubscribedToChannel } from '../../ws';
|
||||
import { userCache, CachedServer, CachedServers } from '../../cache';
|
||||
import { getServers as getUserServers } from '../../utils'
|
||||
|
||||
const { varState, networkState } = states;
|
||||
|
||||
const { ensurePermission } = utils;
|
||||
const { AppError } = errors;
|
||||
|
||||
export interface Context {
|
||||
user: any;
|
||||
websocketId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
* @param resource The access-control permission resource to check against.
|
||||
*/
|
||||
export const createSubscription = (channel: string, resource?: string) => ({
|
||||
subscribe(_: unknown, __: unknown, context: Context) {
|
||||
if (!context.user) {
|
||||
throw new AppError('<ws> No user found in context.', 500);
|
||||
}
|
||||
|
||||
// Check the user has permissison to subscribe to this endpoint
|
||||
ensurePermission(context.user, {
|
||||
resource: resource || channel,
|
||||
action: 'read',
|
||||
possession: 'any'
|
||||
});
|
||||
|
||||
hasSubscribedToChannel(context.websocketId, channel);
|
||||
return pubsub.asyncIterator(channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Add null to types
|
||||
type makeNullUndefinedAndOptional<T> = {
|
||||
[K in keyof T]?: T[K] | null | undefined;
|
||||
};
|
||||
|
||||
type Server = makeNullUndefinedAndOptional<CachedServer>;
|
||||
|
||||
export const getServers = async (): Promise<Server[]> => {
|
||||
const cachedServers = userCache.get<CachedServers>('mine')?.servers;
|
||||
if (cachedServers) {
|
||||
return cachedServers;
|
||||
}
|
||||
|
||||
// For now use the my_servers key
|
||||
// Later we should return the correct one for the current user with the correct scope, etc.
|
||||
const apiKey = apiManager.getValidKeys().find(key => key.name === 'my_servers')?.key.toString()!;
|
||||
|
||||
// No cached servers found
|
||||
if (!cachedServers) {
|
||||
// Fetch servers from mothership
|
||||
const servers = await getUserServers(apiKey);
|
||||
|
||||
log.debug('Using upstream for /servers endpoint');
|
||||
|
||||
// No servers found
|
||||
if (!servers || servers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Cache servers
|
||||
userCache.set<CachedServers>('mine', {
|
||||
servers
|
||||
});
|
||||
|
||||
// Return servers from mothership
|
||||
return servers;
|
||||
}
|
||||
|
||||
log.debug('Falling back to local state for /servers endpoint');
|
||||
const guid = varState?.data?.regGuid;
|
||||
const name = varState?.data?.name;
|
||||
const wanip = null;
|
||||
const lanip = networkState.data[0].ipaddr[0];
|
||||
const localurl = `http://${lanip}:${varState?.data?.port}`;
|
||||
const remoteurl = null;
|
||||
|
||||
return [{
|
||||
owner: {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: ''
|
||||
},
|
||||
guid,
|
||||
apikey: apiKey,
|
||||
name,
|
||||
status: 'online',
|
||||
wanip,
|
||||
lanip,
|
||||
localurl,
|
||||
remoteurl
|
||||
}];
|
||||
};
|
||||
80
app/my_servers.ts
Normal file
80
app/my_servers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*!
|
||||
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
|
||||
* Written by: Alexis Tyler
|
||||
*/
|
||||
|
||||
import { utils, log, apiManager, paths, pubsub } from '@unraid/core';
|
||||
import path from 'path';
|
||||
import chokidar from 'chokidar';
|
||||
import waitFor from 'p-wait-for';
|
||||
import dotProp from 'dot-prop';
|
||||
import display from './graphql/resolvers/query/display';
|
||||
|
||||
const { validateApiKeyFormat, loadState } = utils;
|
||||
|
||||
/**
|
||||
* One second in milliseconds.
|
||||
*/
|
||||
const ONE_SECOND = 1000;
|
||||
|
||||
export const init = async () => {
|
||||
const filePath = paths.get('dynamix-config')!;
|
||||
const configFilePath = path.join(paths.get('dynamix-base')!, 'case-model.cfg');
|
||||
const customImageFilePath = path.join(paths.get('dynamix-base')!, 'case-model.png');
|
||||
const getApiKey = () => dotProp.get(loadState(filePath), 'remote.apikey') as string;
|
||||
|
||||
// Wait for api key to be valid
|
||||
// We have to use await otherwise the module will keep loading without the apikey being added to the api manager
|
||||
await waitFor(() => getApiKey() !== undefined, {
|
||||
// Check every 1 second
|
||||
interval: ONE_SECOND
|
||||
}).then(() => {
|
||||
log.debug('Found my_servers apiKey, adding to manager.');
|
||||
|
||||
// Add key to manager
|
||||
apiManager.add('my_servers', getApiKey(), {
|
||||
userId: '0'
|
||||
});
|
||||
});
|
||||
|
||||
// Update or remove key when file changes
|
||||
chokidar.watch(filePath).on('all', () => {
|
||||
// Invalidate old API key
|
||||
apiManager.expire('my_servers');
|
||||
|
||||
// Get current API key
|
||||
const apiKey = getApiKey();
|
||||
|
||||
// Ensure API key is in the correct format
|
||||
try {
|
||||
validateApiKeyFormat(apiKey);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('my_servers API key was updated, updating ApiManager.');
|
||||
log.debug('Using %s for my_servers API key', apiKey.replace(/./g, '*'));
|
||||
|
||||
process.nextTick(() => {
|
||||
// Bail if we have no API key
|
||||
if (apiKey === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Either add or update the key
|
||||
apiManager.add('my_servers', apiKey, {
|
||||
userId: '0'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const updatePubsub = async () => {
|
||||
pubsub.publish('display', {
|
||||
display: await display()
|
||||
});
|
||||
};
|
||||
|
||||
// Update pub/sub when config/image file is added/updated/removed
|
||||
chokidar.watch(configFilePath).on('all', updatePubsub);
|
||||
chokidar.watch(customImageFilePath).on('all', updatePubsub);
|
||||
};
|
||||
@@ -16,6 +16,10 @@ import { ApolloServer } from 'apollo-server-express';
|
||||
import { log, config, utils, paths } from '@unraid/core';
|
||||
import { graphql } from './graphql';
|
||||
import { connectToMothership } from './mothership';
|
||||
import { init as loadMyServers } from './my_servers';
|
||||
|
||||
// @todo: move this
|
||||
loadMyServers();
|
||||
|
||||
const { getEndpoints, globalErrorHandler, exitApp, loadState, validateApiKeyFormat } = utils;
|
||||
|
||||
@@ -28,13 +32,13 @@ const ONE_SECOND = 1000;
|
||||
* The Graphql server.
|
||||
*/
|
||||
const app = express();
|
||||
const port = String(config.get('node-api-port'));
|
||||
|
||||
const port = process.env.PORT ?? '0' ?? String(config.get('port'));
|
||||
|
||||
app.use(async (_req, res, next) => {
|
||||
// Only get the machine ID on first request
|
||||
// We do this to avoid using async in the main server function
|
||||
if (!app.get('x-machine-id')) {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
app.set('x-machine-id', await utils.getMachineId());
|
||||
}
|
||||
|
||||
@@ -158,6 +162,7 @@ stoppableServer.on('upgrade', (request, socket, head) => {
|
||||
graphApp.installSubscriptionHandlers(wsServer);
|
||||
|
||||
export const server = {
|
||||
httpServer,
|
||||
server: stoppableServer,
|
||||
async start() {
|
||||
const filePath = paths.get('dynamix-config')!;
|
||||
|
||||
10
app/utils.ts
10
app/utils.ts
@@ -1,7 +1,7 @@
|
||||
import fetch from 'cross-fetch';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { MOTHERSHIP_GRAPHQL_LINK } from './consts';
|
||||
import { CachedServer } from './cache';
|
||||
import fetch from 'cross-fetch';
|
||||
|
||||
export const getServers = (apiKey: string) => fetch(MOTHERSHIP_GRAPHQL_LINK, {
|
||||
method: 'POST',
|
||||
@@ -17,9 +17,13 @@ export const getServers = (apiKey: string) => fetch(MOTHERSHIP_GRAPHQL_LINK, {
|
||||
})
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
return data.data.servers as Promise<CachedServer[]>;
|
||||
const { data, errors } = await response.json();
|
||||
if (errors) {
|
||||
return new Error(errors[0].message);
|
||||
}
|
||||
return data.servers as Promise<CachedServer[]>;
|
||||
})
|
||||
.catch(error => {
|
||||
Sentry.captureException(error);
|
||||
return error;
|
||||
});
|
||||
24
ava.config.cjs
Normal file
24
ava.config.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
const path = require('path');
|
||||
|
||||
const config = {
|
||||
environmentVariables: {
|
||||
DEBUG: 'true',
|
||||
NCHAN: 'disable',
|
||||
PATHS_UNRAID_DATA: path.resolve(__dirname, './dev/data'),
|
||||
PATHS_STATES: path.resolve(__dirname, './dev/states'),
|
||||
PATHS_DYNAMIX_BASE: path.resolve(__dirname, './dev/dynamix'),
|
||||
PATHS_DYNAMIX_CONFIG: path.resolve(__dirname, './dev/dynamix/dynamix.cfg'),
|
||||
API_KEY: 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST'
|
||||
},
|
||||
files: [
|
||||
'./test/**/*'
|
||||
],
|
||||
extensions: [
|
||||
'ts'
|
||||
],
|
||||
require: [
|
||||
'ts-node/register/transpile-only'
|
||||
]
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
191
dev/data/permissions.json
Normal file
191
dev/data/permissions.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"admin": {
|
||||
"extends": "user",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "array",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "cpu",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device/unassigned",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk/settings",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "display",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/container",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "license-key",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "memory",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "os",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "parity-history",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "plugin",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/emhttpd",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/node-api",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "services",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "share",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "machine-id",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "unraid-version",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "software-versions",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "user",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "var",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "info",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/domain",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "servers",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vars",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "online",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"extends": "guest",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:own",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guest": {
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "welcome",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
dev/dynamix/case-model.cfg
Normal file
1
dev/dynamix/case-model.cfg
Normal file
@@ -0,0 +1 @@
|
||||
case-model.png
|
||||
BIN
dev/dynamix/case-model.png
Normal file
BIN
dev/dynamix/case-model.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -19,7 +19,7 @@ hot="45"
|
||||
max="55"
|
||||
sysinfo="/Tools/SystemProfiler"
|
||||
[remote]
|
||||
apikey="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
apikey="TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST"
|
||||
wanaccess="yes"
|
||||
wanport="0"
|
||||
sshprivkey="-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
18474
package-lock.json
generated
18474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -14,7 +14,7 @@
|
||||
"lint": "xo --verbose",
|
||||
"lint:quiet": "xo --quiet",
|
||||
"lint:fix": "xo --fix",
|
||||
"test": "exit 0",
|
||||
"test": "npx nyc@latest ava",
|
||||
"cover": "npm run cover:unit && npm run cover:integration && npm run cover:report",
|
||||
"cover:unit": "nyc --silent npm run test:unit",
|
||||
"cover:integration": "nyc --silent --no-clean npm run test:integration",
|
||||
@@ -39,6 +39,7 @@
|
||||
"apollo-server": "2.18.2",
|
||||
"apollo-server-express": "2.18.2",
|
||||
"camelcase": "6.1.0",
|
||||
"clean-cache": "github:omgimalexis/clean-cache",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"dot-prop": "^6.0.0",
|
||||
"express": "^4.17.1",
|
||||
@@ -59,28 +60,48 @@
|
||||
"stoppable": "^1.1.0",
|
||||
"subscriptions-transport-ws": "^0.9.18"
|
||||
},
|
||||
"optionalDependencies": {},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^11.0.0",
|
||||
"@commitlint/config-conventional": "^11.0.0",
|
||||
"@types/lodash.get": "^4.4.6",
|
||||
"@types/semver-regex": "^3.1.0",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"ava": "^3.13.0",
|
||||
"ava-env": "^2.0.2",
|
||||
"bundle-dependencies": "^1.0.2",
|
||||
"cpx": "1.5.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"husky": "4.3.0",
|
||||
"modclean": "^3.0.0-beta.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"p-each-series": "^2.1.0",
|
||||
"source-map-support": "0.5.19",
|
||||
"standard-version": "^9.0.0",
|
||||
"supertest": "^6.0.1",
|
||||
"ts-node": "9.0.0",
|
||||
"tsup": "2.0.3",
|
||||
"typescript": "4.0.3",
|
||||
"typescript-coverage-report": "^0.1.3",
|
||||
"xo": "0.33.1"
|
||||
},
|
||||
"bundledDependencies": [
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
||||
}
|
||||
},
|
||||
"xo": {
|
||||
"rules": {
|
||||
"max-params": 0
|
||||
}
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@apollo/client",
|
||||
"@gridplus/docker-events",
|
||||
"@sentry/node",
|
||||
@@ -108,21 +129,8 @@
|
||||
"redact-secrets",
|
||||
"set-interval-async",
|
||||
"stoppable",
|
||||
"subscriptions-transport-ws"
|
||||
],
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
|
||||
}
|
||||
},
|
||||
"xo": {
|
||||
"rules": {
|
||||
"max-params": 0
|
||||
}
|
||||
}
|
||||
"subscriptions-transport-ws",
|
||||
"source-map-support",
|
||||
"clean-cache"
|
||||
]
|
||||
}
|
||||
|
||||
143
test/_helpers.ts
Normal file
143
test/_helpers.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// @ts-check
|
||||
import { ExecutionContext } from 'ava';
|
||||
import supertest from 'supertest';
|
||||
import { gql } from 'apollo-server-express';
|
||||
import pEachSeries from 'p-each-series';
|
||||
import pMap from 'p-map';
|
||||
|
||||
interface BasicInput {
|
||||
url: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
query?: string | object;
|
||||
body?: string | object;
|
||||
};
|
||||
|
||||
interface BasicExpected {
|
||||
status?: number;
|
||||
body?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
text: string;
|
||||
};
|
||||
|
||||
interface GraphqlInput {
|
||||
apiKey: string;
|
||||
query: ReturnType<typeof gql> | string;
|
||||
variables?: object;
|
||||
};
|
||||
|
||||
interface GraphqlExpected {
|
||||
data?: { [key: string]: any };
|
||||
errors?: any[];
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export const queryToString = query => typeof query === 'string' ? query : query?.loc?.source?.body;
|
||||
|
||||
export const sendGraphqlRequest = (request: ReturnType<typeof supertest>) => (input: GraphqlInput) => {
|
||||
return request
|
||||
.post('/graphql')
|
||||
.set({
|
||||
Accept: 'application/json',
|
||||
'x-api-key': input.apiKey ?? '123456789'
|
||||
})
|
||||
.send({
|
||||
query: queryToString(input.query),
|
||||
...(input.variables ? { variables: input.variables } : {})
|
||||
});
|
||||
};
|
||||
|
||||
export const getObjectWithRegex = async (input: object, expected: object) => {
|
||||
if (typeof input === 'object' && typeof expected === 'object') {
|
||||
return pMap(Object.entries(input), async ([key, value]) => {
|
||||
// Check regex
|
||||
if (expected[key] instanceof RegExp) {
|
||||
const regex = expected[key];
|
||||
const match = regex.exec(input[key]);
|
||||
|
||||
// Reset lastIndex since this uses a /g flag
|
||||
// https://stackoverflow.com/a/21123303/2311366
|
||||
if (regex.global) {
|
||||
expected[key].lastIndex = 0;
|
||||
}
|
||||
|
||||
return [key, match ? match[0] : match];
|
||||
}
|
||||
|
||||
if (typeof expected[key] === 'object') {
|
||||
return [key, await getObjectWithRegex(input[key], expected[key])];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}).then(entries => JSON.parse(JSON.stringify(Object.fromEntries(entries as []))));
|
||||
}
|
||||
};
|
||||
|
||||
export const checkObject = async (t: ExecutionContext, input: object, expected: object) => {
|
||||
if (typeof input === 'object' && typeof expected === 'object') {
|
||||
return pEachSeries(Object.entries(expected), ([key, value]) => {
|
||||
// Check regex
|
||||
if (value instanceof RegExp) {
|
||||
t.regex(`${input[key]}`, value);
|
||||
return;
|
||||
}
|
||||
|
||||
// It's an object, let's go deeper
|
||||
if (typeof value === 'object') {
|
||||
return checkObject(t, input[key], value);
|
||||
}
|
||||
|
||||
// Check normal value
|
||||
t.is(input[key], value);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic request helper
|
||||
*/
|
||||
export const createBasicRequestMacro = (request: ReturnType<typeof supertest>) => async (t: ExecutionContext, input: BasicInput, expected: BasicExpected) => {
|
||||
// Send request
|
||||
const response = await request[input.method.toLowerCase()](input.url).set('Accept', 'application/json').query(input.query).send(input.body);
|
||||
|
||||
// If we expect an "ok" then check we have a body
|
||||
if (expected.status === 200) {
|
||||
t.false(response.body === undefined);
|
||||
}
|
||||
|
||||
// String response
|
||||
if (typeof expected.text === 'string') {
|
||||
t.is(response.text, expected.text);
|
||||
}
|
||||
|
||||
// Object response
|
||||
if (typeof expected.body === 'object') {
|
||||
// Compare body including running any regex we have
|
||||
await checkObject(t, response.body, expected.body);
|
||||
|
||||
// Snapshot
|
||||
t.snapshot(await getObjectWithRegex(response.body, expected.body), queryToString(input.query));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Graphql request helper
|
||||
*/
|
||||
export const createGraphqlRequestMacro = (request: ReturnType<typeof supertest>) => async (t: ExecutionContext, input: GraphqlInput, expected: GraphqlExpected) => {
|
||||
const response = await sendGraphqlRequest(request)(input);
|
||||
|
||||
// If we expect an "ok" then check we have no errors,
|
||||
// else check the errors against our errors array
|
||||
if (expected.status === 200) {
|
||||
t.is(response.body.errors, undefined);
|
||||
} else {
|
||||
// We need to use "deepEqual" instead of "is" since this is an array
|
||||
t.deepEqual(response.body.errors, expected.errors);
|
||||
}
|
||||
|
||||
// Compare body data including running any regex we have
|
||||
await checkObject(t, response.body.data, expected.data);
|
||||
|
||||
// Snapshot
|
||||
t.snapshot(await getObjectWithRegex(response.body.data, expected.data), queryToString(input.query));
|
||||
};
|
||||
191
test/app/dev/data/permissions.json
Normal file
191
test/app/dev/data/permissions.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"admin": {
|
||||
"extends": "user",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "array",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "cpu",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "device/unassigned",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "disk/settings",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "display",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/container",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "docker/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "license-key",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "memory",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "os",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "parity-history",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "plugin",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/emhttpd",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "service/node-api",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "services",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "share",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "machine-id",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "unraid-version",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "software-versions",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "user",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "var",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "info",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/domain",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vm/network",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "servers",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "vars",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "online",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"extends": "guest",
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "apikey",
|
||||
"action": "read:own",
|
||||
"attributes": "*"
|
||||
},
|
||||
{
|
||||
"resource": "permission",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guest": {
|
||||
"permissions": [
|
||||
{
|
||||
"resource": "welcome",
|
||||
"action": "read:any",
|
||||
"attributes": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
80
test/app/graphql.ts
Normal file
80
test/app/graphql.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import ava, { TestInterface } from 'ava';
|
||||
import getSemverRegex from 'semver-regex';
|
||||
import { agent } from 'supertest';
|
||||
import { gql } from 'apollo-server-express';
|
||||
import { server } from '../../app/server';
|
||||
import { createGraphqlRequestMacro } from '../_helpers';
|
||||
|
||||
const semverRegex = getSemverRegex();
|
||||
|
||||
const test = ava as TestInterface<{
|
||||
requestMacro: ReturnType<typeof createGraphqlRequestMacro>;
|
||||
apiKey: string;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const request = agent(server.httpServer);
|
||||
t.context.requestMacro = createGraphqlRequestMacro(request);
|
||||
t.context.apiKey = 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST';
|
||||
});
|
||||
|
||||
test('fetch current user', async t => {
|
||||
await t.context.requestMacro(t, {
|
||||
query: gql`
|
||||
query {
|
||||
me {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
apiKey: t.context.apiKey
|
||||
}, {
|
||||
status: 200,
|
||||
data: {
|
||||
me: {
|
||||
id: /([0-9]+)/,
|
||||
name: 'root'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch /info', async t => {
|
||||
await t.context.requestMacro(t, {
|
||||
query: gql`
|
||||
query {
|
||||
info {
|
||||
apps {
|
||||
installed
|
||||
started
|
||||
}
|
||||
machineId
|
||||
os {
|
||||
hostname
|
||||
}
|
||||
versions {
|
||||
unraid
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
apiKey: t.context.apiKey
|
||||
}, {
|
||||
status: 200,
|
||||
data: {
|
||||
info: {
|
||||
apps: {
|
||||
installed: /[0-9]/,
|
||||
started: /[0-9]/
|
||||
},
|
||||
os: {
|
||||
hostname: /([a-z0-9\-\.]+)/i
|
||||
},
|
||||
versions: {
|
||||
unraid: semverRegex
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
24
test/app/graphql/resolvers/query/display.ts
Normal file
24
test/app/graphql/resolvers/query/display.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import ava, { TestInterface } from 'ava';
|
||||
import display from '../../../../../app/graphql/resolvers/query/display';
|
||||
|
||||
const test = ava as TestInterface<{
|
||||
apiKey: string;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
t.context.apiKey = 'TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST_TEST';
|
||||
});
|
||||
|
||||
test('icons', async t => {
|
||||
const result = await display();
|
||||
|
||||
// Check base64 seperately
|
||||
const { base64, ...rest } = result.case;
|
||||
|
||||
t.regex(base64, new RegExp('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'));
|
||||
t.deepEqual(rest, {
|
||||
url: 'case-model.png',
|
||||
icon: 'custom',
|
||||
error: ''
|
||||
});
|
||||
});
|
||||
59
test/app/snapshots/graphql.ts.md
Normal file
59
test/app/snapshots/graphql.ts.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Snapshot report for `test/app/graphql.ts`
|
||||
|
||||
The actual snapshot is saved in `graphql.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## fetch /info
|
||||
|
||||
>
|
||||
query {
|
||||
info {
|
||||
apps {
|
||||
installed
|
||||
started
|
||||
}
|
||||
machineId
|
||||
os {
|
||||
hostname
|
||||
}
|
||||
versions {
|
||||
unraid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
info: {
|
||||
apps: {
|
||||
installed: '0',
|
||||
started: '0',
|
||||
},
|
||||
machineId: '',
|
||||
os: {
|
||||
hostname: 'Sophie-2.local',
|
||||
},
|
||||
versions: {
|
||||
unraid: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
## fetch current user
|
||||
|
||||
>
|
||||
query {
|
||||
me {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
me: {
|
||||
id: '0',
|
||||
name: 'root',
|
||||
},
|
||||
}
|
||||
BIN
test/app/snapshots/graphql.ts.snap
Normal file
BIN
test/app/snapshots/graphql.ts.snap
Normal file
Binary file not shown.
59
test/app/snapshots/test.ts.md
Normal file
59
test/app/snapshots/test.ts.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Snapshot report for `test/app/test.ts`
|
||||
|
||||
The actual snapshot is saved in `test.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## fetch /info
|
||||
|
||||
>
|
||||
query {
|
||||
info {
|
||||
apps {
|
||||
installed
|
||||
started
|
||||
}
|
||||
machineId
|
||||
os {
|
||||
hostname
|
||||
}
|
||||
versions {
|
||||
unraid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
info: {
|
||||
apps: {
|
||||
installed: '0',
|
||||
started: '0',
|
||||
},
|
||||
machineId: '',
|
||||
os: {
|
||||
hostname: 'Sophie-2.local',
|
||||
},
|
||||
versions: {
|
||||
unraid: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
## fetch current user
|
||||
|
||||
>
|
||||
query {
|
||||
me {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
me: {
|
||||
id: '0',
|
||||
name: 'root',
|
||||
},
|
||||
}
|
||||
BIN
test/app/snapshots/test.ts.snap
Normal file
BIN
test/app/snapshots/test.ts.snap
Normal file
Binary file not shown.
Reference in New Issue
Block a user