diff --git a/.env.test b/.env.test index 3b593f65..e406cc70 100644 --- a/.env.test +++ b/.env.test @@ -4,3 +4,4 @@ POSTGRES_USERNAME=postgres POSTGRES_PASSWORD=postgres POSTGRES_PORT=5433 APPS_REPO_ID=repo-id +APPS_REPO_URL=https://test.com/test diff --git a/src/server/core/TipiConfig/TipiConfig.test.ts b/src/server/core/TipiConfig/TipiConfig.test.ts index d661b54f..70a28384 100644 --- a/src/server/core/TipiConfig/TipiConfig.test.ts +++ b/src/server/core/TipiConfig/TipiConfig.test.ts @@ -24,7 +24,7 @@ describe('Test: getConfig', () => { it('It should overrides config from settings.json file', () => { const settingsJson = { - appsRepoUrl: faker.random.word(), + appsRepoUrl: faker.internet.url(), appsRepoId: faker.random.word(), domain: faker.random.word(), }; @@ -65,7 +65,7 @@ describe('Test: getConfig', () => { describe('Test: setConfig', () => { it('It should be able set config', () => { - const randomWord = faker.random.word(); + const randomWord = faker.internet.url(); setConfig('appsRepoUrl', randomWord); const config = getConfig(); @@ -73,13 +73,24 @@ describe('Test: setConfig', () => { expect(config.appsRepoUrl).toBe(randomWord); }); - it('Should not be able to set invalid NODE_ENV', () => { - // @ts-expect-error - We are testing invalid NODE_ENV - expect(() => setConfig('NODE_ENV', 'invalid')).toThrow(); + it('Should not be able to set invalid NODE_ENV', async () => { + // arrange + let error; + + // act + try { + // @ts-expect-error - We are testing invalid NODE_ENV + await setConfig('NODE_ENV', 'invalid'); + } catch (e) { + error = e; + } + + // assert + expect(error).toBeDefined(); }); it('Should write config to json file', () => { - const randomWord = faker.random.word(); + const randomWord = faker.internet.url(); setConfig('appsRepoUrl', randomWord, true); const config = getConfig(); @@ -92,3 +103,70 @@ describe('Test: setConfig', () => { expect(settingsJson.appsRepoUrl).toBe(randomWord); }); }); + +describe('Test: getSettings', () => { + it('It should return settings from settings.json', () => { + // arrange + const fakeSettings = { + appsRepoUrl: faker.internet.url(), + }; + const MockFiles = { '/runtipi/state/settings.json': JSON.stringify(fakeSettings) }; + // @ts-expect-error - We are mocking fs + fs.__createMockFiles(MockFiles); + + // act + const settings = new TipiConfig().getSettings(); + + // assert + expect(settings).toBeDefined(); + expect(settings.appsRepoUrl).toBe(fakeSettings.appsRepoUrl); + }); + + it('It should return current config if settings.json has any invalid value', () => { + // arrange + const tipiConf = new TipiConfig(); + const MockFiles = { '/runtipi/state/settings.json': JSON.stringify({ appsRepoUrl: 10 }) }; + // @ts-expect-error - We are mocking fs + fs.__createMockFiles(MockFiles); + + // act + const settings = tipiConf.getSettings(); + + // assert + expect(settings).toBeDefined(); + expect(settings.appsRepoUrl).not.toBe(10); + expect(settings.appsRepoUrl).toBe(tipiConf.getConfig().appsRepoUrl); + }); +}); + +describe('Test: setSettings', () => { + it('should write settings to json file', () => { + // arrange + const fakeSettings = { + appsRepoUrl: faker.internet.url(), + }; + + // act + new TipiConfig().setSettings(fakeSettings); + + // assert + const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string }; + + expect(settingsJson).toBeDefined(); + expect(settingsJson.appsRepoUrl).toBe(fakeSettings.appsRepoUrl); + }); + + it('should not write settings to json file if there are invalid values', () => { + // arrange + const fakeSettings = { appsRepoUrl: 10 }; + + // act + new TipiConfig().setSettings(fakeSettings as object); + + // assert + const settingsJson = (readJsonFile('/runtipi/state/settings.json') || {}) as { [key: string]: string }; + + expect(settingsJson).toBeDefined(); + expect(settingsJson.appsRepoUrl).not.toBe(fakeSettings.appsRepoUrl); + }); +}); diff --git a/src/server/core/TipiConfig/TipiConfig.ts b/src/server/core/TipiConfig/TipiConfig.ts index e4f441bd..4c9376f9 100644 --- a/src/server/core/TipiConfig/TipiConfig.ts +++ b/src/server/core/TipiConfig/TipiConfig.ts @@ -21,7 +21,7 @@ const { APPS_REPO_URL, DOMAIN, REDIS_HOST, - STORAGE_PATH = '/runtipi', + STORAGE_PATH, ARCHITECTURE = 'amd64', POSTGRES_HOST, POSTGRES_DBNAME, @@ -30,20 +30,20 @@ const { POSTGRES_PORT = 5432, } = conf; -const configSchema = z.object({ +export const configSchema = z.object({ NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), REDIS_HOST: z.string(), status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]), architecture: z.nativeEnum(ARCHITECTURES), - dnsIp: z.string(), + dnsIp: z.string().ip(), rootFolder: z.string(), internalIp: z.string(), version: z.string(), jwtSecret: z.string(), appsRepoId: z.string(), - appsRepoUrl: z.string(), + appsRepoUrl: z.string().url(), domain: z.string(), - storagePath: z.string(), + storagePath: z.string().optional(), postgresHost: z.string(), postgresDatabase: z.string(), postgresUsername: z.string(), @@ -51,6 +51,9 @@ const configSchema = z.object({ postgresPort: z.number(), }); +export const settingsSchema = configSchema.partial().pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true }); +export type TipiSettingsType = z.infer; + export const formatErrors = (errors: { fieldErrors: Record }) => Object.entries(errors.fieldErrors) .map(([name, value]) => `${name}: ${value[0]}`) @@ -116,7 +119,19 @@ export class TipiConfig { return this.config; } - public setConfig(key: T, value: z.infer[T], writeFile = false) { + public getSettings() { + const fileConfig = readJsonFile('/runtipi/state/settings.json') || {}; + const parsedSettings = settingsSchema.safeParse({ ...this.config, ...fileConfig }); + + if (parsedSettings.success) { + return parsedSettings.data; + } + + Logger.error('❌ Invalid settings.json file'); + return this.config; + } + + public async setConfig(key: T, value: z.infer[T], writeFile = false) { const newConf: z.infer = { ...this.getConfig() }; newConf[key] = value; @@ -129,13 +144,29 @@ export class TipiConfig { parsedConf[key] = value; const parsed = configSchema.partial().parse(parsedConf); - fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed)); + await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed)); } } + + public async setSettings(settings: TipiSettingsType) { + const newConf: z.infer = { ...this.getConfig() }; + const parsed = settingsSchema.safeParse(settings); + + if (!parsed.success) { + Logger.error('❌ Invalid settings.json file'); + return; + } + + await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed.data)); + + this.config = configSchema.parse({ ...newConf, ...parsed.data }); + } } export const setConfig = (key: T, value: z.infer[T], writeFile = false) => { - TipiConfig.getInstance().setConfig(key, value, writeFile); + return TipiConfig.getInstance().setConfig(key, value, writeFile); }; export const getConfig = () => TipiConfig.getInstance().getConfig(); +export const getSettings = () => TipiConfig.getInstance().getSettings(); +export const setSettings = (settings: TipiSettingsType) => TipiConfig.getInstance().setSettings(settings); diff --git a/src/server/core/TipiConfig/index.ts b/src/server/core/TipiConfig/index.ts index 2dbfb4f0..3a1fd02f 100644 --- a/src/server/core/TipiConfig/index.ts +++ b/src/server/core/TipiConfig/index.ts @@ -1 +1 @@ -export { getConfig, setConfig, TipiConfig } from './TipiConfig'; +export { getConfig, setConfig, getSettings, setSettings, TipiConfig } from './TipiConfig'; diff --git a/src/server/routers/routers.test.ts b/src/server/routers/routers.test.ts new file mode 100644 index 00000000..3ea92096 --- /dev/null +++ b/src/server/routers/routers.test.ts @@ -0,0 +1,7 @@ +import { mainRouter } from './_app'; + +describe('routers', () => { + it('should return a router', () => { + expect(mainRouter).toBeDefined(); + }); +}); diff --git a/src/server/routers/system/system.router.ts b/src/server/routers/system/system.router.ts index d6fcad0f..2af88c0f 100644 --- a/src/server/routers/system/system.router.ts +++ b/src/server/routers/system/system.router.ts @@ -1,6 +1,8 @@ import { inferRouterOutputs } from '@trpc/server'; import { router, protectedProcedure, publicProcedure } from '../../trpc'; import { SystemServiceClass } from '../../services/system'; +import { settingsSchema } from '../../core/TipiConfig/TipiConfig'; +import * as TipiConfig from '../../core/TipiConfig'; export type SystemRouterOutput = inferRouterOutputs; const SystemService = new SystemServiceClass(); @@ -11,4 +13,6 @@ export const systemRouter = router({ getVersion: publicProcedure.query(SystemService.getVersion), restart: protectedProcedure.mutation(SystemService.restart), update: protectedProcedure.mutation(SystemService.update), + updateSettings: protectedProcedure.input(settingsSchema.partial()).mutation(({ input }) => TipiConfig.setSettings(input)), + getSettings: protectedProcedure.query(TipiConfig.getSettings), }); diff --git a/src/server/services/system/system.service.ts b/src/server/services/system/system.service.ts index 3b8ae0ef..09952012 100644 --- a/src/server/services/system/system.service.ts +++ b/src/server/services/system/system.service.ts @@ -5,7 +5,7 @@ import { readJsonFile } from '../../common/fs.helpers'; import { EventDispatcher } from '../../core/EventDispatcher'; import { Logger } from '../../core/Logger'; import TipiCache from '../../core/TipiCache'; -import { getConfig, setConfig } from '../../core/TipiConfig'; +import * as TipiConfig from '../../core/TipiConfig'; const SYSTEM_STATUS = ['UPDATING', 'RESTARTING', 'RUNNING'] as const; type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS]; @@ -38,6 +38,7 @@ export class SystemServiceClass { /** * Get the current and latest version of Tipi + * * @returns {Promise<{ current: string; latest: string }>} */ public getVersion = async (): Promise<{ current: string; latest?: string }> => { @@ -52,10 +53,10 @@ export class SystemServiceClass { await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60); } - return { current: getConfig().version, latest: version?.replace('v', '') }; + return { current: TipiConfig.getConfig().version, latest: version?.replace('v', '') }; } catch (e) { Logger.error(e); - return { current: getConfig().version, latest: undefined }; + return { current: TipiConfig.getConfig().version, latest: undefined }; } }; @@ -72,7 +73,7 @@ export class SystemServiceClass { public update = async (): Promise => { const { current, latest } = await this.getVersion(); - if (getConfig().NODE_ENV === 'development') { + if (TipiConfig.getConfig().NODE_ENV === 'development') { throw new Error('Cannot update in development mode'); } @@ -92,7 +93,7 @@ export class SystemServiceClass { throw new Error('The major version has changed. Please update manually (instructions on GitHub)'); } - setConfig('status', 'UPDATING'); + TipiConfig.setConfig('status', 'UPDATING'); this.dispatcher.dispatchEventAsync('update'); @@ -100,17 +101,17 @@ export class SystemServiceClass { }; public restart = async (): Promise => { - if (getConfig().NODE_ENV === 'development') { + if (TipiConfig.getConfig().NODE_ENV === 'development') { throw new Error('Cannot restart in development mode'); } - setConfig('status', 'RESTARTING'); + TipiConfig.setConfig('status', 'RESTARTING'); this.dispatcher.dispatchEventAsync('restart'); return true; }; public static status = async (): Promise<{ status: SystemStatus }> => ({ - status: getConfig().status as SystemStatus, + status: TipiConfig.getConfig().status, }); } diff --git a/src/server/trpc.ts b/src/server/trpc.ts index 5817bcf6..88e8dd8b 100644 --- a/src/server/trpc.ts +++ b/src/server/trpc.ts @@ -1,11 +1,33 @@ import { initTRPC, TRPCError } from '@trpc/server'; import superjson from 'superjson'; +import { typeToFlattenedError, ZodError } from 'zod'; import { type Context } from './context'; +/** + * + * @param errors + */ +export function zodErrorsToRecord(errors: typeToFlattenedError) { + const record: Record = {}; + Object.entries(errors.fieldErrors).forEach(([key, value]) => { + const error = value?.[0]; + if (error) { + record[key] = error; + } + }); + + return record; +} const t = initTRPC.context().create({ transformer: superjson, - errorFormatter({ shape }) { - return shape; + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null, + }, + }; }, }); // Base router and procedure helpers @@ -13,7 +35,7 @@ export const { router } = t; /** * Unprotected procedure - * */ + */ export const publicProcedure = t.procedure; /** @@ -33,5 +55,5 @@ const isAuthed = t.middleware(({ ctx, next }) => { /** * Protected procedure - * */ + */ export const protectedProcedure = t.procedure.use(isAuthed);