refactor: import from packages/shared and remove duplicate code

This commit is contained in:
Nicolas Meienberger 2023-08-15 22:53:24 +02:00 committed by Nicolas Meienberger
parent da9fa0d72a
commit a15a4f602a
35 changed files with 57 additions and 622 deletions

View file

@ -13,9 +13,11 @@ FROM builder_base AS builder
WORKDIR /app
COPY ./pnpm-lock.yaml ./
COPY ./pnpm-workspace.yaml ./
RUN pnpm fetch --no-scripts
COPY ./package*.json ./
COPY ./packages ./packages
RUN pnpm install -r --prefer-offline
COPY ./src ./src

View file

@ -11,6 +11,7 @@ COPY ./pnpm-lock.yaml ./
RUN pnpm fetch --ignore-scripts
COPY ./package*.json ./
COPY ./packages ./packages
RUN pnpm install -r --prefer-offline

View file

@ -18,7 +18,7 @@ const onRebuild = () => {
}
};
const included = ['express', 'pg', '@runtipi/postgres-migrations', 'connect-redis', 'express-session', 'drizzle-orm'];
const included = ['express', 'pg', '@runtipi/postgres-migrations', 'connect-redis', 'express-session', 'drizzle-orm', '@runtipi/shared'];
const excluded = ['pg-native', '*required-server-files.json'];
const external = Object.keys(pkg.dependencies || {}).filter((dep) => !included.includes(dep));
external.push(...excluded);

View file

@ -3,6 +3,7 @@ const nextConfig = {
swcMinify: true,
output: 'standalone',
reactStrictMode: true,
transpilePackages: ['@runtipi/shared'],
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,
TIPI_VERSION: process.env.TIPI_VERSION,

View file

@ -1,5 +1,5 @@
{
"watch": ["src/server"],
"watch": ["src/server", "packages/shared"],
"exec": "node ./esbuild.js dev",
"ext": "js ts"
}

View file

@ -4,11 +4,11 @@ import { IconDownload } from '@tabler/icons-react';
import { Tooltip } from 'react-tooltip';
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import type { AppInfo } from '@runtipi/shared';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
import styles from './AppTile.module.scss';
import { AppInfo } from '../../core/types';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;

View file

@ -16,7 +16,7 @@ import {
IconTool,
IconUsers,
} from '@tabler/icons-react';
import { AppCategory } from './types';
import { AppCategory } from '@runtipi/shared';
type AppCategoryEntry = {
id: AppCategory;

View file

@ -1,7 +1,4 @@
import * as Router from '../../server/routers/_app';
export type { FormField, AppInfo } from '@/server/services/apps/apps.helpers';
export type { AppCategory } from '@/server/services/apps/apps.types';
export type App = Omit<Router.RouterOutput['app']['getApp'], 'info'>;
export type AppWithInfo = Router.RouterOutput['app']['getApp'];

View file

@ -1,7 +1,7 @@
import { faker } from '@faker-js/faker';
import type { AppStatus } from '@/server/db/schema';
import { APP_CATEGORIES } from '../../../server/services/apps/apps.types';
import { App, AppCategory, AppInfo, AppWithInfo } from '../../core/types';
import { AppInfo, AppCategory, APP_CATEGORIES } from '@runtipi/shared';
import { App, AppWithInfo } from '../../core/types';
const randomCategory = (): AppCategory[] => {
const categories = Object.values(APP_CATEGORIES);

View file

@ -2,8 +2,8 @@ import clsx from 'clsx';
import Link from 'next/link';
import React from 'react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { AppCategory } from '../../../../core/types';
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
import styles from './AppStoreTile.module.scss';

View file

@ -2,8 +2,8 @@ import React from 'react';
import Select, { SingleValue, OptionProps, ControlProps, components } from 'react-select';
import { Icon } from '@tabler/icons-react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { APP_CATEGORIES } from '../../../../core/constants';
import { AppCategory } from '../../../../core/types';
import { useUIStore } from '../../../../state/uiStore';
const { Option, Control } = components;

View file

@ -3,33 +3,33 @@ import { limitText, sortTable } from '../table.helpers';
import { AppTableData } from '../table.types';
describe('sortTable function', () => {
const app = createAppConfig({ name: 'a', categories: ['social'] });
const app2 = createAppConfig({ name: 'b', categories: ['network', 'automation'] });
const app3 = createAppConfig({ name: 'c', categories: ['network'] });
const app = createAppConfig({ id: 'a', name: 'a', categories: ['social'] });
const app2 = createAppConfig({ id: 'b', name: 'B', categories: ['network', 'automation'] });
const app3 = createAppConfig({ id: 'c', name: 'c', categories: ['network'] });
// Randomize the order of the apps
const data: AppTableData = [app3, app, app2];
it('should sort by name in ascending order', () => {
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: '' });
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: '' });
expect(sortedData).toEqual([app, app2, app3]);
});
it('should sort by name in descending order', () => {
const sortedData = sortTable({ data, direction: 'desc', col: 'name', search: '' });
const sortedData = sortTable({ data, direction: 'desc', col: 'id', search: '' });
expect(sortedData).toEqual([app3, app2, app]);
});
it('should filter by search term', () => {
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: 'b' });
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: 'b' });
expect(sortedData).toEqual([app2]);
});
it('should filter by category', () => {
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: '', category: 'automation' });
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: '', category: 'automation' });
expect(sortedData).toEqual([app2]);
});

View file

@ -1,4 +1,4 @@
import { AppCategory, AppInfo } from '../../../core/types';
import { AppCategory, AppInfo } from '@runtipi/shared';
import { AppTableData } from './table.types';
type SortParams = {

View file

@ -1,4 +1,4 @@
import { AppInfo } from '../../../core/types';
import { AppInfo } from '@runtipi/shared';
export type SortableColumns = keyof Pick<AppInfo, 'id'>;
export type SortDirection = 'asc' | 'desc';

View file

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { AppCategory } from '../../../core/types';
import { AppCategory } from '@runtipi/shared';
import { SortableColumns } from '../helpers/table.types';
type Store = {

View file

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { AppActions } from './AppActions';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
import { AppInfo } from '../../../../core/types';
afterEach(cleanup);

View file

@ -2,9 +2,9 @@ import { IconExternalLink } from '@tabler/icons-react';
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
import Markdown from '../../../components/Markdown/Markdown';
import { AppInfo } from '../../../core/types';
interface IProps {
info: AppInfo;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { fromPartial } from '@total-typescript/shoehorn';
import { FormField } from '@runtipi/shared';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { FormField } from '../../../../core/types';
import { InstallForm } from './InstallForm';
describe('Test: InstallForm', () => {

View file

@ -5,11 +5,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tooltip } from 'react-tooltip';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import { type FormField, type AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { Switch } from '../../../../components/ui/Switch';
import { Input } from '../../../../components/ui/Input';
import { validateAppConfig } from '../../utils/validators';
import { type FormField, type AppInfo } from '../../../../core/types';
interface IProps {
formFields: FormField[];

View file

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { InstallModal } from './InstallModal';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { AppInfo } from '../../../../core/types';
describe('InstallModal', () => {
const app = {

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from '../InstallForm';
import { AppInfo } from '../../../../core/types';
import { FormValues } from '../InstallForm/InstallForm';
interface IProps {

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { AppInfo } from '../../../core/types';
interface IProps {
info: AppInfo;

View file

@ -2,8 +2,8 @@ import { IconAlertTriangle } from '@tabler/icons-react';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { AppInfo } from '../../../core/types';
interface IProps {
info: AppInfo;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { AppInfo } from '../../../../core/types';
interface IProps {
newVersion: string;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from './InstallForm';
import { AppInfo } from '../../../core/types';
import { FormValues } from './InstallForm/InstallForm';
interface IProps {

View file

@ -1,4 +1,4 @@
import { FormField } from '../../../../core/types';
import { FormField } from '@runtipi/shared';
import { validateAppConfig, validateField } from './validators';
describe('Test: validateField', () => {

View file

@ -1,6 +1,6 @@
import validator from 'validator';
import { useUIStore } from '@/client/state/uiStore';
import type { FormField } from '../../../../core/types';
import type { FormField } from '@runtipi/shared';
export const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
const { translator } = useUIStore.getState();

View file

@ -57,7 +57,7 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
<h2 className="h2 text-center mb-3">{t('auth.reset-password.title')}</h2>
<p>{t('auth.reset-password.instructions')}</p>
<pre>
<code>./scripts/reset-password.sh</code>
<code>./runtipi-cli reset-password</code>
</pre>
</>
);

View file

@ -22,6 +22,6 @@ describe('Test: Dashboard', () => {
},
};
render(<DashboardContainer data={data} />);
render(<DashboardContainer data={data} isLoading={false} />);
});
});

View file

@ -5,37 +5,6 @@ import { readJsonFile } from '../../common/fs.helpers';
jest.mock('fs-extra');
jest.mock('next/config', () =>
jest.fn(() => ({
serverRuntimeConfig: {
DNS_IP: '1.1.1.1',
},
})),
);
// eslint-disable-next-line
import nextConfig from 'next/config';
describe('Test: process.env', () => {
it('should return config from .env', () => {
const config = new TipiConfig().getConfig();
expect(config).toBeDefined();
expect(config.dnsIp).toBe('1.1.1.1');
});
it('should throw an error if there are invalid values', () => {
// @ts-expect-error - We are mocking next/config
nextConfig.mockImplementationOnce(() => ({
serverRuntimeConfig: {
DNS_IP: 'invalid',
},
}));
expect(() => new TipiConfig().getConfig()).toThrow();
});
});
describe('Test: getConfig', () => {
it('It should return config from .env', () => {
// arrange

View file

@ -1,58 +1,10 @@
import { z } from 'zod';
import { envSchema, settingsSchema } from '@runtipi/shared';
import fs from 'fs-extra';
import nextConfig from 'next/config';
import { readJsonFile } from '../../common/fs.helpers';
import { Logger } from '../Logger';
export const ARCHITECTURES = {
ARM: 'arm',
ARM64: 'arm64',
AMD64: 'amd64',
} as const;
export type Architecture = (typeof ARCHITECTURES)[keyof typeof ARCHITECTURES];
const configSchema = z.object({
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
REDIS_HOST: z.string(),
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
architecture: z.nativeEnum(ARCHITECTURES),
dnsIp: z.string().ip().trim(),
rootFolder: z.string(),
internalIp: z.string(),
version: z.string(),
jwtSecret: z.string(),
appsRepoId: z.string(),
appsRepoUrl: z.string().url().trim(),
domain: z.string().trim(),
localDomain: z.string().trim(),
storagePath: z
.string()
.trim()
.optional()
.transform((value) => {
if (!value) return undefined;
return value?.replace(/\s/g, '');
}),
postgresHost: z.string(),
postgresDatabase: z.string(),
postgresUsername: z.string(),
postgresPassword: z.string(),
postgresPort: z.number(),
demoMode: z
.string()
.or(z.boolean())
.optional()
.transform((value) => {
if (typeof value === 'boolean') return value;
return value === 'true';
}),
});
export const settingsSchema = configSchema
.partial()
.pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true })
.and(z.object({ port: z.number(), sslPort: z.number(), listenIp: z.string().ip().trim() }).partial());
type TipiSettingsType = z.infer<typeof settingsSchema>;
const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
@ -64,11 +16,11 @@ const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
export class TipiConfig {
private static instance: TipiConfig;
private config: z.infer<typeof configSchema>;
private config: z.infer<typeof envSchema>;
constructor() {
const conf = { ...process.env, ...nextConfig()?.serverRuntimeConfig };
const envConfig: z.infer<typeof configSchema> = {
const envConfig: z.infer<typeof envSchema> = {
postgresHost: conf.POSTGRES_HOST,
postgresDatabase: conf.POSTGRES_DBNAME,
postgresUsername: conf.POSTGRES_USERNAME,
@ -92,10 +44,10 @@ export class TipiConfig {
};
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
const parsedFileConfig = configSchema.partial().safeParse(fileConfig);
const parsedFileConfig = envSchema.partial().safeParse(fileConfig);
if (parsedFileConfig.success) {
const parsedConfig = configSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
const parsedConfig = envSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
if (parsedConfig.success) {
this.config = parsedConfig.data;
} else {
@ -133,18 +85,18 @@ export class TipiConfig {
return this.config;
}
public async setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
public async setConfig<T extends keyof typeof envSchema.shape>(key: T, value: z.infer<typeof envSchema>[T], writeFile = false) {
const newConf: z.infer<typeof envSchema> = { ...this.getConfig() };
newConf[key] = value;
this.config = configSchema.parse(newConf);
this.config = envSchema.parse(newConf);
if (writeFile) {
const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
const parsedConf = configSchema.partial().parse(currentJsonConf);
const parsedConf = envSchema.partial().parse(currentJsonConf);
parsedConf[key] = value;
const parsed = configSchema.partial().parse(parsedConf);
const parsed = envSchema.partial().parse(parsedConf);
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed));
}
@ -155,7 +107,7 @@ export class TipiConfig {
throw new Error('Cannot update settings in demo mode');
}
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
const newConf: z.infer<typeof envSchema> = { ...this.getConfig() };
const parsed = settingsSchema.safeParse(settings);
if (!parsed.success) {
@ -165,11 +117,11 @@ export class TipiConfig {
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed.data));
this.config = configSchema.parse({ ...newConf, ...parsed.data });
this.config = envSchema.parse({ ...newConf, ...parsed.data });
}
}
export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) => {
export const setConfig = <T extends keyof typeof envSchema.shape>(key: T, value: z.infer<typeof envSchema>[T], writeFile = false) => {
return TipiConfig.getInstance().setConfig(key, value, writeFile);
};

View file

@ -1,7 +1,7 @@
import { inferRouterOutputs } from '@trpc/server';
import { settingsSchema } from '@runtipi/shared';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { SystemServiceClass } from '../../services/system';
import { settingsSchema } from '../../core/TipiConfig/TipiConfig';
import * as TipiConfig from '../../core/TipiConfig';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;

View file

@ -1,10 +1,10 @@
import fs from 'fs-extra';
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
import { fromAny } from '@total-typescript/shoehorn';
import { faker } from '@faker-js/faker';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { appInfoSchema } from '@runtipi/shared';
import { setConfig } from '../../core/TipiConfig';
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
import { checkAppRequirements, checkEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
import { createAppConfig, insertApp } from '../../tests/apps.factory';
let db: TestDatabase;
@ -120,166 +120,6 @@ describe('Test: appInfoSchema', () => {
});
});
describe('Test: generateEnvFile()', () => {
it('Should generate an env file', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
const app = await insertApp({}, appConfig, db);
const fakevalue = faker.string.alphanumeric(10);
// act
await generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
});
it('Should automatically generate value for random field', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] });
const app = await insertApp({}, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBeDefined();
expect(envmap.get('RANDOM_FIELD')).toHaveLength(32);
});
it('Should not re-generate random field if it already exists', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] });
const app = await insertApp({}, appConfig, db);
const randomField = faker.string.alphanumeric(32);
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `RANDOM_FIELD=${randomField}`);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
});
it('Should throw an error if required field is not provided', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
const app = await insertApp({}, appConfig, db);
// act & assert
await expect(generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).rejects.toThrowError('Variable test is required');
});
it('Should throw an error if app does not exist', async () => {
// act & assert
await expect(generateEnvFile(fromPartial({ id: 'not-existing-app' }))).rejects.toThrowError('App not-existing-app has invalid config.json file');
});
it('Should add APP_EXPOSED to env file if domain is provided and app is exposed', async () => {
// arrange
const domain = faker.internet.domainName();
const appConfig = createAppConfig();
const app = await insertApp({ domain, exposed: true }, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBe('true');
expect(envmap.get('APP_DOMAIN')).toBe(domain);
});
it('Should not add APP_EXPOSED if domain is not provided', async () => {
// arrange
const appConfig = createAppConfig();
const app = await insertApp({ exposed: true }, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
});
it('Should not add APP_EXPOSED if app is not exposed', async () => {
// arrange
const appConfig = createAppConfig();
const app = await insertApp({ exposed: false, domain: faker.internet.domainName() }, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should create app folder if it does not exist', async () => {
// arrange
const appConfig = createAppConfig();
const app = await insertApp({}, appConfig, db);
fs.rmSync(`/app/storage/app-data/${app.id}`, { recursive: true });
// act
await generateEnvFile(app);
// assert
expect(fs.existsSync(`/app/storage/app-data/${app.id}`)).toBe(true);
});
it('should generate vapid private and public keys if config has generate_vapid_keys set to true', async () => {
// arrange
const appConfig = createAppConfig({ generate_vapid_keys: true });
const app = await insertApp({}, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeDefined();
expect(envmap.get('VAPID_PUBLIC_KEY')).toBeDefined();
});
it('should not generate vapid private and public keys if config has generate_vapid_keys set to false', async () => {
// arrange
const appConfig = createAppConfig({ generate_vapid_keys: false });
const app = await insertApp({}, appConfig, db);
// act
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeUndefined();
expect(envmap.get('VAPID_PUBLIC_KEY')).toBeUndefined();
});
it('should not re-generate vapid private and public keys if they already exist', async () => {
// arrange
const appConfig = createAppConfig({ generate_vapid_keys: true });
const app = await insertApp({}, appConfig, db);
const vapidPrivateKey = faker.string.alphanumeric(32);
const vapidPublicKey = faker.string.alphanumeric(32);
// act
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBe(vapidPrivateKey);
expect(envmap.get('VAPID_PUBLIC_KEY')).toBe(vapidPublicKey);
});
});
describe('Test: getAvailableApps()', () => {
it('Should return all available apps', async () => {
// arrange
@ -359,18 +199,6 @@ describe('Test: getAppInfo()', () => {
expect(result).toBeNull();
});
it('Should throw if something goes wrong', async () => {
// arrange
jest.spyOn(fs, 'existsSync').mockImplementationOnce(() => {
throw new Error('Something went wrong');
});
const appConfig = createAppConfig();
const app = await insertApp({ status: 'missing' }, appConfig, db);
// act & assert
expect(() => getAppInfo(app.id, app.status)).toThrowError(`Error loading app: ${app.id}`);
});
it('Should return null if app does not exist', async () => {
// arrange
const app = getAppInfo(faker.lorem.word());
@ -413,63 +241,3 @@ describe('Test: getUpdateInfo()', () => {
expect(updateInfo).toEqual({ latestVersion: 0, latestDockerVersion: '0.0.0' });
});
});
describe('Test: ensureAppFolder()', () => {
it('should copy the folder from repo', async () => {
// arrange
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');
// assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('should not copy the folder if it already exists', async () => {
// arrange
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test');
// assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['docker-compose.yml']);
});
it('Should overwrite the folder if clean up is true', async () => {
// arrange
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test', true);
// assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('Should delete folder if it exists but has no docker-compose.yml file', async () => {
// arrange
const randomFileName = `${faker.lorem.word()}.yml`;
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/test/${randomFileName}`, 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');
// assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual([randomFileName]);
});
});

View file

@ -1,61 +1,11 @@
import crypto from 'crypto';
import fs from 'fs-extra';
import { z } from 'zod';
import { App } from '@/server/db/schema';
import { envMapToString, envStringToMap, generateVapidKeys, getAppEnvMap } from '@/server/utils/env-generation';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { appInfoSchema } from '@runtipi/shared';
import { fileExists, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
import { getConfig } from '../../core/TipiConfig';
import { Logger } from '../../core/Logger';
import { notEmpty } from '../../common/typescript.helpers';
import { ARCHITECTURES } from '../../core/TipiConfig/TipiConfig';
const formFieldSchema = z.object({
type: z.nativeEnum(FIELD_TYPES).catch(() => FIELD_TYPES.TEXT),
label: z.string(),
placeholder: z.string().optional(),
max: z.number().optional(),
min: z.number().optional(),
hint: z.string().optional(),
options: z.object({ label: z.string(), value: z.string() }).array().optional(),
required: z.boolean().optional().default(false),
default: z.union([z.boolean(), z.string()]).optional(),
regex: z.string().optional(),
pattern_error: z.string().optional(),
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(),
force_expose: z.boolean().optional().default(false),
generate_vapid_keys: z.boolean().optional().default(false),
categories: z
.nativeEnum(APP_CATEGORIES)
.array()
.catch((ctx) => {
Logger.warn(`Invalid categories "${JSON.stringify(ctx.input)}" defaulting to utilities`);
return [APP_CATEGORIES.UTILITIES];
}),
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(ARCHITECTURES).array().optional(),
});
export type AppInfo = z.infer<typeof appInfoSchema>;
export type FormField = z.infer<typeof formFieldSchema>;
/**
* This function checks the requirements for the app with the provided name.
@ -120,186 +70,6 @@ export const checkEnvFile = async (appName: string) => {
});
};
/**
* This function generates a random string of the provided length by using the SHA-256 hash algorithm.
* It takes the provided name and a seed value, concatenates them, and uses them as input for the hash algorithm.
* It then returns a substring of the resulting hash of the provided length.
*
* @param {string} name - A name used as input for the hash algorithm.
* @param {number} length - The desired length of the random string.
*/
const getEntropy = (name: string, length: number) => {
const hash = crypto.createHash('sha256');
hash.update(name + getSeed());
return hash.digest('hex').substring(0, length);
};
/**
* This function takes an input of unknown type, checks if it is an object and not null,
* and returns it as a record of unknown values, if it is not an object or is null, returns an empty object.
*
* @param {unknown} json - The input of unknown type.
* @returns {Record<string, unknown>} - The input as a record of unknown values, or an empty object if the input is not an object or is null.
*/
const castAppConfig = (json: unknown): Record<string, unknown> => {
if (typeof json !== 'object' || json === null) {
return {};
}
return json as Record<string, unknown>;
};
/**
* This function generates an env file for the provided app.
* It reads the config.json file for the app, parses it,
* and uses the app's form fields and domain to generate the env file
* if the app is exposed and has a domain set, it adds the domain to the env file,
* otherwise, it adds the internal IP address to the env file
* It also creates the app-data folder for the app if it does not exist
*
* @param {App} app - The app for which the env file is generated.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
*/
export const generateEnvFile = async (app: App) => {
const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${app.id} has invalid config.json file`);
}
const baseEnvFile = readFile('/runtipi/.env').toString();
const envMap = envStringToMap(baseEnvFile);
// Default always present env variables
envMap.set('APP_PORT', String(parsedConfig.data.port));
envMap.set('APP_ID', app.id);
const existingEnvMap = await getAppEnvMap(app.id);
if (parsedConfig.data.generate_vapid_keys) {
if (existingEnvMap.has('VAPID_PUBLIC_KEY') && existingEnvMap.has('VAPID_PRIVATE_KEY')) {
envMap.set('VAPID_PUBLIC_KEY', existingEnvMap.get('VAPID_PUBLIC_KEY') as string);
envMap.set('VAPID_PRIVATE_KEY', existingEnvMap.get('VAPID_PRIVATE_KEY') as string);
} else {
const vapidKeys = generateVapidKeys();
envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey);
envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey);
}
}
parsedConfig.data.form_fields.forEach((field) => {
const formValue = castAppConfig(app.config)[field.env_variable];
const envVar = field.env_variable;
if (formValue || typeof formValue === 'boolean') {
envMap.set(envVar, String(formValue));
} else if (field.type === 'random') {
if (existingEnvMap.has(envVar)) {
envMap.set(envVar, existingEnvMap.get(envVar) as string);
} else {
const length = field.min || 32;
const randomString = getEntropy(field.env_variable, length);
envMap.set(envVar, randomString);
}
} else if (field.required) {
throw new Error(`Variable ${field.label || field.env_variable} is required`);
}
});
if (app.exposed && app.domain) {
envMap.set('APP_EXPOSED', 'true');
envMap.set('APP_DOMAIN', app.domain);
envMap.set('APP_PROTOCOL', 'https');
envMap.set('APP_HOST', app.domain);
} else {
envMap.set('APP_DOMAIN', `${getConfig().internalIp}:${parsedConfig.data.port}`);
envMap.set('APP_HOST', getConfig().internalIp);
}
// Create app-data folder if it doesn't exist
const appDataDirectoryExists = await fs.promises.stat(`/app/storage/app-data/${app.id}`).catch(() => false);
if (!appDataDirectoryExists) {
await fs.promises.mkdir(`/app/storage/app-data/${app.id}`, { recursive: true });
}
await fs.promises.writeFile(`/app/storage/app-data/${app.id}/app.env`, envMapToString(envMap));
};
/**
* Given a template and a map of variables, this function replaces all instances of the variables in the template with their values.
*
* @param {string} template - The template to be rendered.
* @param {Map<string, string>} envMap - The map of variables and their values.
*/
const renderTemplate = (template: string, envMap: Map<string, string>) => {
let renderedTemplate = template;
envMap.forEach((value, key) => {
renderedTemplate = renderedTemplate.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return renderedTemplate;
};
/**
* Given an app, this function copies the app's data directory to the app-data folder.
* If a file with an extension of .template is found, it will be copied as a file without the .template extension and the template variables will be replaced
* by the values in the app's env file.
*
* @param {string} id - The id of the app.
*/
export const copyDataDir = async (id: string) => {
const envMap = await getAppEnvMap(id);
const appDataDirExists = (await fs.promises.lstat(`/runtipi/apps/${id}/data`).catch(() => false)) as fs.Stats;
if (!appDataDirExists || !appDataDirExists.isDirectory()) {
return;
}
const dataDir = await fs.promises.readdir(`/runtipi/apps/${id}/data`);
const processFile = async (file: string) => {
if (file.endsWith('.template')) {
const template = await fs.promises.readFile(`/runtipi/apps/${id}/data/${file}`, 'utf-8');
const renderedTemplate = renderTemplate(template, envMap);
await fs.promises.writeFile(`/app/storage/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate);
} else {
await fs.promises.copyFile(`/runtipi/apps/${id}/data/${file}`, `/app/storage/app-data/${id}/data/${file}`);
}
};
const processDir = async (path: string) => {
await fs.promises.mkdir(`/app/storage/app-data/${id}/data/${path}`, { recursive: true });
const files = await fs.promises.readdir(`/runtipi/apps/${id}/data/${path}`);
await Promise.all(
files.map(async (file) => {
const fullPath = `/runtipi/apps/${id}/data/${path}/${file}`;
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(`${path}/${file}`);
} else {
await processFile(`${path}/${file}`);
}
}),
);
};
await Promise.all(
dataDir.map(async (file) => {
const fullPath = `/runtipi/apps/${id}/data/${file}`;
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(file);
} else {
await processFile(file);
}
}),
);
};
/**
This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
parses the config file, filters out any apps that are not available and returns an array of app information.
@ -394,26 +164,3 @@ export const getAppInfo = (id: string, status?: App['status']) => {
throw new Error(`Error loading app: ${id}`);
}
};
/**
* This function ensures that the app folder for the app with the provided name exists.
* If the cleanup parameter is set to true, it deletes the app folder if it exists.
* If the app folder does not exist, it copies the app folder from the apps repository.
*
* @param {string} appName - The name of the app.
* @param {boolean} [cleanup] - A flag indicating whether to cleanup the app folder before ensuring its existence.
* @throws Will throw an error if the app folder cannot be copied from the repository
*/
export const ensureAppFolder = (appName: string, cleanup = false): void => {
if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
if (fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
// Copy from apps repo
fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
}
};

View file

@ -1,9 +1,7 @@
import fs from 'fs-extra';
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Architecture } from '../core/TipiConfig/TipiConfig';
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
import { APP_CATEGORIES } from '../services/apps/apps.types';
import { AppInfo, Architecture, appInfoSchema, APP_CATEGORIES } from '@runtipi/shared';
import { TestDatabase } from './test-utils';
import { appTable, AppStatus, App, NewApp } from '../db/schema';