diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 18976e3fd..ee681a45a 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -2,6 +2,7 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] +sandbox="" [remote] wanaccess="yes" wanport="8443" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index 3eb97f13b..88b932983 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -2,6 +2,7 @@ version="3.11.0" extraOrigins="https://google.com,https://test.com" [local] +sandbox="" [remote] wanaccess="yes" wanport="8443" @@ -19,5 +20,5 @@ dynamicRemoteAccessType="DISABLED" ssoSubIds="" allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [connectionStatus] -minigraph="PRE_INIT" +minigraph="ERROR_RETRYING" upnpStatus="" diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index 2e4eaf041..68e6014eb 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -1,4 +1,5 @@ import { isEqual } from 'lodash-es'; +import merge from 'lodash/merge'; import { getAllowedOrigins } from '@app/common/allowed-origins'; import { initialState } from '@app/store/modules/config'; @@ -23,14 +24,10 @@ export const getWriteableConfig = ( const defaultConfig = schema.parse(initialState); // Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory` - const mergedConfig = { - ...defaultConfig, - ...config, - remote: { - ...defaultConfig.remote, - ...config.remote, - }, - } as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; + const mergedConfig = merge< + MyServersConfig, + T extends 'memory' ? MyServersConfigMemory : MyServersConfig + >(defaultConfig, config); if (mode === 'memory') { (mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', '); diff --git a/api/src/store/modules/config.ts b/api/src/store/modules/config.ts index 631116985..49808044c 100644 --- a/api/src/store/modules/config.ts +++ b/api/src/store/modules/config.ts @@ -49,7 +49,9 @@ export const initialState: SliceState = { dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, ssoSubIds: '', }, - local: {}, + local: { + sandbox: 'no' + }, api: { extraOrigins: '', version: '', diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts index 36dea5e46..1d393fcdf 100644 --- a/api/src/types/my-servers-config.ts +++ b/api/src/types/my-servers-config.ts @@ -41,7 +41,9 @@ const RemoteConfigSchema = z.object({ ), }); -const LocalConfigSchema = z.object({}); +const LocalConfigSchema = z.object({ + sandbox: z.string() +}); // Base config schema export const MyServersConfigSchema = z diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 464211d6c..3a96b1327 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -20,6 +20,8 @@ import { RemoveSSOUserQuestionSet } from '@app/unraid-api/cli/sso/remove-sso-use import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command'; import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions'; import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; +import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command'; +import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions'; @Module({ providers: [ @@ -43,6 +45,8 @@ import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; ValidateTokenCommand, LogsCommand, ConfigCommand, + DeveloperCommand, + DeveloperQuestions ], }) export class CliModule {} diff --git a/api/src/unraid-api/cli/developer/developer.command.ts b/api/src/unraid-api/cli/developer/developer.command.ts new file mode 100644 index 000000000..f8543bfb0 --- /dev/null +++ b/api/src/unraid-api/cli/developer/developer.command.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { Command, CommandRunner, InquirerService } from 'nest-commander'; + +import { loadConfigFile, updateUserConfig } from '@app/store/modules/config'; +import { writeConfigSync } from '@app/store/sync/config-disk-sync'; +import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +interface DeveloperOptions { + disclaimer: boolean; + sandbox: boolean; +} +@Injectable() +@Command({ + name: 'developer', + description: 'Configure Developer Features for the API', +}) +export class DeveloperCommand extends CommandRunner { + constructor( + private logger: LogService, + private readonly inquirerService: InquirerService + ) { + super(); + } + async run(_, options?: DeveloperOptions): Promise { + options = await this.inquirerService.prompt(DeveloperQuestions.name, options); + if (!options.disclaimer) { + this.logger.warn('No changes made, disclaimer not accepted.'); + process.exit(1); + } + const { store } = await import('@app/store'); + await store.dispatch(loadConfigFile()); + store.dispatch(updateUserConfig({ local: { sandbox: options.sandbox ? 'yes' : 'no' } })); + console.log(store.getState().config.local.sandbox); + writeConfigSync('flash'); + + this.logger.info('Updated Developer Configuration'); + } +} diff --git a/api/src/unraid-api/cli/developer/developer.questions.ts b/api/src/unraid-api/cli/developer/developer.questions.ts new file mode 100644 index 000000000..06f708d59 --- /dev/null +++ b/api/src/unraid-api/cli/developer/developer.questions.ts @@ -0,0 +1,26 @@ +import { Question, QuestionSet } from 'nest-commander'; + +@QuestionSet({ name: 'developer' }) +export class DeveloperQuestions { + static name = 'developer'; + + @Question({ + message: `Are you sure you wish to enable developer mode? +Currently this allows enabling the GraphQL sandbox on SERVER_URL/graphql. +`, + type: 'confirm', + name: 'disclaimer', + }) + parseDisclaimer(val: boolean) { + return val; + } + + @Question({ + message: 'Do you wish to enable the sandbox?', + type: 'confirm', + name: 'sandbox', + }) + parseSandbox(val: boolean) { + return val; + } +} diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 6c63d28d5..5c9b529fe 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -12,7 +12,6 @@ import { UUIDResolver, } from 'graphql-scalars'; -import { GRAPHQL_INTROSPECTION } from '@app/environment'; import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long'; import { typeDefs } from '@app/graphql/schema/index'; import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin'; @@ -24,20 +23,21 @@ import { ResolversModule } from './resolvers/resolvers.module'; import { sandboxPlugin } from './sandbox-plugin'; import { ServicesResolver } from './services/services.resolver'; import { SharesResolver } from './shares/shares.resolver'; +import { getters } from '@app/store/index'; @Module({ imports: [ ResolversModule, GraphQLModule.forRoot({ driver: ApolloDriver, - introspection: GRAPHQL_INTROSPECTION ? true : false, + introspection: getters.config().local?.sandbox === 'yes' ? true : false, + playground: false, context: ({ req, connectionParams, extra }) => ({ req, connectionParams, extra, }), - playground: false, - plugins: GRAPHQL_INTROSPECTION ? [sandboxPlugin, idPrefixPlugin] : [idPrefixPlugin], + plugins: [sandboxPlugin, idPrefixPlugin], subscriptions: { 'graphql-ws': { path: '/graphql', diff --git a/api/src/unraid-api/graph/sandbox-plugin.ts b/api/src/unraid-api/graph/sandbox-plugin.ts index d53ebf7b6..7662517ae 100644 --- a/api/src/unraid-api/graph/sandbox-plugin.ts +++ b/api/src/unraid-api/graph/sandbox-plugin.ts @@ -28,13 +28,43 @@ const preconditionFailed = (preconditionName: string) => { throw new HttpException(`Precondition failed: ${preconditionName} `, HttpStatus.PRECONDITION_FAILED); }; +export const getPluginBasedOnSandbox = async (sandbox: boolean, csrfToken: string) => { + if (sandbox) { + const { ApolloServerPluginLandingPageLocalDefault } = await import( + '@apollo/server/plugin/landingPage/default' + ); + const plugin = ApolloServerPluginLandingPageLocalDefault({ + footer: false, + includeCookies: true, + document: initialDocument, + embed: { + initialState: { + sharedHeaders: { + 'x-csrf-token': csrfToken, + }, + }, + }, + }); + return plugin; + } else { + const { ApolloServerPluginLandingPageProductionDefault } = await import( + '@apollo/server/plugin/landingPage/default' + ); + + const plugin = ApolloServerPluginLandingPageProductionDefault({ + footer: false + }); + return plugin; + } +}; + /** * Renders the sandbox page for the GraphQL server with Apollo Server landing page configuration. - * + * * @param service - The GraphQL server context object * @returns Promise that resolves to an Apollo `LandingPage`, or throws a precondition failed error * @throws {Error} When downstream plugin components from apollo are unavailable. This should never happen. - * + * * @remarks * This function configures and renders the Apollo Server landing page with: * - Disabled footer @@ -44,33 +74,22 @@ const preconditionFailed = (preconditionName: string) => { */ async function renderSandboxPage(service: GraphQLServerContext) { const { getters } = await import('@app/store'); - const { ApolloServerPluginLandingPageLocalDefault } = await import( - '@apollo/server/plugin/landingPage/default' - ); - const plugin = ApolloServerPluginLandingPageLocalDefault({ - footer: false, - includeCookies: true, - document: initialDocument, - embed: { - initialState: { - sharedHeaders: { - 'x-csrf-token': getters.emhttp().var.csrfToken, - }, - }, - }, - }); + const sandbox = getters.config().local.sandbox === 'yes'; + const csrfToken = getters.emhttp().var.csrfToken; + const plugin = await getPluginBasedOnSandbox(sandbox, csrfToken); + if (!plugin.serverWillStart) return preconditionFailed('serverWillStart'); const serverListener = await plugin.serverWillStart(service); if (!serverListener) return preconditionFailed('serverListener'); if (!serverListener.renderLandingPage) return preconditionFailed('renderLandingPage'); - + return serverListener.renderLandingPage(); } /** * Apollo plugin to render the GraphQL Sandbox page on-demand based on current server state. - * + * * Usually, the `ApolloServerPluginLandingPageLocalDefault` plugin configures its * parameters once, during server startup. This plugin defers the configuration * and rendering to request-time instead of server startup.