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
This commit is contained in:
parent
406a6925eb
commit
6117bf837c
39 changed files with 1040 additions and 1000 deletions
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.4",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --colors",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
|
||||
if (envVarValue) {
|
||||
form[field.env_variable] = envVarValue;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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';
|
||||
import { notEmpty } from '../../helpers/helpers';
|
||||
|
||||
export const checkAppRequirements = async (appName: string) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
|
||||
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(),
|
||||
});
|
||||
|
||||
if (!configFile) {
|
||||
throw new Error(`App ${appName} not found`);
|
||||
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'];
|
||||
|
||||
if (configFile?.available) {
|
||||
apps.push(app);
|
||||
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 (!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`);
|
||||
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
|
||||
const parsedConfig = appInfoSchema.safeParse(configFile);
|
||||
|
||||
if (configFile) {
|
||||
configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
|
|
@ -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 = await getAvailableApps();
|
||||
|
||||
const apps: AppInfo[] = folders.map((app) => readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)).filter(Boolean);
|
||||
|
||||
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 (!appInfo?.exposable && exposed) {
|
||||
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 (!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}`);
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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,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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -20,7 +20,6 @@ mutation InstallApp($input: AppInputType!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@ mutation StartApp($id: String!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -20,7 +20,6 @@ mutation StopApp($id: String!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -20,7 +20,6 @@ mutation UninstallApp($id: String!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -20,7 +20,6 @@ mutation UpdateApp($id: String!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -20,7 +20,6 @@ mutation UpdateAppConfig($input: AppInputType!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -19,7 +19,6 @@ query GetApp($id: String!) {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
updateInfo {
|
||||
current
|
||||
|
|
|
@ -24,7 +24,6 @@ query {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ query {
|
|||
required
|
||||
env_variable
|
||||
}
|
||||
requirements
|
||||
}
|
||||
total
|
||||
}
|
||||
|
|
1429
pnpm-lock.yaml
1429
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue