浏览代码

Merge pull request #217 from meienberger/feature/storage-path

feat: customize applications storage-path
Nicolas Meienberger 2 年之前
父节点
当前提交
59a2b5cd8b

+ 1 - 1
Dockerfile.dev

@@ -4,7 +4,7 @@ WORKDIR /
 
 
 RUN apt-get update 
 RUN apt-get update 
 # Install docker
 # Install docker
-RUN apt-get install -y ca-certificates curl gnupg lsb-release
+RUN apt-get install -y ca-certificates curl gnupg lsb-release jq
 RUN mkdir -p /etc/apt/keyrings
 RUN mkdir -p /etc/apt/keyrings
 RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
 RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
 RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null
 RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null

+ 2 - 1
docker-compose.dev.yml

@@ -55,7 +55,8 @@ services:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}:/runtipi
       - ${PWD}:/runtipi
       - ${PWD}/packages/system-api/src:/api/src
       - ${PWD}/packages/system-api/src:/api/src
-      - ${PWD}/logs:/api/logs
+      - ${PWD}/logs:/app/logs
+      - ${STORAGE_PATH}:/app/storage
       # - /api/node_modules
       # - /api/node_modules
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}

+ 2 - 1
docker-compose.rc.yml

@@ -47,7 +47,8 @@ services:
       ## Docker sock
       ## Docker sock
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}:/runtipi
       - ${PWD}:/runtipi
-      - ${PWD}/logs:/api/logs
+      - ${PWD}/logs:/app/logs
+      - ${STORAGE_PATH}:/app/storage
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       TIPI_VERSION: ${TIPI_VERSION}
       TIPI_VERSION: ${TIPI_VERSION}

+ 2 - 1
docker-compose.yml

@@ -47,7 +47,8 @@ services:
       ## Docker sock
       ## Docker sock
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}:/runtipi
       - ${PWD}:/runtipi
-      - ${PWD}/logs:/api/logs
+      - ${PWD}/logs:/app/logs
+      - ${STORAGE_PATH}:/app/storage
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       TIPI_VERSION: ${TIPI_VERSION}
       TIPI_VERSION: ${TIPI_VERSION}

+ 1 - 0
packages/system-api/.gitignore

@@ -5,3 +5,4 @@ dist/
 coverage/
 coverage/
 logs/
 logs/
 sessions/
 sessions/
+.vscode

+ 6 - 3
packages/system-api/src/core/config/TipiConfig.ts

@@ -20,6 +20,7 @@ const {
   APPS_REPO_ID = '',
   APPS_REPO_ID = '',
   APPS_REPO_URL = '',
   APPS_REPO_URL = '',
   DOMAIN = '',
   DOMAIN = '',
+  STORAGE_PATH = '/runtipi',
 } = process.env;
 } = process.env;
 
 
 const configSchema = z.object({
 const configSchema = z.object({
@@ -39,6 +40,7 @@ const configSchema = z.object({
   appsRepoId: z.string(),
   appsRepoId: z.string(),
   appsRepoUrl: z.string(),
   appsRepoUrl: z.string(),
   domain: z.string(),
   domain: z.string(),
+  storagePath: z.string(),
 });
 });
 
 
 class Config {
 class Config {
@@ -64,6 +66,7 @@ class Config {
       domain: DOMAIN,
       domain: DOMAIN,
       dnsIp: '9.9.9.9',
       dnsIp: '9.9.9.9',
       status: 'RUNNING',
       status: 'RUNNING',
+      storagePath: STORAGE_PATH,
     };
     };
 
 
     const parsed = configSchema.parse({
     const parsed = configSchema.parse({
@@ -85,7 +88,7 @@ class Config {
   }
   }
 
 
   public applyJsonConfig() {
   public applyJsonConfig() {
-    const fileConfig = readJsonFile('/state/settings.json') || {};
+    const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
 
 
     const parsed = configSchema.parse({
     const parsed = configSchema.parse({
       ...this.config,
       ...this.config,
@@ -102,12 +105,12 @@ class Config {
     this.config = configSchema.parse(newConf);
     this.config = configSchema.parse(newConf);
 
 
     if (writeFile) {
     if (writeFile) {
-      const currentJsonConf = readJsonFile('/state/settings.json') || {};
+      const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
       currentJsonConf[key] = value;
       currentJsonConf[key] = value;
       const partialConfig = configSchema.partial();
       const partialConfig = configSchema.partial();
       const parsed = partialConfig.parse(currentJsonConf);
       const parsed = partialConfig.parse(currentJsonConf);
 
 
-      fs.writeFileSync(`${this.config.rootFolder}/state/settings.json`, JSON.stringify(parsed));
+      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).toBeDefined();
     expect(config.appsRepoUrl).toBe(randomWord);
     expect(config.appsRepoUrl).toBe(randomWord);
 
 
-    const settingsJson = readJsonFile('/state/settings.json');
+    const settingsJson = readJsonFile('/runtipi/state/settings.json');
 
 
     expect(settingsJson).toBeDefined();
     expect(settingsJson).toBeDefined();
     expect(settingsJson.appsRepoUrl).toBe(randomWord);
     expect(settingsJson.appsRepoUrl).toBe(randomWord);

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

@@ -80,7 +80,7 @@ describe('State/apps.json exists with no installed app', () => {
 
 
   it('Should delete state file after update', async () => {
   it('Should delete state file after update', async () => {
     await updateV040();
     await updateV040();
-    expect(fs.existsSync(`${getConfig().rootFolder}/state/apps.json`)).toBe(false);
+    expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
   });
   });
 });
 });
 
 
@@ -89,9 +89,9 @@ describe('State/apps.json exists with one installed app', () => {
   beforeEach(async () => {
   beforeEach(async () => {
     const { MockFiles, appInfo } = await createApp({});
     const { MockFiles, appInfo } = await createApp({});
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createState([appInfo.id]);
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}`] = '';
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+    MockFiles['/runtipi/state/apps.json'] = createState([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
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
   });
   });
@@ -118,9 +118,9 @@ describe('State/apps.json exists with one installed app', () => {
   it('Should not try to migrate app if it already exists', async () => {
   it('Should not try to migrate app if it already exists', async () => {
     const { MockFiles, appInfo } = await createApp({ installed: true });
     const { MockFiles, appInfo } = await createApp({ installed: true });
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createState([appInfo.id]);
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}`] = '';
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+    MockFiles['/runtipi/state/apps.json'] = createState([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
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 

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

@@ -20,15 +20,15 @@ export const updateV040 = async (): Promise<void> => {
     }
     }
 
 
     // Migrate apps
     // Migrate apps
-    if (fileExists('/state/apps.json')) {
-      const state: AppsState = await readJsonFile('/state/apps.json');
+    if (fileExists('/runtipi/state/apps.json')) {
+      const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
       const installed: string[] = state.installed.split(' ').filter(Boolean);
       const installed: string[] = state.installed.split(' ').filter(Boolean);
 
 
       for (const appId of installed) {
       for (const appId of installed) {
         const app = await App.findOne({ where: { id: appId } });
         const app = await App.findOne({ where: { id: appId } });
 
 
         if (!app) {
         if (!app) {
-          const envFile = readFile(`/app-data/${appId}/app.env`).toString();
+          const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
           const envVars = envFile.split('\n');
           const envVars = envFile.split('\n');
           const envVarsMap = new Map<string, string>();
           const envVarsMap = new Map<string, string>();
 
 
@@ -39,7 +39,7 @@ export const updateV040 = async (): Promise<void> => {
 
 
           const form: Record<string, string> = {};
           const form: Record<string, string> = {};
 
 
-          const configFile: AppInfo | null = readJsonFile(`/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
+          const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
           configFile?.form_fields?.forEach((field) => {
           configFile?.form_fields?.forEach((field) => {
             const envVar = field.env_variable;
             const envVar = field.env_variable;
             const envVarValue = envVarsMap.get(envVar);
             const envVarValue = envVarsMap.get(envVar);
@@ -54,17 +54,17 @@ export const updateV040 = async (): Promise<void> => {
           logger.info('App already migrated');
           logger.info('App already migrated');
         }
         }
       }
       }
-      deleteFolder('/state/apps.json');
+      deleteFolder('/runtipi/state/apps.json');
     }
     }
 
 
     // Migrate users
     // Migrate users
     if (fileExists('/state/users.json')) {
     if (fileExists('/state/users.json')) {
-      const state: { email: string; password: string }[] = await readJsonFile('/state/users.json');
+      const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
 
 
       for (const user of state) {
       for (const user of state) {
         await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
         await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
       }
       }
-      deleteFolder('/state/users.json');
+      deleteFolder('/runtipi/state/users.json');
     }
     }
 
 
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();

+ 2 - 3
packages/system-api/src/helpers/__tests__/repo-helpers.test.ts

@@ -1,7 +1,6 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import childProcess from 'child_process';
 import childProcess from 'child_process';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
-import { getConfig } from '../../core/config/TipiConfig';
 import { cloneRepo, updateRepo } from '../repo-helpers';
 import { cloneRepo, updateRepo } from '../repo-helpers';
 
 
 jest.mock('child_process');
 jest.mock('child_process');
@@ -26,7 +25,7 @@ describe('Test: updateRepo', () => {
 
 
     await updateRepo(url);
     await updateRepo(url);
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/scripts/git.sh`, ['update', url], {}, expect.any(Function));
+    expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['update', url], {}, expect.any(Function));
     expect(log).toHaveBeenCalledWith(`Update result: ${stdout}`);
     expect(log).toHaveBeenCalledWith(`Update result: ${stdout}`);
     spy.mockRestore();
     spy.mockRestore();
   });
   });
@@ -70,7 +69,7 @@ describe('Test: cloneRepo', () => {
 
 
     await cloneRepo(url);
     await cloneRepo(url);
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/scripts/git.sh`, ['clone', url], {}, expect.any(Function));
+    expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['clone', url], {}, expect.any(Function));
     expect(log).toHaveBeenCalledWith(`Clone result ${stdout}`);
     expect(log).toHaveBeenCalledWith(`Clone result ${stdout}`);
     spy.mockRestore();
     spy.mockRestore();
   });
   });

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

@@ -3,7 +3,7 @@ import { runScript } from '../modules/fs/fs.helpers';
 
 
 export const updateRepo = (repo: string): Promise<void> => {
 export const updateRepo = (repo: string): Promise<void> => {
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
-    runScript('/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
+    runScript('/runtipi/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
       if (err) {
       if (err) {
         Logger.error(`Error updating repo: ${err}`);
         Logger.error(`Error updating repo: ${err}`);
         reject(err);
         reject(err);
@@ -18,7 +18,7 @@ export const updateRepo = (repo: string): Promise<void> => {
 
 
 export const cloneRepo = (repo: string): Promise<void> => {
 export const cloneRepo = (repo: string): Promise<void> => {
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
-    runScript('/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
+    runScript('/runtipi/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
       if (err) {
       if (err) {
         Logger.error(`Error cloning repo: ${err}`);
         Logger.error(`Error cloning repo: ${err}`);
         reject(err);
         reject(err);

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

@@ -55,11 +55,11 @@ const createApp = async (props: IProps) => {
   }
   }
 
 
   let MockFiles: any = {};
   let MockFiles: any = {};
-  MockFiles[`${getConfig().rootFolder}/.env`] = 'TEST=test';
-  MockFiles[`${getConfig().rootFolder}/repos/repo-id`] = '';
-  MockFiles[`${getConfig().rootFolder}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
-  MockFiles[`${getConfig().rootFolder}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
-  MockFiles[`${getConfig().rootFolder}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+  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}/docker-compose.yml`] = 'compose';
+  MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
 
 
   let appEntity = new App();
   let appEntity = new App();
   if (installed) {
   if (installed) {
@@ -71,10 +71,10 @@ const createApp = async (props: IProps) => {
       domain,
       domain,
     }).save();
     }).save();
 
 
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}`] = '';
-    MockFiles[`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
-    MockFiles[`${getConfig().rootFolder}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
-    MockFiles[`${getConfig().rootFolder}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+    MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
+    MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+    MockFiles[`/app/storage/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
+    MockFiles[`/app/storage/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
   }
   }
 
 
   return { appInfo, MockFiles, appEntity };
   return { appInfo, MockFiles, appEntity };

+ 113 - 10
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -1,7 +1,8 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
+import childProcess from 'child_process';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
-import { getConfig } from '../../../core/config/TipiConfig';
+import logger from '../../../config/logger/logger';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
 import App from '../app.entity';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
@@ -95,7 +96,7 @@ describe('checkEnvFile', () => {
 
 
   it('Should throw if a required field is missing', () => {
   it('Should throw if a required field is missing', () => {
     const newAppEnv = 'APP_PORT=test\n';
     const newAppEnv = 'APP_PORT=test\n';
-    fs.writeFileSync(`${getConfig().rootFolder}/app-data/${app1.id}/app.env`, newAppEnv);
+    fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, newAppEnv);
 
 
     try {
     try {
       checkEnvFile(app1.id);
       checkEnvFile(app1.id);
@@ -107,7 +108,7 @@ describe('checkEnvFile', () => {
   });
   });
 });
 });
 
 
-describe('runAppScript', () => {
+describe('Test: runAppScript', () => {
   let app1: AppInfo;
   let app1: AppInfo;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
@@ -124,9 +125,32 @@ describe('runAppScript', () => {
 
 
     await runAppScript(['install', app1.id]);
     await runAppScript(['install', app1.id]);
   });
   });
+
+  it('Should log the error if the script fails', async () => {
+    const log = jest.spyOn(logger, 'error');
+    const spy = jest.spyOn(childProcess, 'execFile');
+    const randomWord = faker.random.word();
+
+    // @ts-ignore
+    spy.mockImplementation((_path, _args, _, cb) => {
+      // @ts-ignore
+      if (cb) cb(randomWord, null, null);
+    });
+
+    try {
+      await runAppScript(['install', app1.id]);
+      expect(true).toBe(false);
+    } catch (e: any) {
+      expect(e).toBe(randomWord);
+      expect(log).toHaveBeenCalledWith(`Error running app script: ${randomWord}`);
+    }
+
+    log.mockRestore();
+    spy.mockRestore();
+  });
 });
 });
 
 
-describe('generateEnvFile', () => {
+describe('Test: generateEnvFile', () => {
   let app1: AppInfo;
   let app1: AppInfo;
   let appEntity1: App;
   let appEntity1: App;
   beforeEach(async () => {
   beforeEach(async () => {
@@ -167,7 +191,7 @@ describe('generateEnvFile', () => {
 
 
     const randomField = faker.random.alphaNumeric(32);
     const randomField = faker.random.alphaNumeric(32);
 
 
-    fs.writeFileSync(`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
+    fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
 
 
     generateEnvFile(appEntity);
     generateEnvFile(appEntity);
 
 
@@ -234,6 +258,18 @@ describe('generateEnvFile', () => {
     expect(envmap.get('APP_EXPOSED')).toBeUndefined();
     expect(envmap.get('APP_EXPOSED')).toBeUndefined();
     expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
     expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
   });
   });
+
+  it('Should create app folder if it does not exist', async () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    fs.rmSync(`/app/storage/app-data/${appInfo.id}`, { recursive: true });
+
+    generateEnvFile(appEntity);
+
+    expect(fs.existsSync(`/app/storage/app-data/${appInfo.id}`)).toBe(true);
+  });
 });
 });
 
 
 describe('getAvailableApps', () => {
 describe('getAvailableApps', () => {
@@ -251,7 +287,7 @@ describe('getAvailableApps', () => {
   });
   });
 });
 });
 
 
-describe('getAppInfo', () => {
+describe('Test: getAppInfo', () => {
   let app1: AppInfo;
   let app1: AppInfo;
   beforeEach(async () => {
   beforeEach(async () => {
     const app1create = await createApp({ installed: false });
     const app1create = await createApp({ installed: false });
@@ -267,15 +303,82 @@ describe('getAppInfo', () => {
   });
   });
 
 
   it('Should take config.json locally if app is installed', async () => {
   it('Should take config.json locally if app is installed', async () => {
-    const { appInfo, MockFiles } = await createApp({ installed: true });
+    const { appInfo, MockFiles, appEntity } = await createApp({ installed: true });
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    fs.writeFileSync(`${getConfig().rootFolder}/repos/repo-id/apps/${app1.id}/config.json`, '{}');
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+    };
+
+    fs.writeFileSync(`/app/storage/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    const app = await getAppInfo(appInfo.id, appEntity.status);
+
+    expect(app?.id).toEqual(newConfig.id);
+  });
+
+  it('Should take config.json from repo if app is not installed', async () => {
+    const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
 
 
-    const app = await getAppInfo(appInfo.id);
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+      available: true,
+    };
+
+    fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    const app = await getAppInfo(appInfo.id, appEntity.status);
+
+    expect(app?.id).toEqual(newConfig.id);
+  });
+
+  it('Should return null if app is not available', async () => {
+    const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+      available: false,
+    };
+
+    fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    const app = await getAppInfo(appInfo.id, appEntity.status);
+
+    expect(app).toBeNull();
+  });
+
+  it('Should throw if something goes wrong', async () => {
+    const log = jest.spyOn(logger, 'error');
+    const spy = jest.spyOn(fs, 'existsSync').mockImplementation(() => {
+      throw new Error('Something went wrong');
+    });
+
+    const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+      available: false,
+    };
+
+    fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    try {
+      await getAppInfo(appInfo.id, appEntity.status);
+      expect(true).toBe(false);
+    } catch (e: any) {
+      expect(e.message).toBe(`Error loading app: ${appInfo.id}`);
+      expect(log).toBeCalledWith(`Error loading app: ${appInfo.id}`);
+    }
 
 
-    expect(app?.id).toEqual(appInfo.id);
+    spy.mockRestore();
+    log.mockRestore();
   });
   });
 
 
   it('Should return null if app does not exist', async () => {
   it('Should return null if app does not exist', async () => {

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

@@ -43,7 +43,7 @@ describe('Install app', () => {
 
 
   it('Should correctly generate env file for app', async () => {
   it('Should correctly generate env file for app', async () => {
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-    const envFile = fs.readFileSync(`${getConfig().rootFolder}/app-data/${app1.id}/app.env`).toString();
+    const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
 
 
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
@@ -74,8 +74,8 @@ describe('Install app', () => {
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
 
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['install', app1.id], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual(['/runtipi/scripts/app.sh', ['install', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.calls[1]).toEqual(['/runtipi/scripts/app.sh', ['start', app1.id], {}, expect.any(Function)]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
@@ -112,7 +112,7 @@ describe('Install app', () => {
 
 
   it('Should correctly copy app from repos to apps folder', async () => {
   it('Should correctly copy app from repos to apps folder', async () => {
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-    const appFolder = fs.readdirSync(`${getConfig().rootFolder}/apps/${app1.id}`);
+    const appFolder = fs.readdirSync(`/app/storage/apps/${app1.id}`);
 
 
     expect(appFolder).toBeDefined();
     expect(appFolder).toBeDefined();
     expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
     expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
@@ -121,19 +121,19 @@ describe('Install app', () => {
   it('Should cleanup any app folder existing before install', async () => {
   it('Should cleanup any app folder existing before install', async () => {
     const { MockFiles, appInfo } = await createApp({});
     const { MockFiles, appInfo } = await createApp({});
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles[`${getConfig().rootFolder}/apps/${appInfo.id}/docker-compose.yml`] = 'test';
-    MockFiles[`${getConfig().rootFolder}/apps/${appInfo.id}/test.yml`] = 'test';
-    MockFiles[`${getConfig().rootFolder}/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
+    MockFiles[`/app/storage/apps/${appInfo.id}/docker-compose.yml`] = 'test';
+    MockFiles[`/app/storage/apps/${appInfo.id}/test.yml`] = 'test';
+    MockFiles[`/app/storage/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    expect(fs.existsSync(`${getConfig().rootFolder}/apps/${app1.id}/test.yml`)).toBe(true);
+    expect(fs.existsSync(`/app/storage/apps/${app1.id}/test.yml`)).toBe(true);
 
 
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
 
-    expect(fs.existsSync(`${getConfig().rootFolder}/apps/${app1.id}/test.yml`)).toBe(false);
-    expect(fs.existsSync(`${getConfig().rootFolder}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
+    expect(fs.existsSync(`/app/storage/apps/${app1.id}/test.yml`)).toBe(false);
+    expect(fs.existsSync(`/app/storage/apps/${app1.id}/docker-compose.yml`)).toBe(true);
   });
   });
 
 
   it('Should throw if app is exposed and domain is not provided', async () => {
   it('Should throw if app is exposed and domain is not provided', async () => {
@@ -266,11 +266,11 @@ describe('Start app', () => {
   });
   });
 
 
   it('Regenerate env file', async () => {
   it('Regenerate env file', async () => {
-    fs.writeFile(`${getConfig().rootFolder}/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
+    fs.writeFile(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
 
 
-    const envFile = fs.readFileSync(`${getConfig().rootFolder}/app-data/${app1.id}/app.env`).toString();
+    const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
 
 
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
@@ -334,7 +334,7 @@ describe('Update app config', () => {
   it('Should correctly update app config', async () => {
   it('Should correctly update app config', async () => {
     await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
     await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
 
 
-    const envFile = fs.readFileSync(`${getConfig().rootFolder}/app-data/${app1.id}/app.env`).toString();
+    const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
 
 
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
@@ -352,8 +352,8 @@ describe('Update app config', () => {
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    const envFile = fs.readFileSync(`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`).toString();
-    fs.writeFileSync(`${getConfig().rootFolder}/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
+    const envFile = fs.readFileSync(`/app/storage/app-data/${appInfo.id}/app.env`).toString();
+    fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
 
 
     await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
     await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
 
 

+ 28 - 24
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -6,13 +6,12 @@ import { AppInfo, AppStatusEnum } from './apps.types';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import App from './app.entity';
 import App from './app.entity';
 import { getConfig } from '../../core/config/TipiConfig';
 import { getConfig } from '../../core/config/TipiConfig';
-
-const { appsRepoId, internalIp } = getConfig();
+import fs from 'fs-extra';
 
 
 export const checkAppRequirements = async (appName: string) => {
 export const checkAppRequirements = async (appName: string) => {
   let valid = true;
   let valid = true;
 
 
-  const configFile: AppInfo | null = readJsonFile(`/repos/${appsRepoId}/apps/${appName}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
 
 
   if (!configFile) {
   if (!configFile) {
     throw new Error(`App ${appName} not found`);
     throw new Error(`App ${appName} not found`);
@@ -31,7 +30,7 @@ export const checkAppRequirements = async (appName: string) => {
 };
 };
 
 
 export const getEnvMap = (appName: string): Map<string, string> => {
 export const getEnvMap = (appName: string): Map<string, string> => {
-  const envFile = readFile(`/app-data/${appName}/app.env`).toString();
+  const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
   const envVars = envFile.split('\n');
   const envVars = envFile.split('\n');
   const envVarsMap = new Map<string, string>();
   const envVarsMap = new Map<string, string>();
 
 
@@ -44,7 +43,7 @@ export const getEnvMap = (appName: string): Map<string, string> => {
 };
 };
 
 
 export const checkEnvFile = (appName: string) => {
 export const checkEnvFile = (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/app/storage/apps/${appName}/config.json`);
   const envMap = getEnvMap(appName);
   const envMap = getEnvMap(appName);
 
 
   configFile?.form_fields?.forEach((field) => {
   configFile?.form_fields?.forEach((field) => {
@@ -59,9 +58,9 @@ export const checkEnvFile = (appName: string) => {
 
 
 export const runAppScript = async (params: string[]): Promise<void> => {
 export const runAppScript = async (params: string[]): Promise<void> => {
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
-    runScript('/scripts/app.sh', [...params], (err: string) => {
+    runScript('/runtipi/scripts/app.sh', [...params], (err: string) => {
       if (err) {
       if (err) {
-        logger.error(err);
+        logger.error(`Error running app script: ${err}`);
         reject(err);
         reject(err);
       }
       }
 
 
@@ -77,13 +76,13 @@ const getEntropy = (name: string, length: number) => {
 };
 };
 
 
 export const generateEnvFile = (app: App) => {
 export const generateEnvFile = (app: App) => {
-  const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/app/storage/apps/${app.id}/config.json`);
 
 
   if (!configFile) {
   if (!configFile) {
     throw new Error(`App ${app.id} not found`);
     throw new Error(`App ${app.id} not found`);
   }
   }
 
 
-  const baseEnvFile = readFile('/.env').toString();
+  const baseEnvFile = readFile('/runtipi/.env').toString();
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
   const envMap = getEnvMap(app.id);
   const envMap = getEnvMap(app.id);
 
 
@@ -112,20 +111,25 @@ export const generateEnvFile = (app: App) => {
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += 'APP_PROTOCOL=https\n';
     envFile += 'APP_PROTOCOL=https\n';
   } else {
   } else {
-    envFile += `APP_DOMAIN=${internalIp}:${configFile.port}\n`;
+    envFile += `APP_DOMAIN=${getConfig().internalIp}:${configFile.port}\n`;
+  }
+
+  // Create app-data folder if it doesn't exist
+  if (!fs.existsSync(`/app/storage/app-data/${app.id}`)) {
+    fs.mkdirSync(`/app/storage/app-data/${app.id}`, { recursive: true });
   }
   }
 
 
-  writeFile(`/app-data/${app.id}/app.env`, envFile);
+  writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
 };
 };
 
 
 export const getAvailableApps = async (): Promise<string[]> => {
 export const getAvailableApps = async (): Promise<string[]> => {
   const apps: string[] = [];
   const apps: string[] = [];
 
 
-  const appsDir = readdirSync(`/repos/${appsRepoId}/apps`);
+  const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
 
 
   appsDir.forEach((app) => {
   appsDir.forEach((app) => {
-    if (fileExists(`/repos/${appsRepoId}/apps/${app}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/repos/${appsRepoId}/apps/${app}/config.json`);
+    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
 
 
       if (configFile.available) {
       if (configFile.available) {
         apps.push(app);
         apps.push(app);
@@ -141,13 +145,13 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
     // Check if app is installed
     // Check if app is installed
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
 
 
-    if (installed && fileExists(`/apps/${id}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
-      configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
+    if (installed && fileExists(`/app/storage/apps/${id}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/app/storage/apps/${id}/config.json`);
+      configFile.description = readFile(`/app/storage/apps/${id}/metadata/description.md`).toString();
       return configFile;
       return configFile;
-    } else if (fileExists(`/repos/${appsRepoId}/apps/${id}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/repos/${appsRepoId}/apps/${id}/config.json`);
-      configFile.description = readFile(`/repos/${appsRepoId}/apps/${id}/metadata/description.md`);
+    } else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+      configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
 
 
       if (configFile.available) {
       if (configFile.available) {
         return configFile;
         return configFile;
@@ -156,21 +160,21 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
 
 
     return null;
     return null;
   } catch (e) {
   } catch (e) {
-    console.error(e);
-    throw new Error(`Error loading app ${id}`);
+    logger.error(`Error loading app: ${id}`);
+    throw new Error(`Error loading app: ${id}`);
   }
   }
 };
 };
 
 
 export const getUpdateInfo = async (id: string) => {
 export const getUpdateInfo = async (id: string) => {
   const app = await App.findOne({ where: { id } });
   const app = await App.findOne({ where: { id } });
 
 
-  const doesFileExist = fileExists(`/repos/${appsRepoId}/apps/${id}`);
+  const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
 
 
   if (!app || !doesFileExist) {
   if (!app || !doesFileExist) {
     return null;
     return null;
   }
   }
 
 
-  const repoConfig: AppInfo = readJsonFile(`/repos/${appsRepoId}/apps/${id}/config.json`);
+  const repoConfig: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
 
 
   return {
   return {
     current: app.version,
     current: app.version,

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

@@ -83,9 +83,9 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     }
     }
 
 
     // Create app folder
     // Create app folder
-    createFolder(`/app-data/${id}`);
+    createFolder(`/app/storage/app-data/${id}`);
 
 
-    const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+    const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
 
 
     if (!appInfo?.exposable && exposed) {
     if (!appInfo?.exposable && exposed) {
       throw new Error(`App ${id} is not exposable`);
       throw new Error(`App ${id} is not exposable`);
@@ -124,7 +124,7 @@ const listApps = async (): Promise<ListAppsResonse> => {
   const apps: AppInfo[] = folders
   const apps: AppInfo[] = folders
     .map((app) => {
     .map((app) => {
       try {
       try {
-        return readJsonFile(`/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
+        return readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
       } catch (e) {
       } catch (e) {
         return null;
         return null;
       }
       }
@@ -132,7 +132,7 @@ const listApps = async (): Promise<ListAppsResonse> => {
     .filter(Boolean);
     .filter(Boolean);
 
 
   apps.forEach((app) => {
   apps.forEach((app) => {
-    app.description = readFile(`/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
+    app.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
   });
   });
 
 
   return { apps: apps.sort(sortApps), total: apps.length };
   return { apps: apps.sort(sortApps), total: apps.length };
@@ -147,7 +147,7 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     throw new Error(`Domain ${domain} is not valid`);
     throw new Error(`Domain ${domain} is not valid`);
   }
   }
 
 
-  const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+  const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
 
 
   if (!appInfo?.exposable && exposed) {
   if (!appInfo?.exposable && exposed) {
     throw new Error(`App ${id} is not exposable`);
     throw new Error(`App ${id} is not exposable`);
@@ -250,7 +250,7 @@ const updateApp = async (id: string) => {
   // Run script
   // Run script
   try {
   try {
     await runAppScript(['update', id]);
     await runAppScript(['update', id]);
-    const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+    const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
   } catch (e) {
   } catch (e) {
     logger.error(e);
     logger.error(e);

+ 28 - 34
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts

@@ -1,5 +1,5 @@
 import childProcess from 'child_process';
 import childProcess from 'child_process';
-import { getAbsolutePath, readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
+import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { getConfig } from '../../../core/config/TipiConfig';
 import { getConfig } from '../../../core/config/TipiConfig';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
@@ -11,24 +11,18 @@ beforeEach(() => {
   fs.__resetAllMocks();
   fs.__resetAllMocks();
 });
 });
 
 
-describe('Test: getAbsolutePath', () => {
-  it('should return the absolute path', () => {
-    expect(getAbsolutePath('/test')).toBe(`${getConfig().rootFolder}/test`);
-  });
-});
-
 describe('Test: readJsonFile', () => {
 describe('Test: readJsonFile', () => {
   it('should return the json file', () => {
   it('should return the json file', () => {
     // Arrange
     // Arrange
     const rawFile = '{"test": "test"}';
     const rawFile = '{"test": "test"}';
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/test-file.json`]: rawFile,
+      ['/runtipi/test-file.json']: rawFile,
     };
     };
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
     // Act
     // Act
-    const file = readJsonFile('/test-file.json');
+    const file = readJsonFile('/runtipi/test-file.json');
 
 
     // Assert
     // Assert
     expect(file).toEqual({ test: 'test' });
     expect(file).toEqual({ test: 'test' });
@@ -59,13 +53,13 @@ describe('Test: readFile', () => {
   it('should return the file', () => {
   it('should return the file', () => {
     const rawFile = 'test';
     const rawFile = 'test';
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/test-file.txt`]: rawFile,
+      ['/runtipi/test-file.txt']: rawFile,
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(readFile('/test-file.txt')).toEqual('test');
+    expect(readFile('/runtipi/test-file.txt')).toEqual('test');
   });
   });
 
 
   it('should return empty string if the file does not exist', () => {
   it('should return empty string if the file does not exist', () => {
@@ -76,13 +70,13 @@ describe('Test: readFile', () => {
 describe('Test: readdirSync', () => {
 describe('Test: readdirSync', () => {
   it('should return the files', () => {
   it('should return the files', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/test/test-file.txt`]: 'test',
+      ['/runtipi/test/test-file.txt']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(readdirSync('/test')).toEqual(['test-file.txt']);
+    expect(readdirSync('/runtipi/test')).toEqual(['test-file.txt']);
   });
   });
 
 
   it('should return empty array if the directory does not exist', () => {
   it('should return empty array if the directory does not exist', () => {
@@ -93,13 +87,13 @@ describe('Test: readdirSync', () => {
 describe('Test: fileExists', () => {
 describe('Test: fileExists', () => {
   it('should return true if the file exists', () => {
   it('should return true if the file exists', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/test-file.txt`]: 'test',
+      ['/runtipi/test-file.txt']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(fileExists('/test-file.txt')).toBeTruthy();
+    expect(fileExists('/runtipi/test-file.txt')).toBeTruthy();
   });
   });
 
 
   it('should return false if the file does not exist', () => {
   it('should return false if the file does not exist', () => {
@@ -111,9 +105,9 @@ describe('Test: writeFile', () => {
   it('should write the file', () => {
   it('should write the file', () => {
     const spy = jest.spyOn(fs, 'writeFileSync');
     const spy = jest.spyOn(fs, 'writeFileSync');
 
 
-    writeFile('/test-file.txt', 'test');
+    writeFile('/runtipi/test-file.txt', 'test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/test-file.txt`, 'test');
+    expect(spy).toHaveBeenCalledWith('/runtipi/test-file.txt', 'test');
   });
   });
 });
 });
 
 
@@ -123,7 +117,7 @@ describe('Test: createFolder', () => {
 
 
     createFolder('/test');
     createFolder('/test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/test`);
+    expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
   });
   });
 });
 });
 
 
@@ -133,7 +127,7 @@ describe('Test: deleteFolder', () => {
 
 
     deleteFolder('/test');
     deleteFolder('/test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/test`, { recursive: true });
+    expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
   });
   });
 });
 });
 
 
@@ -144,14 +138,14 @@ describe('Test: runScript', () => {
 
 
     runScript('/test', [], callback);
     runScript('/test', [], callback);
 
 
-    expect(spy).toHaveBeenCalledWith(`${getConfig().rootFolder}/test`, [], {}, callback);
+    expect(spy).toHaveBeenCalledWith('/test', [], {}, callback);
   });
   });
 });
 });
 
 
 describe('Test: getSeed', () => {
 describe('Test: getSeed', () => {
   it('should return the seed', () => {
   it('should return the seed', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/state/seed`]: 'test',
+      ['/runtipi/state/seed']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -164,7 +158,7 @@ describe('Test: getSeed', () => {
 describe('Test: ensureAppFolder', () => {
 describe('Test: ensureAppFolder', () => {
   beforeEach(() => {
   beforeEach(() => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
     };
     };
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
@@ -175,15 +169,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${getConfig().rootFolder}/apps/test`);
+    const files = fs.readdirSync('/app/storage/apps/test');
     expect(files).toEqual(['test.yml']);
     expect(files).toEqual(['test.yml']);
   });
   });
 
 
   it('should not copy the folder if it already exists', () => {
   it('should not copy the folder if it already exists', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      [`${getConfig().rootFolder}/apps/test`]: ['docker-compose.yml'],
-      [`${getConfig().rootFolder}/apps/test/docker-compose.yml`]: 'test',
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
+      ['/app/storage/apps/test']: ['docker-compose.yml'],
+      ['/app/storage/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -193,15 +187,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${getConfig().rootFolder}/apps/test`);
+    const files = fs.readdirSync('/app/storage/apps/test');
     expect(files).toEqual(['docker-compose.yml']);
     expect(files).toEqual(['docker-compose.yml']);
   });
   });
 
 
   it('Should overwrite the folder if clean up is true', () => {
   it('Should overwrite the folder if clean up is true', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      [`${getConfig().rootFolder}/apps/test`]: ['docker-compose.yml'],
-      [`${getConfig().rootFolder}/apps/test/docker-compose.yml`]: 'test',
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
+      ['/app/storage/apps/test']: ['docker-compose.yml'],
+      ['/app/storage/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -211,7 +205,7 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test', true);
     ensureAppFolder('test', true);
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${getConfig().rootFolder}/apps/test`);
+    const files = fs.readdirSync('/app/storage/apps/test');
     expect(files).toEqual(['test.yml']);
     expect(files).toEqual(['test.yml']);
   });
   });
 
 
@@ -219,8 +213,8 @@ describe('Test: ensureAppFolder', () => {
     // Arrange
     // Arrange
     const randomFileName = `${faker.random.word()}.yml`;
     const randomFileName = `${faker.random.word()}.yml`;
     const mockFiles = {
     const mockFiles = {
-      [`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
-      [`${getConfig().rootFolder}/apps/test`]: ['test.yml'],
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
+      ['/app/storage/apps/test']: ['test.yml'],
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -230,7 +224,7 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${getConfig().rootFolder}/apps/test`);
+    const files = fs.readdirSync('/app/storage/apps/test');
     expect(files).toEqual([randomFileName]);
     expect(files).toEqual([randomFileName]);
   });
   });
 });
 });

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

@@ -2,11 +2,9 @@ import fs from 'fs-extra';
 import childProcess from 'child_process';
 import childProcess from 'child_process';
 import { getConfig } from '../../core/config/TipiConfig';
 import { getConfig } from '../../core/config/TipiConfig';
 
 
-export const getAbsolutePath = (path: string) => `${getConfig().rootFolder}${path}`;
-
 export const readJsonFile = (path: string): any => {
 export const readJsonFile = (path: string): any => {
   try {
   try {
-    const rawFile = fs.readFileSync(getAbsolutePath(path))?.toString();
+    const rawFile = fs.readFileSync(path)?.toString();
 
 
     if (!rawFile) {
     if (!rawFile) {
       return null;
       return null;
@@ -20,40 +18,40 @@ export const readJsonFile = (path: string): any => {
 
 
 export const readFile = (path: string): string => {
 export const readFile = (path: string): string => {
   try {
   try {
-    return fs.readFileSync(getAbsolutePath(path)).toString();
+    return fs.readFileSync(path).toString();
   } catch {
   } catch {
     return '';
     return '';
   }
   }
 };
 };
 
 
-export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
+export const readdirSync = (path: string): string[] => fs.readdirSync(path);
 
 
-export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
+export const fileExists = (path: string): boolean => fs.existsSync(path);
 
 
-export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
+export const writeFile = (path: string, data: any) => fs.writeFileSync(path, data);
 
 
 export const createFolder = (path: string) => {
 export const createFolder = (path: string) => {
   if (!fileExists(path)) {
   if (!fileExists(path)) {
-    fs.mkdirSync(getAbsolutePath(path));
+    fs.mkdirSync(path, { recursive: true });
   }
   }
 };
 };
-export const deleteFolder = (path: string) => fs.rmSync(getAbsolutePath(path), { recursive: true });
+export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
 
 
-export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(getAbsolutePath(path), args, {}, callback);
+export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(path, args, {}, callback);
 
 
 export const getSeed = () => {
 export const getSeed = () => {
-  const seed = readFile('/state/seed');
+  const seed = readFile('/runtipi/state/seed');
   return seed.toString();
   return seed.toString();
 };
 };
 
 
 export const ensureAppFolder = (appName: string, cleanup = false) => {
 export const ensureAppFolder = (appName: string, cleanup = false) => {
-  if (cleanup && fileExists(`/apps/${appName}`)) {
-    deleteFolder(`/apps/${appName}`);
+  if (cleanup && fileExists(`/app/storage/apps/${appName}`)) {
+    deleteFolder(`/app/storage/apps/${appName}`);
   }
   }
 
 
-  if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
-    if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
+  if (!fileExists(`/app/storage/apps/${appName}/docker-compose.yml`)) {
+    if (fileExists(`/app/storage/apps/${appName}`)) deleteFolder(`/app/storage/apps/${appName}`);
     // Copy from apps repo
     // Copy from apps repo
-    fs.copySync(getAbsolutePath(`/repos/${getConfig().appsRepoId}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
+    fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/app/storage/apps/${appName}`);
   }
   }
 };
 };

+ 3 - 3
packages/system-api/src/modules/system/system.service.ts

@@ -23,7 +23,7 @@ const systemInfoSchema = z.object({
 });
 });
 
 
 const systemInfo = (): z.infer<typeof systemInfoSchema> => {
 const systemInfo = (): z.infer<typeof systemInfoSchema> => {
-  const info = systemInfoSchema.safeParse(readJsonFile('/state/system-info.json'));
+  const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
 
 
   if (!info.success) {
   if (!info.success) {
     logger.error('Error parsing system info');
     logger.error('Error parsing system info');
@@ -57,7 +57,7 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 const restart = async (): Promise<boolean> => {
 const restart = async (): Promise<boolean> => {
   setConfig('status', 'RESTARTING');
   setConfig('status', 'RESTARTING');
 
 
-  runScript('/scripts/system.sh', ['restart'], (err: string) => {
+  runScript('/runtipi/scripts/system.sh', ['restart'], (err: string) => {
     setConfig('status', 'RUNNING');
     setConfig('status', 'RUNNING');
     if (err) {
     if (err) {
       logger.error(`Error restarting: ${err}`);
       logger.error(`Error restarting: ${err}`);
@@ -90,7 +90,7 @@ const update = async (): Promise<boolean> => {
 
 
   setConfig('status', 'UPDATING');
   setConfig('status', 'UPDATING');
 
 
-  runScript('/scripts/system.sh', ['update'], (err: string) => {
+  runScript('/runtipi/scripts/system.sh', ['update'], (err: string) => {
     setConfig('status', 'RUNNING');
     setConfig('status', 'RUNNING');
     if (err) {
     if (err) {
       logger.error(`Error updating: ${err}`);
       logger.error(`Error updating: ${err}`);

+ 29 - 18
scripts/app.sh

@@ -2,31 +2,32 @@
 # Required Notice: Copyright
 # Required Notice: Copyright
 # Umbrel (https://umbrel.com)
 # Umbrel (https://umbrel.com)
 
 
+echo "Starting app script"
+
+source "${BASH_SOURCE%/*}/common.sh"
+
 set -euo pipefail
 set -euo pipefail
 
 
-cd /runtipi || echo ""
-# Ensure PWD ends with /runtipi
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-  echo "Please run this script from the runtipi directory"
-  exit 1
-fi
+ensure_pwd
 
 
-# Root folder in container is /runtipi
 ROOT_FOLDER="${PWD}"
 ROOT_FOLDER="${PWD}"
-
+STATE_FOLDER="${ROOT_FOLDER}/state"
 ENV_FILE="${ROOT_FOLDER}/.env"
 ENV_FILE="${ROOT_FOLDER}/.env"
 
 
 # Root folder in host system
 # Root folder in host system
 ROOT_FOLDER_HOST=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep ROOT_FOLDER_HOST | cut -d '=' -f2)
 ROOT_FOLDER_HOST=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep ROOT_FOLDER_HOST | cut -d '=' -f2)
 REPO_ID=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep APPS_REPO_ID | cut -d '=' -f2)
 REPO_ID=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep APPS_REPO_ID | cut -d '=' -f2)
+STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2)
 
 
-# Get field from json file
-function get_json_field() {
-  local json_file="$1"
-  local field="$2"
+# Override vars with values from settings.json
+if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
+  # If storagePath is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
+    STORAGE_PATH="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
+  fi
+fi
 
 
-  jq -r ".${field}" "${json_file}"
-}
+write_log "Running app script: ROOT_FOLDER=${ROOT_FOLDER}, ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}, REPO_ID=${REPO_ID}, STORAGE_PATH=${STORAGE_PATH}"
 
 
 if [ -z ${1+x} ]; then
 if [ -z ${1+x} ]; then
   command=""
   command=""
@@ -35,7 +36,6 @@ else
 fi
 fi
 
 
 if [ -z ${2+x} ]; then
 if [ -z ${2+x} ]; then
-  show_help
   exit 1
   exit 1
 else
 else
   app="$2"
   app="$2"
@@ -49,13 +49,12 @@ else
     cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
     cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
   fi
   fi
 
 
-  app_data_dir="${ROOT_FOLDER}/app-data/${app}"
+  app_data_dir="/app/storage/app-data/${app}"
 
 
   if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
   if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
     echo "Error: \"${app}\" is not a valid app"
     echo "Error: \"${app}\" is not a valid app"
     exit 1
     exit 1
   fi
   fi
-
 fi
 fi
 
 
 if [ -z ${3+x} ]; then
 if [ -z ${3+x} ]; then
@@ -83,12 +82,21 @@ compose() {
     app_compose_file="${app_dir}/docker-compose.arm.yml"
     app_compose_file="${app_dir}/docker-compose.arm.yml"
   fi
   fi
 
 
+  # Pick arm architecture if running on arm and if the app has a docker-compose.arm64.yml file
+  if [[ "$architecture" == "arm64" ]] && [[ -f "${app_dir}/docker-compose.arm64.yml" ]]; then
+    app_compose_file="${app_dir}/docker-compose.arm64.yml"
+  fi
+
   local common_compose_file="${ROOT_FOLDER}/repos/${REPO_ID}/apps/docker-compose.common.yml"
   local common_compose_file="${ROOT_FOLDER}/repos/${REPO_ID}/apps/docker-compose.common.yml"
 
 
   # Vars to use in compose file
   # Vars to use in compose file
-  export APP_DATA_DIR="${ROOT_FOLDER_HOST}/app-data/${app}"
+  export APP_DATA_DIR="${STORAGE_PATH}/app-data/${app}"
   export ROOT_FOLDER_HOST="${ROOT_FOLDER_HOST}"
   export ROOT_FOLDER_HOST="${ROOT_FOLDER_HOST}"
 
 
+  write_log "Running docker compose -f ${app_compose_file} -f ${common_compose_file} ${*}"
+  write_log "APP_DATA_DIR=${APP_DATA_DIR}"
+  write_log "ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}"
+
   docker compose \
   docker compose \
     --env-file "${app_data_dir}/app.env" \
     --env-file "${app_data_dir}/app.env" \
     --project-name "${app}" \
     --project-name "${app}" \
@@ -99,6 +107,9 @@ compose() {
 
 
 # Install new app
 # Install new app
 if [[ "$command" = "install" ]]; then
 if [[ "$command" = "install" ]]; then
+  # Write to file script.log
+  write_log "Installing app ${app}..."
+
   compose "${app}" pull
   compose "${app}" pull
 
 
   # Copy default data dir to app data dir if it exists
   # Copy default data dir to app data dir if it exists

+ 71 - 0
scripts/common.sh

@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+
+# Get field from json file
+function get_json_field() {
+    local json_file="$1"
+    local field="$2"
+
+    jq -r ".${field}" "${json_file}"
+}
+
+function write_log() {
+    local message="$1"
+    local log_file="/app/logs/script.log"
+
+    echo "$(date) - ${message}" >>"${log_file}"
+}
+
+function derive_entropy() {
+    SEED_FILE="${STATE_FOLDER}/seed"
+    identifier="${1}"
+    tipi_seed=$(cat "${SEED_FILE}") || true
+
+    if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then
+        echo >&2 "Missing derivation parameter, this is unsafe, exiting."
+        exit 1
+    fi
+
+    printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //'
+}
+
+function ensure_pwd() {
+    # # Ensure PWD ends with /runtipi
+    cd /runtipi || echo ""
+
+    if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
+        echo "Please run this script from the runtipi directory"
+        exit 1
+    fi
+}
+
+function ensure_root() {
+    if [[ $UID != 0 ]]; then
+        echo "Tipi must be started as root"
+        echo "Please re-run this script as"
+        echo "  sudo ./scripts/start"
+        exit 1
+    fi
+}
+
+function ensure_linux() {
+    # Check we are on linux
+    if [[ "$(uname)" != "Linux" ]]; then
+        echo "Tipi only works on Linux"
+        exit 1
+    fi
+}
+
+function clean_logs() {
+    # Clean logs folder
+    logs_folder="${ROOT_FOLDER}/logs"
+    if [ "$(find "${logs_folder}" -maxdepth 1 -type f | wc -l)" -gt 0 ]; then
+        echo "Cleaning logs folder..."
+
+        files=($(ls -d "${logs_folder}"/* | xargs -n 1 basename | sed 's/\///g'))
+
+        for file in "${files[@]}"; do
+            echo "Removing ${file}"
+            rm -rf "${ROOT_FOLDER}/logs/${file}"
+        done
+    fi
+}

+ 0 - 15
scripts/git.sh

@@ -11,21 +11,6 @@ fi
 
 
 ROOT_FOLDER="${PWD}"
 ROOT_FOLDER="${PWD}"
 
 
-show_help() {
-    cat <<EOF
-app 0.0.1
-
-CLI for managing Tipi apps
-
-Usage: git <command> <repo> [<arguments>]
-
-Commands:
-    clone                      Clones a repo in the repo folder
-    update                     Updates the repo folder
-    get_hash                   Gets the local hash of the repo
-EOF
-}
-
 # Get a static hash based on the repo url
 # Get a static hash based on the repo url
 function get_hash() {
 function get_hash() {
     url="${1}"
     url="${1}"

+ 44 - 91
scripts/start.sh

@@ -1,30 +1,45 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
-# Required Notice: Copyright
-# Umbrel (https://umbrel.com)
-
 set -e # Exit immediately if a command exits with a non-zero status.
 set -e # Exit immediately if a command exits with a non-zero status.
 
 
-NGINX_PORT=80
-NGINX_PORT_SSL=443
-DOMAIN=tipi.localhost
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-# Check we are on linux
-if [[ "$(uname)" != "Linux" ]]; then
-  echo "Tipi only works on Linux"
-  exit 1
-fi
+write_log "Starting Tipi..."
 
 
-# Ensure BASH_SOURCE is ./scripts/start.sh
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-  echo "Please make sure this script is executed from runtipi/"
-  exit 1
-fi
+ROOT_FOLDER="${PWD}"
 
 
+# Cleanup and ensure environment
+ensure_linux
+ensure_pwd
+ensure_root
+clean_logs
+
+# Default variables
+NGINX_PORT=80
+NGINX_PORT_SSL=443
+DOMAIN=tipi.localhost
 NETWORK_INTERFACE="$(ip route | grep default | awk '{print $5}' | uniq)"
 NETWORK_INTERFACE="$(ip route | grep default | awk '{print $5}' | uniq)"
 INTERNAL_IP="$(ip addr show "${NETWORK_INTERFACE}" | grep "inet " | awk '{print $2}' | cut -d/ -f1)"
 INTERNAL_IP="$(ip addr show "${NETWORK_INTERFACE}" | grep "inet " | awk '{print $2}' | cut -d/ -f1)"
+STATE_FOLDER="${ROOT_FOLDER}/state"
+SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
+DNS_IP=9.9.9.9 # Default to Quad9 DNS
+ARCHITECTURE="$(uname -m)"
+TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
+apps_repository="https://github.com/meienberger/runtipi-appstore"
+REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
+APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
+JWT_SECRET=$(derive_entropy "jwt")
+POSTGRES_PASSWORD=$(derive_entropy "postgres")
+TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
+storage_path="${ROOT_FOLDER}"
+STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
 
 
-while [ -n "$1" ]; do # while loop starts
+if [[ "$ARCHITECTURE" == "aarch64" ]]; then
+  ARCHITECTURE="arm64"
+fi
+
+# Parse arguments
+while [ -n "$1" ]; do
   case "$1" in
   case "$1" in
   --rc) rc="true" ;;
   --rc) rc="true" ;;
   --ci) ci="true" ;;
   --ci) ci="true" ;;
@@ -87,67 +102,14 @@ if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
   exit 1
   exit 1
 fi
 fi
 
 
-ROOT_FOLDER="${PWD}"
-STATE_FOLDER="${ROOT_FOLDER}/state"
-SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
-
-DNS_IP=9.9.9.9 # Default to Quad9 DNS
-ARCHITECTURE="$(uname -m)"
-TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
-APPS_REPOSITORY="https://github.com/meienberger/runtipi-appstore"
-REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${APPS_REPOSITORY})"
-APPS_REPOSITORY_ESCAPED="$(echo ${APPS_REPOSITORY} | sed 's/\//\\\//g')"
-
-if [[ "$ARCHITECTURE" == "aarch64" ]]; then
-  ARCHITECTURE="arm64"
-fi
-
-if [[ $UID != 0 ]]; then
-  echo "Tipi must be started as root"
-  echo "Please re-run this script as"
-  echo "  sudo ./scripts/start"
-  exit 1
-fi
-
-# Configure Tipi if it isn't already configured
+# Configure Tipi
 "${ROOT_FOLDER}/scripts/configure.sh"
 "${ROOT_FOLDER}/scripts/configure.sh"
 
 
-# Get field from json file
-function get_json_field() {
-  local json_file="$1"
-  local field="$2"
-
-  jq -r ".${field}" "${json_file}"
-}
-
-# Deterministically derives 128 bits of cryptographically secure entropy
-function derive_entropy() {
-  SEED_FILE="${STATE_FOLDER}/seed"
-  identifier="${1}"
-  tipi_seed=$(cat "${SEED_FILE}") || true
-
-  if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then
-    echo >&2 "Missing derivation parameter, this is unsafe, exiting."
-    exit 1
-  fi
-
-  # We need `sed 's/^.* //'` to trim the "(stdin)= " prefix from some versions of openssl
-  printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //'
-}
-
 # Copy the config sample if it isn't here
 # Copy the config sample if it isn't here
 if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
 if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
   cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
   cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
 fi
 fi
 
 
-# Get current dns from host
-if [[ -f "/etc/resolv.conf" ]]; then
-  TEMP=$(grep -E -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /etc/resolv.conf | head -n 1)
-fi
-
-# Clean logs folder
-rm -rf "${ROOT_FOLDER}/logs/*"
-
 # Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
 # Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
 if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
 if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
   echo "Generating seed..."
   echo "Generating seed..."
@@ -167,10 +129,6 @@ ENV_FILE=$(mktemp)
 # Copy template configs to intermediary configs
 # Copy template configs to intermediary configs
 [[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
 [[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
 
 
-JWT_SECRET=$(derive_entropy "jwt")
-POSTGRES_PASSWORD=$(derive_entropy "postgres")
-TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
-
 # Override vars with values from settings.json
 # Override vars with values from settings.json
 if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
 if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
 
 
@@ -186,7 +144,7 @@ if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
 
 
   # If appsRepoUrl is set in settings.json, use it
   # If appsRepoUrl is set in settings.json, use it
   if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
   if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
-    APPS_REPOSITORY_ESCAPED="$(echo ${APPS_REPOSITORY} | sed 's/\//\\\//g')"
+    APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
   fi
   fi
 
 
   # If appsRepoId is set in settings.json, use it
   # If appsRepoId is set in settings.json, use it
@@ -208,23 +166,17 @@ if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
   if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
   if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
     INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
     INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
   fi
   fi
+
+  # If storagePath is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
+    storage_path="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
+    STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
+  fi
 fi
 fi
 
 
-echo "Creating .env file with the following values:"
-echo "  DOMAIN=${DOMAIN}"
-echo "  INTERNAL_IP=${INTERNAL_IP}"
-echo "  NGINX_PORT=${NGINX_PORT}"
-echo "  NGINX_PORT_SSL=${NGINX_PORT_SSL}"
-echo "  DNS_IP=${DNS_IP}"
-echo "  ARCHITECTURE=${ARCHITECTURE}"
-echo "  TZ=${TZ}"
-echo "  APPS_REPOSITORY=${APPS_REPOSITORY}"
-echo "  REPO_ID=${REPO_ID}"
-echo "  JWT_SECRET=<redacted>"
-echo "  POSTGRES_PASSWORD=<redacted>"
-echo "  TIPI_VERSION=${TIPI_VERSION}"
-echo "  ROOT_FOLDER=${SED_ROOT_FOLDER}"
-echo "  APPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}"
+# Set array with all new values
+new_values="DOMAIN=${DOMAIN}\nDNS_IP=${DNS_IP}\nAPPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}\nREPO_ID=${REPO_ID}\nNGINX_PORT=${NGINX_PORT}\nNGINX_PORT_SSL=${NGINX_PORT_SSL}\nINTERNAL_IP=${INTERNAL_IP}\nSTORAGE_PATH=${STORAGE_PATH_ESCAPED}\nTZ=${TZ}\nJWT_SECRET=${JWT_SECRET}\nROOT_FOLDER=${SED_ROOT_FOLDER}\nTIPI_VERSION=${TIPI_VERSION}\nARCHITECTURE=${ARCHITECTURE}"
+write_log "Final values: \n${new_values}"
 
 
 for template in ${ENV_FILE}; do
 for template in ${ENV_FILE}; do
   sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
   sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
@@ -240,6 +192,7 @@ for template in ${ENV_FILE}; do
   sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
   sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
   sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
   sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
   sed -i "s/<domain>/${DOMAIN}/g" "${template}"
   sed -i "s/<domain>/${DOMAIN}/g" "${template}"
+  sed -i "s/<storage_path>/${STORAGE_PATH_ESCAPED}/g" "${template}"
 done
 done
 
 
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"

+ 3 - 11
scripts/stop.sh

@@ -1,18 +1,10 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 set -euo pipefail
 set -euo pipefail
 
 
-if [[ $UID != 0 ]]; then
-  echo "Tipi must be stopped as root"
-  echo "Please re-run this script as"
-  echo "  sudo ./scripts/stop.sh"
-  exit 1
-fi
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-# Ensure PWD ends with /runtipi
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-  echo "Please run this script from the runtipi directory"
-  exit 1
-fi
+ensure_root
+ensure_pwd
 
 
 ROOT_FOLDER="${PWD}"
 ROOT_FOLDER="${PWD}"
 
 

+ 2 - 5
scripts/system-info.sh

@@ -1,12 +1,9 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 set -e # Exit immediately if a command exits with a non-zero status.
 set -e # Exit immediately if a command exits with a non-zero status.
 
 
-cd /runtipi || echo ""
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-    echo "Please make sure this script is executed from runtipi/"
-    exit 1
-fi
+ensure_pwd
 
 
 ROOT_FOLDER="$(pwd)"
 ROOT_FOLDER="$(pwd)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 STATE_FOLDER="${ROOT_FOLDER}/state"

+ 2 - 6
scripts/system.sh

@@ -1,12 +1,8 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
-cd /runtipi || echo ""
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-# Ensure PWD ends with /runtipi
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-    echo "Please make sure this script is executed from runtipi/"
-    exit 1
-fi
+ensure_pwd
 
 
 if [ -z ${1+x} ]; then
 if [ -z ${1+x} ]; then
     command=""
     command=""

+ 2 - 1
templates/env-sample

@@ -13,4 +13,5 @@ ROOT_FOLDER_HOST=<root_folder>
 NGINX_PORT=<nginx_port>
 NGINX_PORT=<nginx_port>
 NGINX_PORT_SSL=<nginx_port_ssl>
 NGINX_PORT_SSL=<nginx_port_ssl>
 POSTGRES_PASSWORD=<postgres_password>
 POSTGRES_PASSWORD=<postgres_password>
-DOMAIN=<domain>
+DOMAIN=<domain>
+STORAGE_PATH=<storage_path>