Переглянути джерело

Release/0.7.4 (#266)

* feat: move from cookie base auth to jwt auth

test: mock redis

* test: auth.service & auth.resolver

test: auth.resolver

* fix: semver comparaison client side

* refactor: allow all origins

* feat: specify which app have no GUI and therefore don't show the "open" button

* feat(install form): add input placeholder

chore: fix code smells

* chore: update tests to cover invalid config.json

* fix(dashboard): refresh page when update is successful

* chore: bump version 0.7.4

* feat: use redis cache in apollo server

* feat: allow apps to configure a uid:gid for folder permissions

* test: correct broken test
Nicolas Meienberger 2 роки тому
батько
коміт
6117bf837c
39 змінених файлів з 699 додано та 634 видалено
  1. 2 0
      docker-compose.dev.yml
  2. 1 1
      package.json
  3. 1 1
      packages/dashboard/codegen.yml
  4. 0 7
      packages/dashboard/next.config.js
  5. 1 1
      packages/dashboard/package.json
  6. 4 2
      packages/dashboard/src/components/Form/FormInput.tsx
  7. 1 1
      packages/dashboard/src/components/Form/validators.ts
  8. 6 1
      packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx
  9. 5 2
      packages/dashboard/src/generated/graphql.tsx
  10. 2 0
      packages/dashboard/src/graphql/queries/getApp.graphql
  11. 4 1
      packages/dashboard/src/modules/Apps/components/AppActions.tsx
  12. 11 1
      packages/dashboard/src/modules/Apps/components/InstallForm.tsx
  13. 7 4
      packages/dashboard/src/pages/settings.tsx
  14. 4 1
      packages/system-api/package.json
  15. 5 4
      packages/system-api/src/core/config/TipiConfig.ts
  16. 1 1
      packages/system-api/src/core/config/__tests__/TipiConfig.test.ts
  17. 54 4
      packages/system-api/src/core/updates/__tests__/v040.test.ts
  18. 32 19
      packages/system-api/src/core/updates/v040.ts
  19. 2 0
      packages/system-api/src/helpers/helpers.ts
  20. 22 9
      packages/system-api/src/modules/apps/__tests__/apps.factory.ts
  21. 88 15
      packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
  22. 1 1
      packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
  23. 53 0
      packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
  24. 97 47
      packages/system-api/src/modules/apps/apps.helpers.ts
  25. 30 27
      packages/system-api/src/modules/apps/apps.service.ts
  26. 6 9
      packages/system-api/src/modules/apps/apps.types.ts
  27. 1 1
      packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts
  28. 1 1
      packages/system-api/src/modules/fs/fs.helpers.ts
  29. 3 0
      packages/system-api/src/server.ts
  30. 0 1
      packages/system-api/src/test/mutations/installApp.graphql
  31. 0 1
      packages/system-api/src/test/mutations/startApp.graphql
  32. 0 1
      packages/system-api/src/test/mutations/stopApp.graphql
  33. 0 1
      packages/system-api/src/test/mutations/uninstallApp.graphql
  34. 0 1
      packages/system-api/src/test/mutations/updateApp.graphql
  35. 0 1
      packages/system-api/src/test/mutations/updateAppConfig.graphql
  36. 0 1
      packages/system-api/src/test/queries/getApp.graphql
  37. 0 1
      packages/system-api/src/test/queries/installedApps.graphql
  38. 0 1
      packages/system-api/src/test/queries/listAppInfos.graphql
  39. 254 464
      pnpm-lock.yaml

+ 2 - 0
docker-compose.dev.yml

@@ -125,6 +125,8 @@ services:
       # Middlewares
       traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
       traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
+  
+  
 
 networks:
   tipi_main_network:

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "runtipi",
-  "version": "0.7.3",
+  "version": "0.7.4",
   "description": "A homeserver for everyone",
   "scripts": {
     "prepare": "husky install",

+ 1 - 1
packages/dashboard/codegen.yml

@@ -1,5 +1,5 @@
 overwrite: true
-schema: "http://localhost:3001/graphql"
+schema: "http://localhost:3000/api/graphql"
 documents: "src/graphql/**/*.graphql"
 generates:
   src/generated/graphql.tsx:

+ 0 - 7
packages/dashboard/next.config.js

@@ -1,13 +1,6 @@
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   output: 'standalone',
-  webpackDevMiddleware: (config) => {
-    config.watchOptions = {
-      poll: 1000,
-      aggregateTimeout: 300,
-    };
-    return config;
-  },
   reactStrictMode: true,
   basePath: '/dashboard',
 };

+ 1 - 1
packages/dashboard/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dashboard",
-  "version": "0.7.3",
+  "version": "0.7.4",
   "private": true,
   "scripts": {
     "test": "jest --colors",

+ 4 - 2
packages/dashboard/src/components/Form/FormInput.tsx

@@ -10,12 +10,14 @@ interface IProps {
   className?: string;
   isInvalid?: boolean;
   size?: Parameters<typeof Input>[0]['size'];
+  hint?: string;
 }
 
-const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
+const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, hint, ...rest }) => {
   return (
     <div className={clsx('transition-all', className)}>
-      {label && <label>{label}</label>}
+      {label && <label className="mb-1">{label}</label>}
+      {hint && <div className="text-sm text-gray-500 mb-1">{hint}</div>}
       <Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
       {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
     </div>

+ 1 - 1
packages/dashboard/src/components/Form/validators.ts

@@ -62,7 +62,7 @@ const validateField = (field: FormField, value: string | undefined | boolean): s
 
 const validateDomain = (domain?: string): string | undefined => {
   if (!validator.isFQDN(domain || '')) {
-    return `${domain} must be a valid domain`;
+    return 'Must be a valid domain';
   }
 };
 

+ 6 - 1
packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx

@@ -16,6 +16,11 @@ const StatusWrapper: React.FC<IProps> = ({ children }) => {
   const { data } = useSWR('/api/status', fetcher, { refreshInterval: 1000 });
 
   useEffect(() => {
+    // If previous was not running and current is running, we need to refresh the page
+    if (data?.status === SystemStatus.RUNNING && s !== SystemStatus.RUNNING) {
+      window.location.reload();
+    }
+
     if (data?.status === SystemStatus.RUNNING) {
       setS(SystemStatus.RUNNING);
     }
@@ -25,7 +30,7 @@ const StatusWrapper: React.FC<IProps> = ({ children }) => {
     if (data?.status === SystemStatus.UPDATING) {
       setS(SystemStatus.UPDATING);
     }
-  }, [data?.status]);
+  }, [data?.status, s]);
 
   if (s === SystemStatus.RESTARTING) {
     return (

+ 5 - 2
packages/dashboard/src/generated/graphql.tsx

@@ -63,8 +63,8 @@ export type AppInfo = {
   https?: Maybe<Scalars['Boolean']>;
   id: Scalars['String'];
   name: Scalars['String'];
+  no_gui?: Maybe<Scalars['Boolean']>;
   port: Scalars['Float'];
-  requirements?: Maybe<Scalars['JSONObject']>;
   short_desc: Scalars['String'];
   source: Scalars['String'];
   supported_architectures?: Maybe<Array<AppSupportedArchitecturesEnum>>;
@@ -128,6 +128,7 @@ export type FormField = {
   label: Scalars['String'];
   max?: Maybe<Scalars['Float']>;
   min?: Maybe<Scalars['Float']>;
+  placeholder?: Maybe<Scalars['String']>;
   required?: Maybe<Scalars['Boolean']>;
   type: FieldTypesEnum;
 };
@@ -324,7 +325,7 @@ export type GetAppQueryVariables = Exact<{
 }>;
 
 
-export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, exposed: boolean, domain?: string | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, https?: boolean | null, exposable?: boolean | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, required?: boolean | null, env_variable: string }> } | null } };
+export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, exposed: boolean, domain?: string | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, https?: boolean | null, exposable?: boolean | null, no_gui?: boolean | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, placeholder?: string | null, required?: boolean | null, env_variable: string }> } | null } };
 
 export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
 
@@ -757,12 +758,14 @@ export const GetAppDocument = gql`
       url_suffix
       https
       exposable
+      no_gui
       form_fields {
         type
         label
         max
         min
         hint
+        placeholder
         required
         env_variable
       }

+ 2 - 0
packages/dashboard/src/graphql/queries/getApp.graphql

@@ -26,12 +26,14 @@ query GetApp($appId: String!) {
       url_suffix
       https
       exposable
+      no_gui
       form_fields {
         type
         label
         max
         min
         hint
+        placeholder
         required
         env_variable
       }

+ 4 - 1
packages/dashboard/src/modules/Apps/components/AppActions.tsx

@@ -76,7 +76,10 @@ const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onS
       }
       break;
     case AppStatusEnum.Running:
-      buttons.push(StopButton, OpenButton);
+      buttons.push(StopButton);
+      if (!app.no_gui) {
+        buttons.push(OpenButton);
+      }
       if (hasSettings) {
         buttons.push(SettingsButton);
       }

+ 11 - 1
packages/dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -28,7 +28,17 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exp
       <Field
         key={field.env_variable}
         name={field.env_variable}
-        render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label={field.label} {...input} />}
+        render={({ input, meta }) => (
+          <FormInput
+            hint={field.hint || ''}
+            placeholder={field.placeholder || ''}
+            className="mb-3"
+            error={meta.error}
+            isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
+            label={field.label}
+            {...input}
+          />
+        )}
       />
     );
   };

+ 7 - 4
packages/dashboard/src/pages/settings.tsx

@@ -1,10 +1,12 @@
 import type { NextPage } from 'next';
 import { AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Button, Text, useDisclosure, useToast } from '@chakra-ui/react';
 import Layout from '../components/Layout';
-import { useLogoutMutation, useRestartMutation, useUpdateMutation, useVersionQuery } from '../generated/graphql';
+import { useRestartMutation, useUpdateMutation, useVersionQuery } from '../generated/graphql';
 import { useRef, useState } from 'react';
 import semver from 'semver';
 
+const wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
+
 const Settings: NextPage = () => {
   const toast = useToast();
   const restartDisclosure = useDisclosure();
@@ -15,7 +17,6 @@ const Settings: NextPage = () => {
 
   const [restart] = useRestartMutation();
   const [update] = useUpdateMutation();
-  const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
 
   const defaultVersion = '0.0.0';
   const isLatest = semver.gte(data?.version.current || defaultVersion, data?.version.latest || defaultVersion);
@@ -55,7 +56,8 @@ const Settings: NextPage = () => {
     setLoading(true);
     try {
       restart();
-      logout();
+      await wait(1000);
+      localStorage.removeItem('token');
     } catch (error) {
       handleError(error);
     } finally {
@@ -67,7 +69,8 @@ const Settings: NextPage = () => {
     setLoading(true);
     try {
       update();
-      logout();
+      await wait(1000);
+      localStorage.removeItem('token');
     } catch (error) {
       handleError(error);
     } finally {

+ 4 - 1
packages/system-api/package.json

@@ -1,6 +1,6 @@
 {
   "name": "system-api",
-  "version": "0.7.3",
+  "version": "0.7.4",
   "description": "",
   "exports": "./dist/server.js",
   "type": "module",
@@ -25,6 +25,8 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
+    "@apollo/utils.keyvadapter": "^1.1.2",
+    "@keyv/redis": "^2.5.3",
     "apollo-server-core": "^3.10.0",
     "apollo-server-express": "^3.9.0",
     "argon2": "^0.29.1",
@@ -38,6 +40,7 @@
     "graphql-type-json": "^0.3.2",
     "http": "0.0.1-security",
     "jsonwebtoken": "^8.5.1",
+    "keyv": "^4.5.2",
     "node-cache": "^5.1.2",
     "node-cron": "^3.0.1",
     "pg": "^8.7.3",

+ 5 - 4
packages/system-api/src/core/config/TipiConfig.ts

@@ -112,10 +112,11 @@ class Config {
     this.config = configSchema.parse(newConf);
 
     if (writeFile) {
-      const currentJsonConf = readJsonFile<Partial<z.infer<typeof configSchema>>>('/runtipi/state/settings.json') || {};
-      currentJsonConf[key] = value;
-      const partialConfig = configSchema.partial();
-      const parsed = partialConfig.parse(currentJsonConf);
+      const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
+      const parsedConf = configSchema.partial().parse(currentJsonConf);
+
+      parsedConf[key] = value;
+      const parsed = configSchema.partial().parse(parsedConf);
 
       fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
     }

+ 1 - 1
packages/system-api/src/core/config/__tests__/TipiConfig.test.ts

@@ -48,7 +48,7 @@ describe('Test: setConfig', () => {
     expect(config).toBeDefined();
     expect(config.appsRepoUrl).toBe(randomWord);
 
-    const settingsJson = readJsonFile<any>('/runtipi/state/settings.json');
+    const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string };
 
     expect(settingsJson).toBeDefined();
     expect(settingsJson.appsRepoUrl).toBe(randomWord);

+ 54 - 4
packages/system-api/src/core/updates/__tests__/v040.test.ts

@@ -1,9 +1,11 @@
+import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
 import { DataSource } from 'typeorm';
 import logger from '../../../config/logger/logger';
 import App from '../../../modules/apps/app.entity';
 import { AppInfo, AppStatusEnum } from '../../../modules/apps/apps.types';
 import { createApp } from '../../../modules/apps/__tests__/apps.factory';
+import User from '../../../modules/auth/user.entity';
 import Update, { UpdateStatusEnum } from '../../../modules/system/update.entity';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { getConfig } from '../../config/TipiConfig';
@@ -30,7 +32,8 @@ afterAll(async () => {
   await teardownConnection(TEST_SUITE);
 });
 
-const createState = (apps: string[]) => JSON.stringify({ installed: apps.join(' ') });
+const createAppState = (apps: string[]) => JSON.stringify({ installed: apps.join(' ') });
+const createUserState = (users: { email: string; password: string }[]) => JSON.stringify(users);
 
 describe('No state/apps.json', () => {
   it('Should do nothing and create the update with status SUCCES', async () => {
@@ -60,7 +63,7 @@ describe('No state/apps.json', () => {
 describe('State/apps.json exists with no installed app', () => {
   beforeEach(async () => {
     const { MockFiles } = await createApp({});
-    MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createState([]);
+    MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createAppState([]);
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
   });
@@ -87,7 +90,7 @@ describe('State/apps.json exists with one installed app', () => {
   beforeEach(async () => {
     const { MockFiles, appInfo } = await createApp({});
     app1 = appInfo;
-    MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
+    MockFiles['/runtipi/state/apps.json'] = createAppState([appInfo.id]);
     MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
     MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
     // @ts-ignore
@@ -116,7 +119,7 @@ describe('State/apps.json exists with one installed app', () => {
   it('Should not try to migrate app if it already exists', async () => {
     const { MockFiles, appInfo } = await createApp({ installed: true });
     app1 = appInfo;
-    MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
+    MockFiles['/runtipi/state/apps.json'] = createAppState([appInfo.id]);
     MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
     MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
     // @ts-ignore
@@ -129,3 +132,50 @@ describe('State/apps.json exists with one installed app', () => {
     expect(spy).toHaveBeenCalledWith('App already migrated');
   });
 });
+
+describe('State/users.json exists with no user', () => {
+  beforeEach(async () => {
+    const { MockFiles } = await createApp({});
+    MockFiles[`${getConfig().rootFolder}/state/users.json`] = createUserState([]);
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+  });
+
+  it('Should do nothing and create the update with status SUCCES', async () => {
+    await updateV040();
+    const update = await Update.findOne({ where: { name: 'v040' } });
+
+    expect(update).toBeDefined();
+    expect(update?.status).toBe(UpdateStatusEnum.SUCCESS);
+
+    const apps = await App.find();
+    expect(apps).toHaveLength(0);
+  });
+
+  it('Should delete state file after update', async () => {
+    await updateV040();
+    expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
+  });
+});
+
+describe('State/users.json exists with one user', () => {
+  const email = faker.internet.email();
+
+  beforeEach(async () => {
+    const MockFiles: Record<string, string> = {};
+    MockFiles[`/runtipi/state/users.json`] = createUserState([{ email, password: faker.internet.password() }]);
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+  });
+
+  it('Should create a new user and update', async () => {
+    await updateV040();
+
+    const user = await User.findOne({ where: { username: email } });
+    const update = await Update.findOne({ where: { name: 'v040' } });
+
+    expect(user).toBeDefined();
+    expect(update).toBeDefined();
+    expect(update?.status).toBe('SUCCESS');
+  });
+});

+ 32 - 19
packages/system-api/src/core/updates/v040.ts

@@ -1,12 +1,15 @@
+import { z } from 'zod';
 import logger from '../../config/logger/logger';
 import App from '../../modules/apps/app.entity';
-import { AppInfo, AppStatusEnum } from '../../modules/apps/apps.types';
+import { appInfoSchema } from '../../modules/apps/apps.helpers';
+import { AppStatusEnum } from '../../modules/apps/apps.types';
 import User from '../../modules/auth/user.entity';
 import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
 import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
 import { getConfig } from '../config/TipiConfig';
 
-type AppsState = { installed: string };
+const appStateSchema = z.object({ installed: z.string().optional().default('') });
+const userStateSchema = z.object({ email: z.string(), password: z.string() }).array();
 
 const UPDATE_NAME = 'v040';
 
@@ -25,17 +28,21 @@ const migrateApp = async (appId: string): Promise<void> => {
 
     const form: Record<string, string> = {};
 
-    const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
-    configFile?.form_fields?.forEach((field) => {
-      const envVar = field.env_variable;
-      const envVarValue = envVarsMap.get(envVar);
+    const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
+    const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (envVarValue) {
-        form[field.env_variable] = envVarValue;
-      }
-    });
+    if (parsedConfig.success) {
+      parsedConfig.data.form_fields.forEach((field) => {
+        const envVar = field.env_variable;
+        const envVarValue = envVarsMap.get(envVar);
+
+        if (envVarValue) {
+          form[field.env_variable] = envVarValue;
+        }
+      });
 
-    await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
+      await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
+    }
   } else {
     logger.info('App already migrated');
   }
@@ -56,19 +63,25 @@ export const updateV040 = async (): Promise<void> => {
 
     // Migrate apps
     if (fileExists('/runtipi/state/apps.json')) {
-      const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
-      const installed: string[] = state.installed.split(' ').filter(Boolean);
+      const state = readJsonFile('/runtipi/state/apps.json');
+      const parsedState = appStateSchema.safeParse(state);
 
-      await Promise.all(installed.map((appId) => migrateApp(appId)));
-      deleteFolder('/runtipi/state/apps.json');
+      if (parsedState.success) {
+        const installed: string[] = parsedState.data.installed.split(' ').filter(Boolean);
+        await Promise.all(installed.map((appId) => migrateApp(appId)));
+        deleteFolder('/runtipi/state/apps.json');
+      }
     }
 
     // Migrate users
-    if (fileExists('/state/users.json')) {
-      const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
+    if (fileExists('/runtipi/state/users.json')) {
+      const state = readJsonFile('/runtipi/state/users.json');
+      const parsedState = userStateSchema.safeParse(state);
 
-      await Promise.all(state.map((user) => migrateUser(user)));
-      deleteFolder('/runtipi/state/users.json');
+      if (parsedState.success) {
+        await Promise.all(parsedState.data.map((user) => migrateUser(user)));
+        deleteFolder('/runtipi/state/users.json');
+      }
     }
 
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();

+ 2 - 0
packages/system-api/src/helpers/helpers.ts

@@ -1,3 +1,5 @@
 const objectKeys = <T extends object>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
 
+export const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => value !== null && value !== undefined;
+
 export default { objectKeys };

+ 22 - 9
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -1,6 +1,7 @@
 import { faker } from '@faker-js/faker';
 import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from '../apps.types';
 import App from '../app.entity';
+import { appInfoSchema } from '../apps.helpers';
 
 interface IProps {
   installed?: boolean;
@@ -13,8 +14,26 @@ interface IProps {
   supportedArchitectures?: AppSupportedArchitecturesEnum[];
 }
 
+type CreateConfigParams = {
+  id?: string;
+};
+
+const createAppConfig = (props?: CreateConfigParams): AppInfo =>
+  appInfoSchema.parse({
+    id: props?.id || faker.random.alphaNumeric(32),
+    available: true,
+    port: faker.datatype.number({ min: 30, max: 65535 }),
+    name: faker.random.alphaNumeric(32),
+    description: faker.random.alphaNumeric(32),
+    tipi_version: 1,
+    short_desc: faker.random.alphaNumeric(32),
+    author: faker.random.alphaNumeric(32),
+    source: faker.internet.url(),
+    categories: [AppCategoriesEnum.AUTOMATION],
+  });
+
 const createApp = async (props: IProps) => {
-  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
+  const { installed = false, status = AppStatusEnum.RUNNING, randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
 
   const categories = Object.values(AppCategoriesEnum);
 
@@ -49,16 +68,10 @@ const createApp = async (props: IProps) => {
     });
   }
 
-  if (requiredPort) {
-    appInfo.requirements = {
-      ports: [requiredPort],
-    };
-  }
-
   const MockFiles: Record<string, string | string[]> = {};
   MockFiles['/runtipi/.env'] = 'TEST=test';
   MockFiles['/runtipi/repos/repo-id'] = '';
-  MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
+  MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
   MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
   MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
 
@@ -82,4 +95,4 @@ const createApp = async (props: IProps) => {
   return { appInfo, MockFiles, appEntity };
 };
 
-export { createApp };
+export { createApp, createAppConfig };

+ 88 - 15
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -2,11 +2,12 @@ import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
 import { DataSource } from 'typeorm';
 import logger from '../../../config/logger/logger';
+import { setConfig } from '../../../core/config/TipiConfig';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
 import { checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
-import { AppInfo } from '../apps.types';
-import { createApp } from './apps.factory';
+import { AppInfo, AppSupportedArchitecturesEnum } from '../apps.types';
+import { createApp, createAppConfig } from './apps.factory';
 
 jest.mock('fs-extra');
 jest.mock('child_process');
@@ -23,6 +24,13 @@ afterAll(async () => {
   await teardownConnection(TEST_SUITE);
 });
 
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+  await App.clear();
+});
+
 describe('checkAppRequirements', () => {
   let app1: AppInfo;
 
@@ -33,13 +41,34 @@ describe('checkAppRequirements', () => {
     fs.__createMockFiles(app1create.MockFiles);
   });
 
-  it('should return true if there are no particular requirement', async () => {
-    const ivValid = await checkAppRequirements(app1.id);
-    expect(ivValid).toBe(true);
+  it('should return appInfo if there are no particular requirement', async () => {
+    const result = checkAppRequirements(app1.id);
+    expect(result.id).toEqual(app1.id);
   });
 
   it('Should throw an error if app does not exist', async () => {
-    await expect(checkAppRequirements('not-existing-app')).rejects.toThrow('App not-existing-app not found');
+    try {
+      checkAppRequirements('notexisting');
+      expect(true).toBe(false);
+    } catch (e) {
+      // @ts-ignore
+      expect(e.message).toEqual('App notexisting has invalid config.json file');
+    }
+  });
+
+  it('Should throw if architecture is not supported', async () => {
+    setConfig('architecture', AppSupportedArchitecturesEnum.ARM64);
+    const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    try {
+      checkAppRequirements(appInfo.id);
+      expect(true).toBe(false);
+    } catch (e) {
+      // @ts-ignore
+      expect(e.message).toEqual(`App ${appInfo.id} is not supported on this architecture`);
+    }
   });
 });
 
@@ -60,7 +89,7 @@ describe('getEnvMap', () => {
   });
 });
 
-describe('checkEnvFile', () => {
+describe('Test: checkEnvFile', () => {
   let app1: AppInfo;
 
   beforeEach(async () => {
@@ -90,6 +119,25 @@ describe('checkEnvFile', () => {
       }
     }
   });
+
+  it('Should throw if config.json is incorrect', async () => {
+    // arrange
+    fs.writeFileSync(`/app/storage/app-data/${app1.id}/config.json`, 'invalid json');
+    const { appInfo } = await createApp({});
+
+    // act
+    try {
+      await checkEnvFile(appInfo.id);
+      expect(true).toBe(false);
+    } catch (e: unknown) {
+      if (e instanceof Error) {
+        expect(e).toBeDefined();
+        expect(e.message).toBe(`App ${appInfo.id} has invalid config.json file`);
+      } else {
+        fail('Should throw an error');
+      }
+    }
+  });
 });
 
 describe('Test: generateEnvFile', () => {
@@ -163,7 +211,7 @@ describe('Test: generateEnvFile', () => {
     } catch (e: unknown) {
       if (e instanceof Error) {
         expect(e).toBeDefined();
-        expect(e.message).toBe('App not-existing-app not found');
+        expect(e.message).toBe('App not-existing-app has invalid config.json file');
       } else {
         fail('Should throw an error');
       }
@@ -235,6 +283,18 @@ describe('getAvailableApps', () => {
 
     expect(availableApps.length).toBe(2);
   });
+
+  it('Should not return apps with invalid config.json', async () => {
+    const { appInfo: app1, MockFiles: MockFiles1 } = await createApp({ installed: true });
+    const { MockFiles: MockFiles2 } = await createApp({});
+    MockFiles1[`/runtipi/repos/repo-id/apps/${app1.id}/config.json`] = 'invalid json';
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(MockFiles1, MockFiles2));
+
+    const availableApps = await getAvailableApps();
+
+    expect(availableApps.length).toBe(1);
+  });
 });
 
 describe('Test: getAppInfo', () => {
@@ -257,9 +317,7 @@ describe('Test: getAppInfo', () => {
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
 
-    const newConfig = {
-      id: faker.random.alphaNumeric(32),
-    };
+    const newConfig = createAppConfig();
 
     fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
 
@@ -273,10 +331,7 @@ describe('Test: getAppInfo', () => {
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
 
-    const newConfig = {
-      id: faker.random.alphaNumeric(32),
-      available: true,
-    };
+    const newConfig = createAppConfig();
 
     fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
 
@@ -363,6 +418,24 @@ describe('getUpdateInfo', () => {
 
     expect(updateInfo).toBeNull();
   });
+
+  it('Should return null if config.json is invalid', async () => {
+    const { appInfo, MockFiles } = await createApp({ installed: true });
+    MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const updateInfo = await getUpdateInfo(appInfo.id, 1);
+
+    expect(updateInfo).toBeNull();
+  });
+
+  it('should return null if version is not provided', async () => {
+    // @ts-ignore
+    const updateInfo = await getUpdateInfo(app1.id);
+
+    expect(updateInfo).toBe(null);
+  });
 });
 
 describe('Test: ensureAppFolder', () => {

+ 1 - 1
packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts

@@ -182,7 +182,7 @@ describe('InstallApp', () => {
       variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
 
-    expect(errors?.[0].message).toBe('App not-existing not found');
+    expect(errors?.[0].message).toBe('App not-existing has invalid config.json file');
     expect(data?.installApp).toBeUndefined();
   });
 

+ 53 - 0
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -155,6 +155,8 @@ describe('Install app', () => {
   });
 
   it('Should throw if architecure is not supported', async () => {
+    // arrange
+    setConfig('architecture', AppSupportedArchitecturesEnum.AMD64);
     const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
@@ -185,6 +187,29 @@ describe('Install app', () => {
 
     expect(app).toBeDefined();
   });
+
+  it('Should throw if config.json is not valid', async () => {
+    // arrange
+    const { MockFiles, appInfo } = await createApp({});
+    MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'test';
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    // act & assert
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
+  });
+
+  it('Should throw if config.json is not valid after folder copy', async () => {
+    // arrange
+    jest.spyOn(fs, 'copySync').mockImplementationOnce(() => {});
+    const { MockFiles, appInfo } = await createApp({});
+    MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'test';
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    // act & assert
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
+  });
 });
 
 describe('Uninstall app', () => {
@@ -404,6 +429,17 @@ describe('Update app config', () => {
     await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
     await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
   });
+
+  it('Should throw if app has invalid config.json', async () => {
+    const { appInfo, MockFiles } = await createApp({ installed: true });
+    MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'invalid json';
+
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(MockFiles));
+    fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/config.json`, 'test');
+
+    await expect(AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json`);
+  });
 });
 
 describe('Get app config', () => {
@@ -504,6 +540,23 @@ describe('List apps', () => {
     expect(apps).toBeDefined();
     expect(apps.length).toBe(1);
   });
+
+  it('Should not list app with invalid config.json', async () => {
+    // Arrange
+    const { MockFiles: mockApp1, appInfo } = await createApp({});
+    const { MockFiles: mockApp2 } = await createApp({});
+    const MockFiles = Object.assign(mockApp1, mockApp2);
+    MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    // Act
+    const { apps } = await AppsService.listApps();
+
+    // Assert
+    expect(apps).toBeDefined();
+    expect(apps.length).toBe(1);
+  });
 });
 
 describe('Start all apps', () => {

+ 97 - 47
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,23 +1,58 @@
 import crypto from 'crypto';
 import fs from 'fs-extra';
+import { z } from 'zod';
 import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
-import { AppInfo, AppStatusEnum } from './apps.types';
+import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from './apps.types';
 import logger from '../../config/logger/logger';
 import { getConfig } from '../../core/config/TipiConfig';
 import { AppEntityType } from './app.types';
-
-export const checkAppRequirements = async (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
-
-  if (!configFile) {
-    throw new Error(`App ${appName} not found`);
+import { notEmpty } from '../../helpers/helpers';
+
+const formFieldSchema = z.object({
+  type: z.nativeEnum(FieldTypes),
+  label: z.string(),
+  placeholder: z.string().optional(),
+  max: z.number().optional(),
+  min: z.number().optional(),
+  hint: z.string().optional(),
+  required: z.boolean().optional().default(false),
+  env_variable: z.string(),
+});
+
+export const appInfoSchema = z.object({
+  id: z.string(),
+  available: z.boolean(),
+  port: z.number().min(1).max(65535),
+  name: z.string(),
+  description: z.string().optional().default(''),
+  version: z.string().optional().default('latest'),
+  tipi_version: z.number(),
+  short_desc: z.string(),
+  author: z.string(),
+  source: z.string(),
+  website: z.string().optional(),
+  categories: z.nativeEnum(AppCategoriesEnum).array(),
+  url_suffix: z.string().optional(),
+  form_fields: z.array(formFieldSchema).optional().default([]),
+  https: z.boolean().optional().default(false),
+  exposable: z.boolean().optional().default(false),
+  no_gui: z.boolean().optional().default(false),
+  supported_architectures: z.nativeEnum(AppSupportedArchitecturesEnum).array().optional(),
+});
+
+export const checkAppRequirements = (appName: string) => {
+  const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
+
+  if (!parsedConfig.success) {
+    throw new Error(`App ${appName} has invalid config.json file`);
   }
 
-  if (configFile?.supported_architectures && !configFile.supported_architectures.includes(getConfig().architecture)) {
+  if (parsedConfig.data.supported_architectures && !parsedConfig.data.supported_architectures.includes(getConfig().architecture)) {
     throw new Error(`App ${appName} is not supported on this architecture`);
   }
 
-  return true;
+  return parsedConfig.data;
 };
 
 export const getEnvMap = (appName: string): Map<string, string> => {
@@ -34,10 +69,16 @@ export const getEnvMap = (appName: string): Map<string, string> => {
 };
 
 export const checkEnvFile = (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${appName}/config.json`);
+  const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
+
+  if (!parsedConfig.success) {
+    throw new Error(`App ${appName} has invalid config.json file`);
+  }
+
   const envMap = getEnvMap(appName);
 
-  configFile?.form_fields?.forEach((field) => {
+  parsedConfig.data.form_fields.forEach((field) => {
     const envVar = field.env_variable;
     const envVarValue = envMap.get(envVar);
 
@@ -54,17 +95,18 @@ const getEntropy = (name: string, length: number) => {
 };
 
 export const generateEnvFile = (app: AppEntityType) => {
-  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
+  const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(configFile);
 
-  if (!configFile) {
-    throw new Error(`App ${app.id} not found`);
+  if (!parsedConfig.success) {
+    throw new Error(`App ${app.id} has invalid config.json file`);
   }
 
   const baseEnvFile = readFile('/runtipi/.env').toString();
-  let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
+  let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`;
   const envMap = getEnvMap(app.id);
 
-  configFile.form_fields?.forEach((field) => {
+  parsedConfig.data.form_fields.forEach((field) => {
     const formValue = app.config[field.env_variable];
     const envVar = field.env_variable;
 
@@ -89,7 +131,7 @@ export const generateEnvFile = (app: AppEntityType) => {
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += 'APP_PROTOCOL=https\n';
   } else {
-    envFile += `APP_DOMAIN=${getConfig().internalIp}:${configFile.port}\n`;
+    envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
   }
 
   // Create app-data folder if it doesn't exist
@@ -100,20 +142,28 @@ export const generateEnvFile = (app: AppEntityType) => {
   writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
 };
 
-export const getAvailableApps = async (): Promise<string[]> => {
-  const apps: string[] = [];
-
+export const getAvailableApps = async (): Promise<AppInfo[]> => {
   const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
 
-  appsDir.forEach((app) => {
-    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
+  const skippedFiles = ['__tests__', 'docker-compose.common.yml', 'schema.json'];
+
+  const apps = appsDir
+    .map((app) => {
+      if (skippedFiles.includes(app)) return null;
+
+      const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile?.available) {
-        apps.push(app);
+      if (!parsedConfig.success) {
+        logger.error(`App ${JSON.stringify(app)} has invalid config.json`);
+      } else if (parsedConfig.data.available) {
+        const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${parsedConfig.data.id}/metadata/description.md`);
+        return { ...parsedConfig.data, description };
       }
-    }
-  });
+
+      return null;
+    })
+    .filter(notEmpty);
 
   return apps;
 };
@@ -124,23 +174,22 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
 
     if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/apps/${id}/config.json`);
+      const configFile = readJsonFile(`/runtipi/apps/${id}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile) {
-        configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
+      if (parsedConfig.success && parsedConfig.data.available) {
+        const description = readFile(`/runtipi/apps/${id}/metadata/description.md`);
+        return { ...parsedConfig.data, description };
       }
-
-      return configFile;
     }
-    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
-      const configFile = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
 
-      if (configFile) {
-        configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
-      }
+    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
+      const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+      const parsedConfig = appInfoSchema.safeParse(configFile);
 
-      if (configFile?.available) {
-        return configFile;
+      if (parsedConfig.success && parsedConfig.data.available) {
+        const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
+        return { ...parsedConfig.data, description };
       }
     }
 
@@ -158,17 +207,18 @@ export const getUpdateInfo = async (id: string, version?: number) => {
     return null;
   }
 
-  const repoConfig = readJsonFile<AppInfo>(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+  const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+  const parsedConfig = appInfoSchema.safeParse(repoConfig);
 
-  if (!repoConfig?.tipi_version) {
-    return null;
+  if (parsedConfig.success) {
+    return {
+      current: version || 0,
+      latest: parsedConfig.data.tipi_version,
+      dockerVersion: parsedConfig.data.version,
+    };
   }
 
-  return {
-    current: version || 0,
-    latest: repoConfig?.tipi_version,
-    dockerVersion: repoConfig?.version,
-  };
+  return null;
 };
 
 export const ensureAppFolder = (appName: string, cleanup = false) => {

+ 30 - 27
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,7 +1,7 @@
 import validator from 'validator';
 import { Not } from 'typeorm';
-import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder } from './apps.helpers';
+import { createFolder, readJsonFile } from '../fs/fs.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
@@ -106,18 +106,19 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     }
 
     ensureAppFolder(id, true);
-    const appIsValid = await checkAppRequirements(id);
-
-    if (!appIsValid) {
-      throw new Error(`App ${id} requirements not met`);
-    }
+    checkAppRequirements(id);
 
     // Create app folder
     createFolder(`/app/storage/app-data/${id}`);
 
-    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const parsedAppInfo = appInfoSchema.safeParse(appInfo);
 
-    if (!appInfo?.exposable && exposed) {
+    if (!parsedAppInfo.success) {
+      throw new Error(`App ${id} has invalid config.json file`);
+    }
+
+    if (!parsedAppInfo.data.exposable && exposed) {
       throw new Error(`App ${id} is not exposable`);
     }
 
@@ -128,7 +129,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
       }
     }
 
-    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0), exposed: exposed || false, domain }).save();
+    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain }).save();
 
     // Create env file
     generateEnvFile(app);
@@ -153,14 +154,9 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
  * @returns - list of all apps available
  */
 const listApps = async (): Promise<ListAppsResonse> => {
-  const folders: string[] = await getAvailableApps();
-
-  const apps: AppInfo[] = folders.map((app) => readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)).filter(Boolean);
+  const apps = await getAvailableApps();
 
-  const filteredApps = filterApps(apps).map((app) => {
-    const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
-    return { ...app, description };
-  });
+  const filteredApps = filterApps(apps);
 
   return { apps: filteredApps, total: apps.length };
 };
@@ -182,9 +178,20 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     throw new Error(`Domain ${domain} is not valid`);
   }
 
-  const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
+  let app = await App.findOne({ where: { id } });
+
+  if (!app) {
+    throw new Error(`App ${id} not found`);
+  }
+
+  const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
+  const parsedAppInfo = appInfoSchema.safeParse(appInfo);
+
+  if (!parsedAppInfo.success) {
+    throw new Error(`App ${id} has invalid config.json`);
+  }
 
-  if (!appInfo?.exposable && exposed) {
+  if (!parsedAppInfo.data.exposable && exposed) {
     throw new Error(`App ${id} is not exposable`);
   }
 
@@ -195,12 +202,6 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     }
   }
 
-  let app = await App.findOne({ where: { id } });
-
-  if (!app) {
-    throw new Error(`App ${id} not found`);
-  }
-
   await App.update({ id }, { config: form, exposed: exposed || false, domain });
   app = (await App.findOne({ where: { id } })) as App;
 
@@ -309,8 +310,10 @@ const updateApp = async (id: string) => {
   const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
 
   if (success) {
-    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
-    await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
+    const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
+    const parsedAppInfo = appInfoSchema.parse(appInfo);
+
+    await App.update({ id }, { status: AppStatusEnum.RUNNING, version: parsedAppInfo.tipi_version });
   } else {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);

+ 6 - 9
packages/system-api/src/modules/apps/apps.types.ts

@@ -76,6 +76,9 @@ class FormField {
   @Field(() => String, { nullable: true })
   hint?: string;
 
+  @Field(() => String, { nullable: true })
+  placeholder?: string;
+
   @Field(() => Boolean, { nullable: true })
   required?: boolean;
 
@@ -83,12 +86,6 @@ class FormField {
   env_variable!: string;
 }
 
-@ObjectType()
-class Requirements {
-  @Field(() => [Number], { nullable: true })
-  ports?: number[];
-}
-
 @ObjectType()
 class AppInfo {
   @Field(() => String)
@@ -130,15 +127,15 @@ class AppInfo {
   @Field(() => [FormField])
   form_fields?: FormField[];
 
-  @Field(() => GraphQLJSONObject, { nullable: true })
-  requirements?: Requirements;
-
   @Field(() => Boolean, { nullable: true })
   https?: boolean;
 
   @Field(() => Boolean, { nullable: true })
   exposable?: boolean;
 
+  @Field(() => Boolean, { nullable: true })
+  no_gui?: boolean;
+
   @Field(() => [AppSupportedArchitecturesEnum], { nullable: true })
   supported_architectures?: AppSupportedArchitecturesEnum[];
 }

+ 1 - 1
packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts

@@ -7,8 +7,8 @@ import { setupConnection, teardownConnection } from '../../../test/connection';
 import { gcall } from '../../../test/gcall';
 import { loginMutation, registerMutation } from '../../../test/mutations';
 import { isConfiguredQuery, MeQuery, refreshTokenQuery } from '../../../test/queries';
-import User from '../user.entity';
 import { TokenResponse } from '../auth.types';
+import User from '../user.entity';
 import { createUser } from './user.factory';
 
 jest.mock('redis');

+ 1 - 1
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -1,6 +1,6 @@
 import fs from 'fs-extra';
 
-export const readJsonFile = <T>(path: string): T | null => {
+export const readJsonFile = (path: string): unknown | null => {
   try {
     const rawFile = fs.readFileSync(path).toString();
 

+ 3 - 0
packages/system-api/src/server.ts

@@ -5,6 +5,8 @@ import { ApolloServer } from 'apollo-server-express';
 import { createServer } from 'http';
 import { ZodError } from 'zod';
 import cors, { CorsOptions } from 'cors';
+import Keyv from 'keyv';
+import { KeyvAdapter } from '@apollo/utils.keyvadapter';
 import { createSchema } from './schema';
 import { ApolloLogs } from './config/logger/apollo.logger';
 import logger from './config/logger/logger';
@@ -68,6 +70,7 @@ const main = async () => {
       schema,
       context: ({ req, res }): MyContext => ({ req, res }),
       plugins,
+      cache: new KeyvAdapter(new Keyv(`redis://${getConfig().REDIS_HOST}:6379`)),
     });
 
     await apolloServer.start();

+ 0 - 1
packages/system-api/src/test/mutations/installApp.graphql

@@ -20,7 +20,6 @@ mutation InstallApp($input: AppInputType!) {
         required
         env_variable
       }
-      requirements
     }
   }
 }

+ 0 - 1
packages/system-api/src/test/mutations/startApp.graphql

@@ -21,7 +21,6 @@ mutation StartApp($id: String!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/mutations/stopApp.graphql

@@ -20,7 +20,6 @@ mutation StopApp($id: String!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/mutations/uninstallApp.graphql

@@ -20,7 +20,6 @@ mutation UninstallApp($id: String!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/mutations/updateApp.graphql

@@ -20,7 +20,6 @@ mutation UpdateApp($id: String!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/mutations/updateAppConfig.graphql

@@ -20,7 +20,6 @@ mutation UpdateAppConfig($input: AppInputType!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/queries/getApp.graphql

@@ -19,7 +19,6 @@ query GetApp($id: String!) {
         required
         env_variable
       }
-      requirements
     }
     updateInfo {
       current

+ 0 - 1
packages/system-api/src/test/queries/installedApps.graphql

@@ -24,7 +24,6 @@ query {
         required
         env_variable
       }
-      requirements
     }
   }
 }

+ 0 - 1
packages/system-api/src/test/queries/listAppInfos.graphql

@@ -17,7 +17,6 @@ query {
         required
         env_variable
       }
-      requirements
     }
     total
   }

Різницю між файлами не показано, бо вона завелика
+ 254 - 464
pnpm-lock.yaml


Деякі файли не було показано, через те що забагато файлів було змінено