Browse Source

Merge pull request #168 from meienberger/release/0.6.0

Release/0.6.0
Nicolas Meienberger 2 years ago
parent
commit
160eb2623a
71 changed files with 1261 additions and 298 deletions
  1. 2 3
      .github/workflows/ci.yml
  2. 1 0
      .gitignore
  3. 1 1
      Dockerfile
  4. 10 1
      README.md
  5. 41 2
      docker-compose.dev.yml
  6. 49 5
      docker-compose.rc.yml
  7. 48 5
      docker-compose.yml
  8. 4 13
      package.json
  9. 3 1
      packages/dashboard/next.config.js
  10. 3 1
      packages/dashboard/package.json
  11. 2 2
      packages/dashboard/src/components/AppLogo/AppLogo.tsx
  12. 23 0
      packages/dashboard/src/components/Form/FormSwitch.tsx
  13. 17 4
      packages/dashboard/src/components/Form/validators.ts
  14. 2 1
      packages/dashboard/src/components/Layout/Header.tsx
  15. 2 1
      packages/dashboard/src/components/Layout/SideMenu.tsx
  16. 1 1
      packages/dashboard/src/core/api.ts
  17. 2 2
      packages/dashboard/src/core/apollo/client.ts
  18. 4 3
      packages/dashboard/src/core/apollo/links/httpLink.ts
  19. 2 3
      packages/dashboard/src/core/fetcher.ts
  20. 10 0
      packages/dashboard/src/core/helpers/url-helpers.ts
  21. 19 1
      packages/dashboard/src/generated/graphql.tsx
  22. 4 0
      packages/dashboard/src/graphql/queries/getApp.graphql
  23. 1 0
      packages/dashboard/src/graphql/queries/installedApps.graphql
  24. 1 0
      packages/dashboard/src/graphql/queries/listApps.graphql
  25. 20 11
      packages/dashboard/src/hooks/useCachedRessources.ts
  26. 1 0
      packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts
  27. 1 1
      packages/dashboard/src/modules/Apps/components/AppActions.tsx
  28. 38 7
      packages/dashboard/src/modules/Apps/components/InstallForm.tsx
  29. 1 1
      packages/dashboard/src/modules/Apps/components/InstallModal.tsx
  30. 4 2
      packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx
  31. 36 8
      packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
  32. 2 1
      packages/dashboard/src/modules/Auth/components/AuthFormLayout.tsx
  33. 4 0
      packages/dashboard/src/pages/_app.tsx
  34. 6 5
      packages/dashboard/src/pages/_document.tsx
  35. 2 1
      packages/dashboard/src/pages/api/ip.tsx
  36. 0 19
      packages/dashboard/src/state/networkStore.ts
  37. 10 2
      packages/dashboard/src/state/systemStore.ts
  38. 1 0
      packages/system-api/.swcrc
  39. 19 5
      packages/system-api/__mocks__/fs-extra.ts
  40. 4 3
      packages/system-api/package.json
  41. 2 1
      packages/system-api/src/config/config.ts
  42. 22 0
      packages/system-api/src/config/migrations/1662036689477-AppExposedDomain.ts
  43. 4 2
      packages/system-api/src/core/middlewares/sessionMiddleware.ts
  44. 11 3
      packages/system-api/src/modules/apps/__tests__/apps.factory.ts
  45. 57 17
      packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
  46. 10 10
      packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
  47. 84 0
      packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
  48. 9 1
      packages/system-api/src/modules/apps/app.entity.ts
  49. 20 11
      packages/system-api/src/modules/apps/apps.helpers.ts
  50. 4 4
      packages/system-api/src/modules/apps/apps.resolver.ts
  51. 57 12
      packages/system-api/src/modules/apps/apps.service.ts
  52. 13 0
      packages/system-api/src/modules/apps/apps.types.ts
  53. 173 0
      packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts
  54. 8 2
      packages/system-api/src/modules/auth/auth.service.ts
  55. 200 0
      packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts
  56. 6 2
      packages/system-api/src/modules/fs/fs.helpers.ts
  57. 15 14
      packages/system-api/src/server.ts
  58. 4 0
      packages/system-api/src/test/mutations/index.ts
  59. 9 0
      packages/system-api/src/test/mutations/login.graphql
  60. 9 0
      packages/system-api/src/test/mutations/register.graphql
  61. 4 0
      packages/system-api/src/test/queries/index.ts
  62. 3 0
      packages/system-api/src/test/queries/isConfigured.graphql
  63. 88 60
      pnpm-lock.yaml
  64. 32 0
      scripts/start.sh
  65. 1 3
      scripts/unsafe-cleanup.sh
  66. 3 1
      templates/env-sample
  67. 0 14
      traefik/dynamic.yml
  68. 0 0
      traefik/letsencrypt/.gitkeep
  69. 0 10
      traefik/letsencrypt/acme.json
  70. 0 0
      traefik/ssl/.gitkeep
  71. 12 15
      traefik/traefik.yml

+ 2 - 3
.github/workflows/ci.yml

@@ -65,7 +65,6 @@ jobs:
       - name: Run tests
       - name: Run tests
         run: pnpm -r test
         run: pnpm -r test
 
 
-      - uses: codecov/codecov-action@v2
+      - uses: codecov/codecov-action@v3
         with:
         with:
-          token: ${{ secrets.CODECOV_TOKEN }}
-          files: ./packages/system-api/coverage/clover.xml,./packages/dashboard/coverage/clover.xml
+          token: ${{ secrets.CODECOV_TOKEN }}

+ 1 - 0
.gitignore

@@ -11,6 +11,7 @@ repos/*
 !repos/.gitkeep
 !repos/.gitkeep
 apps/*
 apps/*
 !apps/.gitkeep
 !apps/.gitkeep
+traefik/shared
 
 
 scripts/pacapt
 scripts/pacapt
 
 

+ 1 - 1
Dockerfile

@@ -42,4 +42,4 @@ COPY ./packages/system-api /api
 COPY --from=build /dashboard/.next /dashboard/.next
 COPY --from=build /dashboard/.next /dashboard/.next
 COPY ./packages/dashboard /dashboard
 COPY ./packages/dashboard /dashboard
 
 
-WORKDIR /
+WORKDIR /

+ 10 - 1
README.md

@@ -18,7 +18,7 @@
 
 
 Tipi is a personal homeserver orchestrator. It is running docker containers under the hood and provides a simple web interface to manage them. Every service comes with an opinionated configuration in order to remove the need for manual configuration and network setup.
 Tipi is a personal homeserver orchestrator. It is running docker containers under the hood and provides a simple web interface to manage them. Every service comes with an opinionated configuration in order to remove the need for manual configuration and network setup.
 
 
-Check our demo instance : **95.179.210.152** / username: **user@runtipi.com** / password: **runtipi**
+Check our demo instance : **[demo.runtipi.com](https://demo.runtipi.com)** / username: **user@runtipi.com** / password: **runtipi**
 
 
 ## Apps available
 ## Apps available
 - [Adguard Home](https://github.com/AdguardTeam/AdGuardHome) - Adguard Home DNS adblocker
 - [Adguard Home](https://github.com/AdguardTeam/AdGuardHome) - Adguard Home DNS adblocker
@@ -94,6 +94,15 @@ To stop Tipi, run the stop script.
 sudo ./scripts/stop.sh
 sudo ./scripts/stop.sh
 ```
 ```
 
 
+## Linking a domain to your dashboard
+If you want to link a domain to your dashboard, you can do so by providing the `--domain` option in the start script.
+
+```bash
+sudo ./scripts/start.sh --domain mydomain.com
+```
+
+A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
+
 ## ❤️ Contributing
 ## ❤️ Contributing
 
 
 Tipi is made to be very easy to plug in new apps. We welcome and appreciate new contributions.
 Tipi is made to be very easy to plug in new apps. We welcome and appreciate new contributions.

+ 41 - 2
docker-compose.dev.yml

@@ -1,6 +1,23 @@
 version: "3.7"
 version: "3.7"
 
 
 services:
 services:
+  reverse-proxy:
+    container_name: reverse-proxy
+    image: traefik:v2.8
+    restart: always
+    ports:
+      - ${NGINX_PORT-80}:80
+      - ${NGINX_PORT_SSL-443}:443
+      - 8080:8080
+    command: --providers.docker
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+      - ${PWD}/traefik:/root/.config
+      - ${PWD}/traefik/shared:/shared
+      - ${PWD}/traefik/letsencrypt:/letsencrypt
+    networks:
+      - tipi_main_network
+
   tipi-db:
   tipi-db:
     container_name: tipi-db
     container_name: tipi-db
     image: postgres:latest
     image: postgres:latest
@@ -51,8 +68,19 @@ services:
       POSTGRES_HOST: tipi-db
       POSTGRES_HOST: tipi-db
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
+    labels:
+      traefik.enable: true
+      # Web
+      traefik.http.routers.api.rule: PathPrefix(`/api`)
+      traefik.http.routers.api.service: api
+      traefik.http.routers.api.entrypoints: web
+      traefik.http.routers.api.middlewares: api-stripprefix
+      traefik.http.services.api.loadbalancer.server.port: 3001
+      # Middlewares
+      traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
 
 
   dashboard:
   dashboard:
     build:
     build:
@@ -66,16 +94,27 @@ services:
       - tipi_main_network
       - tipi_main_network
     environment:
     environment:
       - INTERNAL_IP=${INTERNAL_IP}
       - INTERNAL_IP=${INTERNAL_IP}
+      - DOMAIN=${DOMAIN}
     volumes:
     volumes:
       - ${PWD}/packages/dashboard/src:/dashboard/src
       - ${PWD}/packages/dashboard/src:/dashboard/src
       # - /dashboard/node_modules
       # - /dashboard/node_modules
       # - /dashboard/.next
       # - /dashboard/.next
     labels:
     labels:
       traefik.enable: true
       traefik.enable: true
-      traefik.http.routers.dashboard.rule: PathPrefix("/") # Host(`tipi.local`) &&
-      traefik.http.routers.dashboard.entrypoints: webinsecure
+      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
+      traefik.http.routers.dashboard-redirect.entrypoints: web
+      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
+      traefik.http.routers.dashboard-redirect.service: dashboard
+      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
+
+      # Web
+      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.service: dashboard
+      traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
+      # Middlewares
+      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
+      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
 
 
 networks:
 networks:
   tipi_main_network:
   tipi_main_network:

+ 49 - 5
docker-compose.rc.yml

@@ -3,15 +3,17 @@ version: "3.7"
 services:
 services:
   reverse-proxy:
   reverse-proxy:
     container_name: reverse-proxy
     container_name: reverse-proxy
-    image: traefik:v2.6
+    image: traefik:v2.8
     restart: always
     restart: always
     ports:
     ports:
       - ${NGINX_PORT-80}:80
       - ${NGINX_PORT-80}:80
-      - ${PROXY_PORT-8080}:8080
-    command: --api.insecure=true --providers.docker
+      - ${NGINX_PORT_SSL-443}:443
+      - 8080:8080
+    command: --providers.docker
     volumes:
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}/traefik:/root/.config
       - ${PWD}/traefik:/root/.config
+      - ${PWD}/traefik/shared:/shared
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
 
 
@@ -62,10 +64,28 @@ services:
       NODE_ENV: production
       NODE_ENV: production
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     dns:
     dns:
       - ${DNS_IP}
       - ${DNS_IP}
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
+    labels:
+      traefik.enable: true
+      # Web
+      traefik.http.routers.api.rule: PathPrefix(`/api`)
+      traefik.http.routers.api.service: api
+      traefik.http.routers.api.entrypoints: web
+      traefik.http.routers.api.middlewares: api-stripprefix
+      traefik.http.services.api.loadbalancer.server.port: 3001
+      # Websecure
+      traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
+      traefik.http.routers.api-secure.entrypoints: websecure
+      traefik.http.routers.api-secure.service: api-secure
+      traefik.http.routers.api-secure.tls.certresolver: myresolver
+      traefik.http.routers.api-secure.middlewares: api-stripprefix
+      traefik.http.services.api-secure.loadbalancer.server.port: 3001
+      # Middlewares
+      traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
 
 
   dashboard:
   dashboard:
     image: meienberger/runtipi:rc-${TIPI_VERSION}
     image: meienberger/runtipi:rc-${TIPI_VERSION}
@@ -78,12 +98,36 @@ services:
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
       NODE_ENV: production
+      DOMAIN: ${DOMAIN}
     labels:
     labels:
       traefik.enable: true
       traefik.enable: true
-      traefik.http.routers.dashboard.rule: PathPrefix("/") # Host(`tipi.local`) &&
-      traefik.http.routers.dashboard.entrypoints: webinsecure
+      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
+      traefik.http.routers.dashboard-redirect.entrypoints: web
+      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
+      traefik.http.routers.dashboard-redirect.service: dashboard
+      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
+
+      traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
+      traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
+      traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
+      traefik.http.routers.dashboard-redirect-secure.service: dashboard
+      traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
+      traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
+
+      # Web
+      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.service: dashboard
+      traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
+      # Websecure
+      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
+      traefik.http.routers.dashboard-secure.service: dashboard-secure
+      traefik.http.routers.dashboard-secure.entrypoints: websecure
+      traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
+      traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
+      # Middlewares
+      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
+      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
 
 
 networks:
 networks:
   tipi_main_network:
   tipi_main_network:

+ 48 - 5
docker-compose.yml

@@ -3,15 +3,16 @@ version: "3.9"
 services:
 services:
   reverse-proxy:
   reverse-proxy:
     container_name: reverse-proxy
     container_name: reverse-proxy
-    image: traefik:v2.6
+    image: traefik:v2.8
     restart: always
     restart: always
     ports:
     ports:
       - ${NGINX_PORT-80}:80
       - ${NGINX_PORT-80}:80
-      - ${PROXY_PORT-8080}:8080
-    command: --api.insecure=true --providers.docker
+      - ${NGINX_PORT_SSL-443}:443
+    command: --providers.docker
     volumes:
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}/traefik:/root/.config
       - ${PWD}/traefik:/root/.config
+      - ${PWD}/traefik/shared:/shared
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
 
 
@@ -63,10 +64,28 @@ services:
       NODE_ENV: production
       NODE_ENV: production
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     dns:
     dns:
       - ${DNS_IP}
       - ${DNS_IP}
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
+    labels:
+      traefik.enable: true
+      # Web
+      traefik.http.routers.api.rule: PathPrefix(`/api`)
+      traefik.http.routers.api.service: api
+      traefik.http.routers.api.entrypoints: web
+      traefik.http.routers.api.middlewares: api-stripprefix
+      traefik.http.services.api.loadbalancer.server.port: 3001
+      # Websecure
+      traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
+      traefik.http.routers.api-secure.entrypoints: websecure
+      traefik.http.routers.api-secure.service: api-secure
+      traefik.http.routers.api-secure.tls.certresolver: myresolver
+      traefik.http.routers.api-secure.middlewares: api-stripprefix
+      traefik.http.services.api-secure.loadbalancer.server.port: 3001
+      # Middlewares
+      traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
 
 
   dashboard:
   dashboard:
     image: meienberger/runtipi:${TIPI_VERSION}
     image: meienberger/runtipi:${TIPI_VERSION}
@@ -80,12 +99,36 @@ services:
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
       NODE_ENV: production
+      DOMAIN: ${DOMAIN}
     labels:
     labels:
       traefik.enable: true
       traefik.enable: true
-      traefik.http.routers.dashboard.rule: PathPrefix("/") # Host(`tipi.local`) &&
-      traefik.http.routers.dashboard.entrypoints: webinsecure
+      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
+      traefik.http.routers.dashboard-redirect.entrypoints: web
+      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
+      traefik.http.routers.dashboard-redirect.service: dashboard
+      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
+
+      traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
+      traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
+      traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
+      traefik.http.routers.dashboard-redirect-secure.service: dashboard
+      traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
+      traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
+
+      # Web
+      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.service: dashboard
+      traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
+      # Websecure
+      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
+      traefik.http.routers.dashboard-secure.service: dashboard-secure
+      traefik.http.routers.dashboard-secure.entrypoints: websecure
+      traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
+      traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
+      # Middlewares
+      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
+      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
 
 
 networks:
 networks:
   tipi_main_network:
   tipi_main_network:

+ 4 - 13
package.json

@@ -1,9 +1,8 @@
 {
 {
   "name": "runtipi",
   "name": "runtipi",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "A homeserver for everyone",
   "description": "A homeserver for everyone",
   "scripts": {
   "scripts": {
-    "test": "jest",
     "prepare": "husky install",
     "prepare": "husky install",
     "commit": "git-cz",
     "commit": "git-cz",
     "act:test-install": "act --container-architecture linux/amd64 -j test-install",
     "act:test-install": "act --container-architecture linux/amd64 -j test-install",
@@ -11,23 +10,16 @@
     "start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
     "start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
     "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:prod": "docker-compose --env-file .env up --build",
     "start:prod": "docker-compose --env-file .env up --build",
-    "build:common": "cd packages/common && npm run build",
     "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
     "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
     "version": "echo $npm_package_version"
     "version": "echo $npm_package_version"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@types/jest": "^27.5.0",
-    "@types/js-yaml": "^4.0.5",
-    "@types/node": "17.0.31",
-    "husky": "^8.0.1",
-    "jest": "^28.1.0",
-    "js-yaml": "^4.1.0",
-    "ts-jest": "^28.0.2",
-    "typescript": "4.6.4",
     "@commitlint/cli": "^17.0.3",
     "@commitlint/cli": "^17.0.3",
     "@commitlint/config-conventional": "^17.0.3",
     "@commitlint/config-conventional": "^17.0.3",
     "@commitlint/cz-commitlint": "^17.0.3",
     "@commitlint/cz-commitlint": "^17.0.3",
-    "commitizen": "^4.2.4"
+    "commitizen": "^4.2.4",
+    "husky": "^8.0.1",
+    "inquirer": "8.2.4"
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -39,7 +31,6 @@
     "url": "https://github.com/meienberger/runtipi/issues"
     "url": "https://github.com/meienberger/runtipi/issues"
   },
   },
   "homepage": "https://github.com/meienberger/runtipi#readme",
   "homepage": "https://github.com/meienberger/runtipi#readme",
-  "dependencies": {},
   "config": {
   "config": {
     "commitizen": {
     "commitizen": {
       "path": "@commitlint/cz-commitlint"
       "path": "@commitlint/cz-commitlint"

+ 3 - 1
packages/dashboard/next.config.js

@@ -1,5 +1,5 @@
 /** @type {import('next').NextConfig} */
 /** @type {import('next').NextConfig} */
-const { NODE_ENV, INTERNAL_IP } = process.env;
+const { INTERNAL_IP, DOMAIN } = process.env;
 
 
 const nextConfig = {
 const nextConfig = {
   webpackDevMiddleware: (config) => {
   webpackDevMiddleware: (config) => {
@@ -12,7 +12,9 @@ const nextConfig = {
   reactStrictMode: true,
   reactStrictMode: true,
   env: {
   env: {
     INTERNAL_IP: INTERNAL_IP,
     INTERNAL_IP: INTERNAL_IP,
+    NEXT_PUBLIC_DOMAIN: DOMAIN,
   },
   },
+  basePath: '/dashboard',
 };
 };
 
 
 module.exports = nextConfig;
 module.exports = nextConfig;

+ 3 - 1
packages/dashboard/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "dashboard",
   "name": "dashboard",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "test": "jest --colors",
     "test": "jest --colors",
@@ -60,8 +60,10 @@
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
     "eslint-config-next": "12.1.4",
     "eslint-plugin-import": "^2.25.3",
     "eslint-plugin-import": "^2.25.3",
+    "jest": "^28.1.0",
     "postcss": "^8.4.12",
     "postcss": "^8.4.12",
     "tailwindcss": "^3.0.23",
     "tailwindcss": "^3.0.23",
+    "ts-jest": "^28.0.2",
     "typescript": "4.6.4"
     "typescript": "4.6.4"
   }
   }
 }
 }

+ 2 - 2
packages/dashboard/src/components/AppLogo/AppLogo.tsx

@@ -2,8 +2,8 @@ import React from 'react';
 import { useSytemStore } from '../../state/systemStore';
 import { useSytemStore } from '../../state/systemStore';
 
 
 const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
 const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const { internalIp } = useSytemStore();
-  const logoUrl = `http://${internalIp}:3001/apps/${id}/metadata/logo.jpg`;
+  const { baseUrl } = useSytemStore();
+  const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
 
 
   return (
   return (
     <div aria-label={alt} className={`drop-shadow ${className}`} style={{ width: size, height: size }}>
     <div aria-label={alt} className={`drop-shadow ${className}`} style={{ width: size, height: size }}>

+ 23 - 0
packages/dashboard/src/components/Form/FormSwitch.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { Input, Switch } from '@chakra-ui/react';
+import clsx from 'clsx';
+
+interface IProps {
+  placeholder?: string;
+  type?: Parameters<typeof Input>[0]['type'];
+  label?: string;
+  className?: string;
+  size?: Parameters<typeof Input>[0]['size'];
+  checked?: boolean;
+}
+
+const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
+  return (
+    <div className={clsx('transition-all', className)}>
+      {label && <label className="mr-2">{label}</label>}
+      <Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
+    </div>
+  );
+};
+
+export default FormSwitch;

+ 17 - 4
packages/dashboard/src/components/Form/validators.ts

@@ -1,12 +1,13 @@
 import validator from 'validator';
 import validator from 'validator';
 import { FieldTypesEnum, FormField } from '../../generated/graphql';
 import { FieldTypesEnum, FormField } from '../../generated/graphql';
+import { IFormValues } from '../../modules/Apps/components/InstallForm';
 
 
-const validateField = (field: FormField, value: string): string | undefined => {
+const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
   if (field.required && !value) {
   if (field.required && !value) {
     return `${field.label} is required`;
     return `${field.label} is required`;
   }
   }
 
 
-  if (!value) {
+  if (!value || typeof value !== 'string') {
     return;
     return;
   }
   }
 
 
@@ -59,12 +60,24 @@ const validateField = (field: FormField, value: string): string | undefined => {
   }
   }
 };
 };
 
 
-export const validateAppConfig = (values: Record<string, string>, fields: FormField[]) => {
+const validateDomain = (domain?: string): string | undefined => {
+  if (!validator.isFQDN(domain || '')) {
+    return `${domain} must be a valid domain`;
+  }
+};
+
+export const validateAppConfig = (values: IFormValues, fields: FormField[]) => {
+  const { exposed, domain, ...config } = values;
+
   const errors: any = {};
   const errors: any = {};
 
 
   fields.forEach((field) => {
   fields.forEach((field) => {
-    errors[field.env_variable] = validateField(field, values[field.env_variable]);
+    errors[field.env_variable] = validateField(field, config[field.env_variable]);
   });
   });
 
 
+  if (exposed) {
+    errors.domain = validateDomain(domain);
+  }
+
   return errors;
   return errors;
 };
 };

+ 2 - 1
packages/dashboard/src/components/Layout/Header.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 import Link from 'next/link';
 import Link from 'next/link';
 import { Flex } from '@chakra-ui/react';
 import { Flex } from '@chakra-ui/react';
 import { FiMenu } from 'react-icons/fi';
 import { FiMenu } from 'react-icons/fi';
+import { getUrl } from '../../core/helpers/url-helpers';
 
 
 interface IProps {
 interface IProps {
   onClickMenu: () => void;
   onClickMenu: () => void;
@@ -16,7 +17,7 @@ const Header: React.FC<IProps> = ({ onClickMenu }) => {
         </div>
         </div>
         <Flex justifyContent="center" flex="1">
         <Flex justifyContent="center" flex="1">
           <Link href="/" passHref>
           <Link href="/" passHref>
-            <img src="/tipi.png" alt="Tipi Logo" width={30} height={30} />
+            <img src={getUrl('tipi.png')} alt="Tipi Logo" width={30} height={30} />
           </Link>
           </Link>
         </Flex>
         </Flex>
       </Flex>
       </Flex>

+ 2 - 1
packages/dashboard/src/components/Layout/SideMenu.tsx

@@ -9,6 +9,7 @@ import clsx from 'clsx';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { IconType } from 'react-icons';
 import { IconType } from 'react-icons';
 import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
 import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
+import { getUrl } from '../../core/helpers/url-helpers';
 
 
 const SideMenu: React.FC = () => {
 const SideMenu: React.FC = () => {
   const router = useRouter();
   const router = useRouter();
@@ -45,7 +46,7 @@ const SideMenu: React.FC = () => {
 
 
   return (
   return (
     <Box className="flex-1 flex flex-col p-0 md:p-4">
     <Box className="flex-1 flex flex-col p-0 md:p-4">
-      <img className="self-center mb-5 logo mt-0 md:mt-5" src="/tipi.png" width={512} height={512} />
+      <img className="self-center mb-5 logo mt-0 md:mt-5" src={getUrl('tipi.png')} width={512} height={512} />
       <List spacing={3} className="pt-5">
       <List spacing={3} className="pt-5">
         {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
         {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
         {renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
         {renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}

+ 1 - 1
packages/dashboard/src/core/api.ts

@@ -12,7 +12,7 @@ const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
   const { endpoint, method = 'GET', params, data } = fetchParams;
   const { endpoint, method = 'GET', params, data } = fetchParams;
 
 
   const { getState } = useSytemStore;
   const { getState } = useSytemStore;
-  const BASE_URL = `http://${getState().internalIp}:3001`;
+  const BASE_URL = getState().baseUrl;
 
 
   const response = await axios.request<T & { error?: string }>({
   const response = await axios.request<T & { error?: string }>({
     method,
     method,

+ 2 - 2
packages/dashboard/src/core/apollo/client.ts

@@ -1,8 +1,8 @@
 import { ApolloClient, from, InMemoryCache } from '@apollo/client';
 import { ApolloClient, from, InMemoryCache } from '@apollo/client';
 import links from './links';
 import links from './links';
 
 
-export const createApolloClient = async (ip: string): Promise<ApolloClient<any>> => {
-  const additiveLink = from([links.errorLink, links.httpLink(ip)]);
+export const createApolloClient = async (url: string): Promise<ApolloClient<any>> => {
+  const additiveLink = from([links.errorLink, links.httpLink(url)]);
 
 
   return new ApolloClient({
   return new ApolloClient({
     link: additiveLink,
     link: additiveLink,

+ 4 - 3
packages/dashboard/src/core/apollo/links/httpLink.ts

@@ -1,9 +1,10 @@
 import { HttpLink } from '@apollo/client';
 import { HttpLink } from '@apollo/client';
 
 
-const httpLink = (ip: string) =>
-  new HttpLink({
-    uri: `http://${ip}:3001/graphql`,
+const httpLink = (url: string) => {
+  return new HttpLink({
+    uri: `${url}/graphql`,
     credentials: 'include',
     credentials: 'include',
   });
   });
+};
 
 
 export default httpLink;
 export default httpLink;

+ 2 - 3
packages/dashboard/src/core/fetcher.ts

@@ -3,10 +3,9 @@ import axios from 'axios';
 import { useSytemStore } from '../state/systemStore';
 import { useSytemStore } from '../state/systemStore';
 
 
 const fetcher: BareFetcher<any> = (url: string) => {
 const fetcher: BareFetcher<any> = (url: string) => {
-  const { getState } = useSytemStore;
-  const BASE_URL = `http://${getState().internalIp}:3001`;
+  const { baseUrl } = useSytemStore.getState();
 
 
-  return axios.get(url, { baseURL: BASE_URL, withCredentials: true }).then((res) => res.data);
+  return axios.get(url, { baseURL: baseUrl, withCredentials: true }).then((res) => res.data);
 };
 };
 
 
 export default fetcher;
 export default fetcher;

+ 10 - 0
packages/dashboard/src/core/helpers/url-helpers.ts

@@ -0,0 +1,10 @@
+export const getUrl = (url: string) => {
+  const domain = process.env.NEXT_PUBLIC_DOMAIN;
+  let prefix = '';
+
+  if (domain !== 'tipi.localhost') {
+    prefix = 'dashboard';
+  }
+
+  return `/${prefix}/${url}`;
+};

+ 19 - 1
packages/dashboard/src/generated/graphql.tsx

@@ -23,6 +23,8 @@ export type App = {
   __typename?: 'App';
   __typename?: 'App';
   config: Scalars['JSONObject'];
   config: Scalars['JSONObject'];
   createdAt: Scalars['DateTime'];
   createdAt: Scalars['DateTime'];
+  domain: Scalars['String'];
+  exposed: Scalars['Boolean'];
   id: Scalars['String'];
   id: Scalars['String'];
   info?: Maybe<AppInfo>;
   info?: Maybe<AppInfo>;
   lastOpened: Scalars['DateTime'];
   lastOpened: Scalars['DateTime'];
@@ -40,6 +42,7 @@ export enum AppCategoriesEnum {
   Development = 'DEVELOPMENT',
   Development = 'DEVELOPMENT',
   Featured = 'FEATURED',
   Featured = 'FEATURED',
   Finance = 'FINANCE',
   Finance = 'FINANCE',
+  Gaming = 'GAMING',
   Media = 'MEDIA',
   Media = 'MEDIA',
   Music = 'MUSIC',
   Music = 'MUSIC',
   Network = 'NETWORK',
   Network = 'NETWORK',
@@ -55,7 +58,9 @@ export type AppInfo = {
   available: Scalars['Boolean'];
   available: Scalars['Boolean'];
   categories: Array<AppCategoriesEnum>;
   categories: Array<AppCategoriesEnum>;
   description: Scalars['String'];
   description: Scalars['String'];
+  exposable?: Maybe<Scalars['Boolean']>;
   form_fields: Array<FormField>;
   form_fields: Array<FormField>;
+  https?: Maybe<Scalars['Boolean']>;
   id: Scalars['String'];
   id: Scalars['String'];
   name: Scalars['String'];
   name: Scalars['String'];
   port: Scalars['Float'];
   port: Scalars['Float'];
@@ -68,6 +73,8 @@ export type AppInfo = {
 };
 };
 
 
 export type AppInputType = {
 export type AppInputType = {
+  domain: Scalars['String'];
+  exposed: Scalars['Boolean'];
   form: Scalars['JSONObject'];
   form: Scalars['JSONObject'];
   id: Scalars['String'];
   id: Scalars['String'];
 };
 };
@@ -286,6 +293,8 @@ export type GetAppQuery = {
     status: AppStatusEnum;
     status: AppStatusEnum;
     config: any;
     config: any;
     version?: number | null;
     version?: number | null;
+    exposed: boolean;
+    domain: string;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     info?: {
     info?: {
       __typename?: 'AppInfo';
       __typename?: 'AppInfo';
@@ -301,6 +310,8 @@ export type GetAppQuery = {
       source: string;
       source: string;
       categories: Array<AppCategoriesEnum>;
       categories: Array<AppCategoriesEnum>;
       url_suffix?: string | null;
       url_suffix?: string | null;
+      https?: boolean | null;
+      exposable?: boolean | null;
       form_fields: Array<{
       form_fields: Array<{
         __typename?: 'FormField';
         __typename?: 'FormField';
         type: FieldTypesEnum;
         type: FieldTypesEnum;
@@ -326,7 +337,7 @@ export type InstalledAppsQuery = {
     config: any;
     config: any;
     version?: number | null;
     version?: number | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
-    info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string } | null;
+    info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string; https?: boolean | null } | null;
   }>;
   }>;
 };
 };
 
 
@@ -352,6 +363,7 @@ export type ListAppsQuery = {
       short_desc: string;
       short_desc: string;
       author: string;
       author: string;
       categories: Array<AppCategoriesEnum>;
       categories: Array<AppCategoriesEnum>;
+      https?: boolean | null;
     }>;
     }>;
   };
   };
 };
 };
@@ -693,6 +705,8 @@ export const GetAppDocument = gql`
       status
       status
       config
       config
       version
       version
+      exposed
+      domain
       updateInfo {
       updateInfo {
         current
         current
         latest
         latest
@@ -711,6 +725,8 @@ export const GetAppDocument = gql`
         source
         source
         categories
         categories
         url_suffix
         url_suffix
+        https
+        exposable
         form_fields {
         form_fields {
           type
           type
           label
           label
@@ -770,6 +786,7 @@ export const InstalledAppsDocument = gql`
         description
         description
         tipi_version
         tipi_version
         short_desc
         short_desc
+        https
       }
       }
     }
     }
   }
   }
@@ -846,6 +863,7 @@ export const ListAppsDocument = gql`
         short_desc
         short_desc
         author
         author
         categories
         categories
+        https
       }
       }
       total
       total
     }
     }

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

@@ -4,6 +4,8 @@ query GetApp($appId: String!) {
     status
     status
     config
     config
     version
     version
+    exposed
+    domain
     updateInfo {
     updateInfo {
       current
       current
       latest
       latest
@@ -22,6 +24,8 @@ query GetApp($appId: String!) {
       source
       source
       categories
       categories
       url_suffix
       url_suffix
+      https
+      exposable
       form_fields {
       form_fields {
         type
         type
         label
         label

+ 1 - 0
packages/dashboard/src/graphql/queries/installedApps.graphql

@@ -15,6 +15,7 @@ query InstalledApps {
       description
       description
       tipi_version
       tipi_version
       short_desc
       short_desc
+      https
     }
     }
   }
   }
 }
 }

+ 1 - 0
packages/dashboard/src/graphql/queries/listApps.graphql

@@ -11,6 +11,7 @@ query ListApps {
       short_desc
       short_desc
       author
       author
       categories
       categories
+      https
     }
     }
     total
     total
   }
   }

+ 20 - 11
packages/dashboard/src/hooks/useCachedRessources.ts

@@ -4,6 +4,7 @@ import axios from 'axios';
 import useSWR, { BareFetcher } from 'swr';
 import useSWR, { BareFetcher } from 'swr';
 import { createApolloClient } from '../core/apollo/client';
 import { createApolloClient } from '../core/apollo/client';
 import { useSytemStore } from '../state/systemStore';
 import { useSytemStore } from '../state/systemStore';
+import { getUrl } from '../core/helpers/url-helpers';
 
 
 interface IReturnProps {
 interface IReturnProps {
   client?: ApolloClient<unknown>;
   client?: ApolloClient<unknown>;
@@ -11,18 +12,18 @@ interface IReturnProps {
 }
 }
 
 
 const fetcher: BareFetcher<any> = (url: string) => {
 const fetcher: BareFetcher<any> = (url: string) => {
-  return axios.get(url).then((res) => res.data);
+  return axios.get(getUrl(url)).then((res) => res.data);
 };
 };
 
 
 export default function useCachedResources(): IReturnProps {
 export default function useCachedResources(): IReturnProps {
-  const { data } = useSWR('/api/ip', fetcher);
-  const { internalIp, setInternalIp } = useSytemStore();
+  const { data } = useSWR<{ ip: string; domain: string }>('api/ip', fetcher);
+  const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSytemStore();
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [client, setClient] = useState<ApolloClient<unknown>>();
   const [client, setClient] = useState<ApolloClient<unknown>>();
 
 
-  async function loadResourcesAndDataAsync(ip: string) {
+  async function loadResourcesAndDataAsync(url: string) {
     try {
     try {
-      const restoredClient = await createApolloClient(ip);
+      const restoredClient = await createApolloClient(url);
 
 
       setClient(restoredClient);
       setClient(restoredClient);
     } catch (error) {
     } catch (error) {
@@ -34,16 +35,24 @@ export default function useCachedResources(): IReturnProps {
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
-    if (data?.ip && !internalIp) {
-      setInternalIp(data.ip);
+    const { ip, domain } = data || {};
+    if (ip && !baseUrl) {
+      setInternalIp(ip);
+      setDomain(domain);
+
+      if (!domain || domain === 'tipi.localhost') {
+        setBaseUrl(`http://${ip}/api`);
+      } else {
+        setBaseUrl(`https://${domain}/api`);
+      }
     }
     }
-  }, [data?.ip, internalIp, setInternalIp]);
+  }, [baseUrl, setBaseUrl, data, setInternalIp, setDomain]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (internalIp) {
-      loadResourcesAndDataAsync(internalIp);
+    if (baseUrl) {
+      loadResourcesAndDataAsync(baseUrl);
     }
     }
-  }, [internalIp]);
+  }, [baseUrl]);
 
 
   return { client, isLoadingComplete };
   return { client, isLoadingComplete };
 }
 }

+ 1 - 0
packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts

@@ -39,4 +39,5 @@ export const colorSchemeForCategory: Record<AppCategoriesEnum, string> = {
   [AppCategoriesEnum.Books]: 'blue',
   [AppCategoriesEnum.Books]: 'blue',
   [AppCategoriesEnum.Music]: 'green',
   [AppCategoriesEnum.Music]: 'green',
   [AppCategoriesEnum.Finance]: 'orange',
   [AppCategoriesEnum.Finance]: 'orange',
+  [AppCategoriesEnum.Gaming]: 'purple',
 };
 };

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

@@ -41,7 +41,7 @@ const ActionButton: React.FC<BtnProps> = (props) => {
 };
 };
 
 
 const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
 const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
-  const hasSettings = Object.keys(app.form_fields).length > 0;
+  const hasSettings = Object.keys(app.form_fields).length > 0 || app.exposable;
 
 
   const buttons: JSX.Element[] = [];
   const buttons: JSX.Element[] = [];
 
 

+ 38 - 7
packages/dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -2,6 +2,7 @@ import { Button } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import { Form, Field } from 'react-final-form';
 import { Form, Field } from 'react-final-form';
 import FormInput from '../../../components/Form/FormInput';
 import FormInput from '../../../components/Form/FormInput';
+import FormSwitch from '../../../components/Form/FormSwitch';
 import { validateAppConfig } from '../../../components/Form/validators';
 import { validateAppConfig } from '../../../components/Form/validators';
 import { AppInfo, FormField } from '../../../generated/graphql';
 import { AppInfo, FormField } from '../../../generated/graphql';
 
 
@@ -9,12 +10,19 @@ interface IProps {
   formFields: AppInfo['form_fields'];
   formFields: AppInfo['form_fields'];
   onSubmit: (values: Record<string, unknown>) => void;
   onSubmit: (values: Record<string, unknown>) => void;
   initalValues?: Record<string, string>;
   initalValues?: Record<string, string>;
+  exposable?: boolean | null;
 }
 }
 
 
+export type IFormValues = {
+  exposed?: boolean;
+  domain?: string;
+  [key: string]: string | boolean | undefined;
+};
+
 const hiddenTypes = ['random'];
 const hiddenTypes = ['random'];
 const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
 const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
 
 
-const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) => {
+const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exposable }) => {
   const renderField = (field: FormField) => {
   const renderField = (field: FormField) => {
     return (
     return (
       <Field
       <Field
@@ -25,18 +33,41 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) =
     );
     );
   };
   };
 
 
+  const renderExposeForm = (isExposedChecked?: boolean) => {
+    return (
+      <>
+        <Field key="exposed" name="exposed" type="checkbox" render={({ input }) => <FormSwitch className="mb-3" label="Expose app ?" {...input} />} />
+        {isExposedChecked && (
+          <>
+            <Field
+              key="domain"
+              name="domain"
+              render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label="Domain name" {...input} />}
+            />
+            <span className="text-sm">
+              Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
+            </span>
+          </>
+        )}
+      </>
+    );
+  };
+
   return (
   return (
-    <Form<Record<string, string>>
+    <Form<IFormValues>
       initialValues={initalValues}
       initialValues={initalValues}
       onSubmit={onSubmit}
       onSubmit={onSubmit}
       validateOnBlur={true}
       validateOnBlur={true}
       validate={(values) => validateAppConfig(values, formFields)}
       validate={(values) => validateAppConfig(values, formFields)}
-      render={({ handleSubmit, validating, submitting }) => (
+      render={({ handleSubmit, validating, submitting, values }) => (
         <form className="flex flex-col" onSubmit={handleSubmit}>
         <form className="flex flex-col" onSubmit={handleSubmit}>
-          {formFields.filter(typeFilter).map(renderField)}
-          <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
-            {initalValues ? 'Update' : 'Install'}
-          </Button>
+          <>
+            {formFields.filter(typeFilter).map(renderField)}
+            {exposable && renderExposeForm(values.exposed)}
+            <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
+              {initalValues ? 'Update' : 'Install'}
+            </Button>
+          </>
         </form>
         </form>
       )}
       )}
     />
     />

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

@@ -18,7 +18,7 @@ const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
         <ModalHeader>Install {app.name}</ModalHeader>
         <ModalHeader>Install {app.name}</ModalHeader>
         <ModalCloseButton />
         <ModalCloseButton />
         <ModalBody>
         <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} />
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
         </ModalBody>
         </ModalBody>
       </ModalContent>
       </ModalContent>
     </Modal>
     </Modal>

+ 4 - 2
packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx

@@ -7,11 +7,13 @@ interface IProps {
   app: AppInfo;
   app: AppInfo;
   config: App['config'];
   config: App['config'];
   isOpen: boolean;
   isOpen: boolean;
+  exposed?: boolean;
+  domain?: string;
   onClose: () => void;
   onClose: () => void;
   onSubmit: (values: Record<string, any>) => void;
   onSubmit: (values: Record<string, any>) => void;
 }
 }
 
 
-const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit }) => {
+const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => {
   return (
   return (
     <Modal isOpen={isOpen} onClose={onClose}>
     <Modal isOpen={isOpen} onClose={onClose}>
       <ModalOverlay />
       <ModalOverlay />
@@ -19,7 +21,7 @@ const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, o
         <ModalHeader>Update {app.name} config</ModalHeader>
         <ModalHeader>Update {app.name} config</ModalHeader>
         <ModalCloseButton />
         <ModalCloseButton />
         <ModalBody>
         <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={config} />
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
         </ModalBody>
         </ModalBody>
       </ModalContent>
       </ModalContent>
     </Modal>
     </Modal>

+ 36 - 8
packages/dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -23,9 +23,10 @@ import {
   useUpdateAppMutation,
   useUpdateAppMutation,
 } from '../../../generated/graphql';
 } from '../../../generated/graphql';
 import UpdateModal from '../components/UpdateModal';
 import UpdateModal from '../components/UpdateModal';
+import { IFormValues } from '../components/InstallForm';
 
 
 interface IProps {
 interface IProps {
-  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo'>;
+  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo' | 'exposed' | 'domain'>;
   info: AppInfo;
   info: AppInfo;
 }
 }
 
 
@@ -61,11 +62,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
     }
     }
   };
   };
 
 
-  const handleInstallSubmit = async (values: Record<string, any>) => {
+  const handleInstallSubmit = async (values: IFormValues) => {
     installDisclosure.onClose();
     installDisclosure.onClose();
+    const { exposed, domain, ...form } = values;
     try {
     try {
       await install({
       await install({
-        variables: { input: { form: values, id: info.id } },
+        variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
         optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
         optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
       });
       });
     } catch (error) {
     } catch (error) {
@@ -99,14 +101,16 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
     }
     }
   };
   };
 
 
-  const handleUpdateSettingsSubmit = async (values: Record<string, any>) => {
+  const handleUpdateSettingsSubmit = async (values: IFormValues) => {
     try {
     try {
-      await updateConfig({ variables: { input: { form: values, id: info.id } } });
+      const { exposed, domain, ...form } = values;
+      await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
       toast({
       toast({
         title: 'Success',
         title: 'Success',
-        description: 'App config updated successfully',
+        description: 'App config updated successfully. Restart the app to apply the changes.',
         position: 'top',
         position: 'top',
         status: 'success',
         status: 'success',
+        isClosable: true,
       });
       });
       updateSettingsDisclosure.onClose();
       updateSettingsDisclosure.onClose();
     } catch (error) {
     } catch (error) {
@@ -123,6 +127,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
         description: 'App updated successfully',
         description: 'App updated successfully',
         position: 'top',
         position: 'top',
         status: 'success',
         status: 'success',
+        isClosable: true,
       });
       });
     } catch (error) {
     } catch (error) {
       handleError(error);
       handleError(error);
@@ -130,7 +135,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
   };
   };
 
 
   const handleOpen = () => {
   const handleOpen = () => {
-    window.open(`http://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
+    const { https } = info;
+    const protocol = https ? 'https' : 'http';
+
+    if (typeof window !== 'undefined') {
+      window.open(`${protocol}://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
+    }
   };
   };
 
 
   const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
   const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
@@ -144,6 +154,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
           <div className="flex flex-col justify-between flex-1 ml-0 md:ml-4">
           <div className="flex flex-col justify-between flex-1 ml-0 md:ml-4">
             <div className="mt-3 items-center self-center flex flex-col md:items-start md:self-start md:mt-0">
             <div className="mt-3 items-center self-center flex flex-col md:items-start md:self-start md:mt-0">
               <h1 className="font-bold text-2xl">{info.name}</h1>
               <h1 className="font-bold text-2xl">{info.name}</h1>
+              {app?.domain && app.exposed && (
+                <a target="_blank" rel="noreferrer" className="text-blue-500 text-md" href={`https://${app.domain}`}>
+                  <Flex className="items-center">
+                    {app.domain}
+                    <FiExternalLink className="ml-1" />
+                  </Flex>
+                </a>
+              )}
+
               <h2 className="text-center md:text-left">{info.short_desc}</h2>
               <h2 className="text-center md:text-left">{info.short_desc}</h2>
               <h3 className="text-center md:text-left text-sm">
               <h3 className="text-center md:text-left text-sm">
                 version: <b>{version}</b>
                 version: <b>{version}</b>
@@ -158,6 +177,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
               )}
               )}
               <p className="text-xs text-gray-600">By {info.author}</p>
               <p className="text-xs text-gray-600">By {info.author}</p>
             </div>
             </div>
+
             <div className="flex flex-1">
             <div className="flex flex-1">
               <AppActions
               <AppActions
                 updateAvailable={updateAvailable}
                 updateAvailable={updateAvailable}
@@ -180,7 +200,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
         <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
         <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
         <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
         <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
-        <UpdateSettingsModal onSubmit={handleUpdateSettingsSubmit} isOpen={updateSettingsDisclosure.isOpen} onClose={updateSettingsDisclosure.onClose} app={info} config={app?.config} />
+        <UpdateSettingsModal
+          onSubmit={handleUpdateSettingsSubmit}
+          isOpen={updateSettingsDisclosure.isOpen}
+          onClose={updateSettingsDisclosure.onClose}
+          app={info}
+          config={app?.config}
+          exposed={app?.exposed}
+          domain={app?.domain}
+        />
         <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
         <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
       </div>
       </div>
     </SlideFade>
     </SlideFade>

+ 2 - 1
packages/dashboard/src/modules/Auth/components/AuthFormLayout.tsx

@@ -1,5 +1,6 @@
 import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
 import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
+import { getUrl } from '../../../core/helpers/url-helpers';
 
 
 interface IProps {
 interface IProps {
   title: string;
   title: string;
@@ -12,7 +13,7 @@ const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
     <Container maxW="1250px">
     <Container maxW="1250px">
       <Flex flex={1} height="100vh" overflowY="hidden">
       <Flex flex={1} height="100vh" overflowY="hidden">
         <SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
         <SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
-          <img className="self-center mb-5 logo" src="/tipi.png" width={512} height={512} />
+          <img className="self-center mb-5 logo" src={getUrl('tipi.png')} width={512} height={512} />
           <Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
           <Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
             {title}
             {title}
           </Text>
           </Text>

+ 4 - 0
packages/dashboard/src/pages/_app.tsx

@@ -7,6 +7,7 @@ import { theme } from '../styles/theme';
 import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
 import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
 import { ApolloProvider } from '@apollo/client';
 import { ApolloProvider } from '@apollo/client';
 import useCachedResources from '../hooks/useCachedRessources';
 import useCachedResources from '../hooks/useCachedRessources';
+import Head from 'next/head';
 
 
 function MyApp({ Component, pageProps }: AppProps) {
 function MyApp({ Component, pageProps }: AppProps) {
   const { client } = useCachedResources();
   const { client } = useCachedResources();
@@ -18,6 +19,9 @@ function MyApp({ Component, pageProps }: AppProps) {
   return (
   return (
     <ApolloProvider client={client}>
     <ApolloProvider client={client}>
       <ChakraProvider theme={theme}>
       <ChakraProvider theme={theme}>
+        <Head>
+          <title>Tipi</title>
+        </Head>
         <AuthWrapper>
         <AuthWrapper>
           <Component {...pageProps} />
           <Component {...pageProps} />
         </AuthWrapper>
         </AuthWrapper>

+ 6 - 5
packages/dashboard/src/pages/_document.tsx

@@ -2,16 +2,17 @@ import React from 'react';
 import { Html, Head, Main, NextScript } from 'next/document';
 import { Html, Head, Main, NextScript } from 'next/document';
 import { ColorModeScript } from '@chakra-ui/react';
 import { ColorModeScript } from '@chakra-ui/react';
 import { theme } from '../styles/theme';
 import { theme } from '../styles/theme';
+import { getUrl } from '../core/helpers/url-helpers';
 
 
 export default function MyDocument() {
 export default function MyDocument() {
   return (
   return (
     <Html lang="en">
     <Html lang="en">
       <Head>
       <Head>
-        <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
-        <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
-        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
-        <link rel="manifest" href="/site.webmanifest" />
-        <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
+        <link rel="apple-touch-icon" sizes="180x180" href={getUrl('apple-touch-icon.png')} />
+        <link rel="icon" type="image/png" sizes="32x32" href={getUrl('favicon-32x32.png')} />
+        <link rel="icon" type="image/png" sizes="16x16" href={getUrl('favicon-16x16.png')} />
+        <link rel="manifest" href={getUrl('site.webmanifest')} />
+        <link rel="mask-icon" href={getUrl('safari-pinned-tab.svg')} color="#5bbad5" />
         <meta name="msapplication-TileColor" content="#da532c" />
         <meta name="msapplication-TileColor" content="#da532c" />
         <meta name="theme-color" content="#ffffff" />
         <meta name="theme-color" content="#ffffff" />
       </Head>
       </Head>

+ 2 - 1
packages/dashboard/src/pages/api/ip.tsx

@@ -1,5 +1,6 @@
 export default function ip(_: any, res: any) {
 export default function ip(_: any, res: any) {
   const { INTERNAL_IP } = process.env;
   const { INTERNAL_IP } = process.env;
+  const { DOMAIN } = process.env;
 
 
-  res.status(200).json({ ip: INTERNAL_IP });
+  res.status(200).json({ ip: INTERNAL_IP, domain: DOMAIN });
 }
 }

+ 0 - 19
packages/dashboard/src/state/networkStore.ts

@@ -1,19 +0,0 @@
-import create from 'zustand';
-import api from '../core/api';
-
-type AppsStore = {
-  internalIp: string;
-  fetchInternalIp: () => void;
-};
-
-export const useNetworkStore = create<AppsStore>((set) => ({
-  internalIp: '',
-  fetchInternalIp: async () => {
-    const response = await api.fetch<string>({
-      endpoint: '/network/internal-ip',
-      method: 'get',
-    });
-
-    set({ internalIp: response });
-  },
-}));

+ 10 - 2
packages/dashboard/src/state/systemStore.ts

@@ -1,11 +1,19 @@
 import create from 'zustand';
 import create from 'zustand';
 
 
 type Store = {
 type Store = {
+  baseUrl: string;
   internalIp: string;
   internalIp: string;
-  setInternalIp: (internalIp: string) => void;
+  domain: string;
+  setDomain: (domain?: string) => void;
+  setBaseUrl: (url: string) => void;
+  setInternalIp: (ip: string) => void;
 };
 };
 
 
 export const useSytemStore = create<Store>((set) => ({
 export const useSytemStore = create<Store>((set) => ({
+  baseUrl: '',
   internalIp: '',
   internalIp: '',
-  setInternalIp: (internalIp: string) => set((state) => ({ ...state, internalIp })),
+  domain: '',
+  setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
+  setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
+  setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
 }));
 }));

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

@@ -1,4 +1,5 @@
 {
 {
+  "$schema": "https://json.schemastore.org/swcrc",
   "jsc": {
   "jsc": {
     "parser": {
     "parser": {
       "syntax": "typescript",
       "syntax": "typescript",

+ 19 - 5
packages/system-api/__mocks__/fs-extra.ts

@@ -1,6 +1,7 @@
 import path from 'path';
 import path from 'path';
 const fs: {
 const fs: {
   __createMockFiles: typeof createMockFiles;
   __createMockFiles: typeof createMockFiles;
+  __resetAllMocks: typeof resetAllMocks;
   readFileSync: typeof readFileSync;
   readFileSync: typeof readFileSync;
   existsSync: typeof existsSync;
   existsSync: typeof existsSync;
   writeFileSync: typeof writeFileSync;
   writeFileSync: typeof writeFileSync;
@@ -9,6 +10,7 @@ const fs: {
   readdirSync: typeof readdirSync;
   readdirSync: typeof readdirSync;
   copyFileSync: typeof copyFileSync;
   copyFileSync: typeof copyFileSync;
   copySync: typeof copyFileSync;
   copySync: typeof copyFileSync;
+  createFileSync: typeof createFileSync;
 } = jest.genMockFromModule('fs-extra');
 } = jest.genMockFromModule('fs-extra');
 
 
 let mockFiles = Object.create(null);
 let mockFiles = Object.create(null);
@@ -45,12 +47,14 @@ const mkdirSync = (p: string) => {
   mockFiles[p] = Object.create(null);
   mockFiles[p] = Object.create(null);
 };
 };
 
 
-const rmSync = (p: string, options: { recursive: boolean }) => {
-  if (options.recursive) {
-    delete mockFiles[p];
-  } else {
-    delete mockFiles[p][Object.keys(mockFiles[p])[0]];
+const rmSync = (p: string) => {
+  if (mockFiles[p] instanceof Array) {
+    mockFiles[p].forEach((file: string) => {
+      delete mockFiles[path.join(p, file)];
+    });
   }
   }
+
+  delete mockFiles[p];
 };
 };
 
 
 const readdirSync = (p: string) => {
 const readdirSync = (p: string) => {
@@ -85,6 +89,14 @@ const copySync = (source: string, destination: string) => {
   }
   }
 };
 };
 
 
+const createFileSync = (p: string) => {
+  mockFiles[p] = '';
+};
+
+const resetAllMocks = () => {
+  mockFiles = Object.create(null);
+};
+
 fs.readdirSync = readdirSync;
 fs.readdirSync = readdirSync;
 fs.existsSync = existsSync;
 fs.existsSync = existsSync;
 fs.readFileSync = readFileSync;
 fs.readFileSync = readFileSync;
@@ -93,6 +105,8 @@ fs.mkdirSync = mkdirSync;
 fs.rmSync = rmSync;
 fs.rmSync = rmSync;
 fs.copyFileSync = copyFileSync;
 fs.copyFileSync = copyFileSync;
 fs.copySync = copySync;
 fs.copySync = copySync;
+fs.createFileSync = createFileSync;
 fs.__createMockFiles = createMockFiles;
 fs.__createMockFiles = createMockFiles;
+fs.__resetAllMocks = resetAllMocks;
 
 
 module.exports = fs;
 module.exports = fs;

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "system-api",
   "name": "system-api",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "",
   "description": "",
   "exports": "./dist/server.js",
   "exports": "./dist/server.js",
   "type": "module",
   "type": "module",
@@ -13,7 +13,7 @@
     "lint:fix": "eslint . --ext .ts --fix",
     "lint:fix": "eslint . --ext .ts --fix",
     "test": "jest --colors",
     "test": "jest --colors",
     "test:watch": "jest --watch",
     "test:watch": "jest --watch",
-    "build": "rm -rf dist && swc ./src --ignore **/*.test.* -d dist",
+    "build": "rm -rf dist && swc ./src -d dist",
     "build:watch": "swc ./src -d dist --watch",
     "build:watch": "swc ./src -d dist --watch",
     "start:dev": "NODE_ENV=development && nodemon --experimental-specifier-resolution=node --trace-deprecation --trace-warnings --watch dist dist/server.js",
     "start:dev": "NODE_ENV=development && nodemon --experimental-specifier-resolution=node --trace-deprecation --trace-warnings --watch dist dist/server.js",
     "dev": "concurrently \"npm run build:watch\" \"npm run start:dev\"",
     "dev": "concurrently \"npm run build:watch\" \"npm run start:dev\"",
@@ -27,7 +27,7 @@
   "dependencies": {
   "dependencies": {
     "apollo-server-core": "^3.10.0",
     "apollo-server-core": "^3.10.0",
     "apollo-server-express": "^3.9.0",
     "apollo-server-express": "^3.9.0",
-    "argon2": "^0.28.5",
+    "argon2": "^0.29.1",
     "axios": "^0.26.1",
     "axios": "^0.26.1",
     "class-validator": "^0.13.2",
     "class-validator": "^0.13.2",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
@@ -55,6 +55,7 @@
     "tcp-port-used": "^1.0.2",
     "tcp-port-used": "^1.0.2",
     "type-graphql": "^1.1.1",
     "type-graphql": "^1.1.1",
     "typeorm": "^0.3.6",
     "typeorm": "^0.3.6",
+    "validator": "^13.7.0",
     "winston": "^3.7.2"
     "winston": "^3.7.2"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 2 - 1
packages/system-api/src/config/config.ts

@@ -34,6 +34,7 @@ const {
   NGINX_PORT = '80',
   NGINX_PORT = '80',
   APPS_REPO_ID = '',
   APPS_REPO_ID = '',
   APPS_REPO_URL = '',
   APPS_REPO_URL = '',
+  DOMAIN = '',
 } = process.env;
 } = process.env;
 
 
 const config: IConfig = {
 const config: IConfig = {
@@ -45,7 +46,7 @@ const config: IConfig = {
   NODE_ENV,
   NODE_ENV,
   ROOT_FOLDER: '/tipi',
   ROOT_FOLDER: '/tipi',
   JWT_SECRET,
   JWT_SECRET,
-  CLIENT_URLS: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`],
+  CLIENT_URLS: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`, `https://${DOMAIN}`],
   VERSION: TIPI_VERSION,
   VERSION: TIPI_VERSION,
   ROOT_FOLDER_HOST,
   ROOT_FOLDER_HOST,
   APPS_REPO_ID,
   APPS_REPO_ID,

+ 22 - 0
packages/system-api/src/config/migrations/1662036689477-AppExposedDomain.ts

@@ -0,0 +1,22 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AppExposedDomain1662036689477 implements MigrationInterface {
+  name = 'AppExposedDomain1662036689477';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE "app" ADD "exposed" boolean DEFAULT false');
+    // populate all apps with exposed to false
+    await queryRunner.query('UPDATE "app" SET "exposed" = false');
+    // add NOT NULL constraint
+    await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "exposed" SET NOT NULL');
+
+    await queryRunner.query('ALTER TABLE "app" ADD "domain" character varying');
+    await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "version" SET DEFAULT \'1\'');
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "version" SET DEFAULT \'0\'');
+    await queryRunner.query('ALTER TABLE "app" DROP COLUMN "domain"');
+    await queryRunner.query('ALTER TABLE "app" DROP COLUMN "exposed"');
+  }
+}

+ 4 - 2
packages/system-api/src/core/middlewares/sessionMiddleware.ts

@@ -1,15 +1,17 @@
 import session from 'express-session';
 import session from 'express-session';
 import config from '../../config';
 import config from '../../config';
 import SessionFileStore from 'session-file-store';
 import SessionFileStore from 'session-file-store';
-import { COOKIE_MAX_AGE } from '../../config/constants/constants';
+import { COOKIE_MAX_AGE, __prod__ } from '../../config/constants/constants';
 
 
 const getSessionMiddleware = () => {
 const getSessionMiddleware = () => {
   const FileStore = SessionFileStore(session);
   const FileStore = SessionFileStore(session);
 
 
+  const sameSite = __prod__ ? 'lax' : 'none';
+
   return session({
   return session({
     name: 'qid',
     name: 'qid',
     store: new FileStore(),
     store: new FileStore(),
-    cookie: { maxAge: COOKIE_MAX_AGE, secure: false, sameSite: 'lax', httpOnly: true },
+    cookie: { maxAge: COOKIE_MAX_AGE, secure: false, sameSite, httpOnly: true },
     secret: config.JWT_SECRET,
     secret: config.JWT_SECRET,
     resave: false,
     resave: false,
     saveUninitialized: false,
     saveUninitialized: false,

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

@@ -8,10 +8,13 @@ interface IProps {
   status?: AppStatusEnum;
   status?: AppStatusEnum;
   requiredPort?: number;
   requiredPort?: number;
   randomField?: boolean;
   randomField?: boolean;
+  exposed?: boolean;
+  domain?: string;
+  exposable?: boolean;
 }
 }
 
 
 const createApp = async (props: IProps) => {
 const createApp = async (props: IProps) => {
-  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false } = props;
+  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false } = props;
 
 
   const categories = Object.values(AppCategoriesEnum);
   const categories = Object.values(AppCategoriesEnum);
 
 
@@ -34,6 +37,7 @@ const createApp = async (props: IProps) => {
     author: faker.name.firstName(),
     author: faker.name.firstName(),
     source: faker.internet.url(),
     source: faker.internet.url(),
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
+    exposable,
   };
   };
 
 
   if (randomField) {
   if (randomField) {
@@ -54,13 +58,17 @@ const createApp = async (props: IProps) => {
   MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
   MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
+  MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
 
 
+  let appEntity = new App();
   if (installed) {
   if (installed) {
-    await App.create({
+    appEntity = await App.create({
       id: appInfo.id,
       id: appInfo.id,
       config: { TEST_FIELD: 'test' },
       config: { TEST_FIELD: 'test' },
       status,
       status,
+      exposed,
+      domain,
     }).save();
     }).save();
 
 
     MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
     MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
@@ -69,7 +77,7 @@ const createApp = async (props: IProps) => {
     MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
     MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
   }
   }
 
 
-  return { appInfo, MockFiles };
+  return { appInfo, MockFiles, appEntity };
 };
 };
 
 
 export { createApp };
 export { createApp };

+ 57 - 17
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -3,6 +3,7 @@ import fs from 'fs-extra';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
 import config from '../../../config';
 import config from '../../../config';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
+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';
 import { AppInfo } from '../apps.types';
 import { AppInfo } from '../apps.types';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
@@ -127,16 +128,19 @@ describe('runAppScript', () => {
 
 
 describe('generateEnvFile', () => {
 describe('generateEnvFile', () => {
   let app1: AppInfo;
   let app1: AppInfo;
+  let appEntity1: App;
   beforeEach(async () => {
   beforeEach(async () => {
     const app1create = await createApp({ installed: true });
     const app1create = await createApp({ installed: true });
     app1 = app1create.appInfo;
     app1 = app1create.appInfo;
+    appEntity1 = app1create.appEntity;
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(app1create.MockFiles);
     fs.__createMockFiles(app1create.MockFiles);
   });
   });
 
 
   it('Should generate an env file', async () => {
   it('Should generate an env file', async () => {
     const fakevalue = faker.random.alphaNumeric(10);
     const fakevalue = faker.random.alphaNumeric(10);
-    generateEnvFile(app1.id, { TEST_FIELD: fakevalue });
+
+    generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: fakevalue } }));
 
 
     const envmap = await getEnvMap(app1.id);
     const envmap = await getEnvMap(app1.id);
 
 
@@ -144,11 +148,11 @@ describe('generateEnvFile', () => {
   });
   });
 
 
   it('Should automatically generate value for random field', async () => {
   it('Should automatically generate value for random field', async () => {
-    const { appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    generateEnvFile(appInfo.id, { TEST_FIELD: 'test' });
+    generateEnvFile(appEntity);
 
 
     const envmap = await getEnvMap(appInfo.id);
     const envmap = await getEnvMap(appInfo.id);
 
 
@@ -157,7 +161,7 @@ describe('generateEnvFile', () => {
   });
   });
 
 
   it('Should not re-generate random field if it already exists', async () => {
   it('Should not re-generate random field if it already exists', async () => {
-    const { appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
@@ -165,7 +169,7 @@ describe('generateEnvFile', () => {
 
 
     fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
     fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
 
 
-    generateEnvFile(appInfo.id, { TEST_FIELD: 'test' });
+    generateEnvFile(appEntity);
 
 
     const envmap = await getEnvMap(appInfo.id);
     const envmap = await getEnvMap(appInfo.id);
 
 
@@ -174,7 +178,7 @@ describe('generateEnvFile', () => {
 
 
   it('Should throw an error if required field is not provided', async () => {
   it('Should throw an error if required field is not provided', async () => {
     try {
     try {
-      generateEnvFile(app1.id, {});
+      generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: undefined } }));
       expect(true).toBe(false);
       expect(true).toBe(false);
     } catch (e: any) {
     } catch (e: any) {
       expect(e).toBeDefined();
       expect(e).toBeDefined();
@@ -184,13 +188,53 @@ describe('generateEnvFile', () => {
 
 
   it('Should throw an error if app does not exist', async () => {
   it('Should throw an error if app does not exist', async () => {
     try {
     try {
-      generateEnvFile('not-existing-app', { TEST_FIELD: 'test' });
+      generateEnvFile(Object.assign(appEntity1, { id: 'not-existing-app' }));
       expect(true).toBe(false);
       expect(true).toBe(false);
     } catch (e: any) {
     } catch (e: any) {
       expect(e).toBeDefined();
       expect(e).toBeDefined();
       expect(e.message).toBe('App not-existing-app not found');
       expect(e.message).toBe('App not-existing-app not found');
     }
     }
   });
   });
+
+  it('Should add APP_EXPOSED to env file', async () => {
+    const domain = faker.internet.domainName();
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true, domain });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    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 () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    expect(envmap.get('APP_EXPOSED')).toBeUndefined();
+    expect(envmap.get('APP_DOMAIN')).toBeUndefined();
+  });
+
+  it('Should not add APP_EXPOSED if app is not exposed', async () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, domain: faker.internet.domainName() });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    expect(envmap.get('APP_EXPOSED')).toBeUndefined();
+    expect(envmap.get('APP_DOMAIN')).toBeUndefined();
+  });
 });
 });
 
 
 describe('getAvailableApps', () => {
 describe('getAvailableApps', () => {
@@ -220,7 +264,7 @@ describe('getAppInfo', () => {
   it('Should return app info', async () => {
   it('Should return app info', async () => {
     const appInfo = await getAppInfo(app1.id);
     const appInfo = await getAppInfo(app1.id);
 
 
-    expect(appInfo.id).toBe(app1.id);
+    expect(appInfo?.id).toBe(app1.id);
   });
   });
 
 
   it('Should take config.json locally if app is installed', async () => {
   it('Should take config.json locally if app is installed', async () => {
@@ -232,17 +276,13 @@ describe('getAppInfo', () => {
 
 
     const app = await getAppInfo(appInfo.id);
     const app = await getAppInfo(appInfo.id);
 
 
-    expect(app.id).toEqual(appInfo.id);
+    expect(app?.id).toEqual(appInfo.id);
   });
   });
 
 
-  it('Should throw an error if app does not exist', async () => {
-    try {
-      await getAppInfo('not-existing-app');
-      expect(true).toBe(false);
-    } catch (e: any) {
-      expect(e).toBeDefined();
-      expect(e.message).toBe('Error loading app not-existing-app');
-    }
+  it('Should return null if app does not exist', async () => {
+    const app = await getAppInfo(faker.random.word());
+
+    expect(app).toBeNull();
   });
   });
 });
 });
 
 

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

@@ -166,7 +166,7 @@ describe('InstallApp', () => {
     const { data } = await gcall<{ installApp: TApp }>({
     const { data } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(data?.installApp.info.id).toBe(app1.id);
     expect(data?.installApp.info.id).toBe(app1.id);
@@ -179,7 +179,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' } } },
+      variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('App not-existing not found');
     expect(errors?.[0].message).toBe('App not-existing not found');
@@ -189,7 +189,7 @@ describe('InstallApp', () => {
   it("Should throw an error if user doesn't exist", async () => {
   it("Should throw an error if user doesn't exist", async () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
@@ -199,7 +199,7 @@ describe('InstallApp', () => {
   it('Should throw an error if no userId is provided', async () => {
   it('Should throw an error if no userId is provided', async () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
@@ -212,7 +212,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: app1.id, form: {} } },
+      variableValues: { input: { id: app1.id, form: {}, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe(`Variable ${app1.form_fields?.[0].env_variable} is required`);
     expect(errors?.[0].message).toBe(`Variable ${app1.form_fields?.[0].env_variable} is required`);
@@ -229,7 +229,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       source: installAppMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: appInfo.id, form: { TEST_FIELD: 'hello' } } },
+      variableValues: { input: { id: appInfo.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe(`App ${appInfo.id} requirements not met`);
     expect(errors?.[0].message).toBe(`App ${appInfo.id} requirements not met`);
@@ -429,7 +429,7 @@ describe('UpdateAppConfig', () => {
     const { data } = await gcall<{ updateAppConfig: TApp }>({
     const { data } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       source: updateAppConfigMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: word } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: word }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(data?.updateAppConfig.info.id).toBe(app1.id);
     expect(data?.updateAppConfig.info.id).toBe(app1.id);
@@ -442,7 +442,7 @@ describe('UpdateAppConfig', () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       source: updateAppConfigMutation,
       userId: user.id,
       userId: user.id,
-      variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: faker.random.word() } } },
+      variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('App not-existing not found');
     expect(errors?.[0].message).toBe('App not-existing not found');
@@ -453,7 +453,7 @@ describe('UpdateAppConfig', () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       source: updateAppConfigMutation,
       userId: 0,
       userId: 0,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
@@ -463,7 +463,7 @@ describe('UpdateAppConfig', () => {
   it('Should throw an error if no userId is provided', async () => {
   it('Should throw an error if no userId is provided', async () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       source: updateAppConfigMutation,
-      variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() } } },
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
     });
     });
 
 
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');

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

@@ -109,6 +109,59 @@ describe('Install app', () => {
     expect(envMap.get('RANDOM_FIELD')).toBeDefined();
     expect(envMap.get('RANDOM_FIELD')).toBeDefined();
     expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
     expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
   });
   });
+
+  it('Should correctly copy app from repos to apps folder', async () => {
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
+    const appFolder = fs.readdirSync(`${config.ROOT_FOLDER}/apps/${app1.id}`);
+
+    expect(appFolder).toBeDefined();
+    expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
+  });
+
+  it('Should cleanup any app folder existing before install', async () => {
+    const { MockFiles, appInfo } = await createApp({});
+    app1 = appInfo;
+    MockFiles[`/tipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
+    MockFiles[`/tipi/apps/${appInfo.id}/test.yml`] = 'test';
+    MockFiles[`/tipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(true);
+
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
+
+    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(false);
+    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
+  });
+
+  it('Should throw if app is exposed and domain is not provided', async () => {
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required if app is exposed');
+  });
+
+  it('Should throw if app is exposed and config does not allow it', async () => {
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
+  });
+
+  it('Should throw if app is exposed and domain is not valid', async () => {
+    const { MockFiles, appInfo } = await createApp({ exposable: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
+  });
+
+  it('Should throw if app is exposed and domain is already used', async () => {
+    const app2 = await createApp({ exposable: true });
+    const app3 = await createApp({ exposable: true });
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign({}, app2.MockFiles, app3.MockFiles));
+
+    await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
+
+    await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
+  });
 });
 });
 
 
 describe('Uninstall app', () => {
 describe('Uninstall app', () => {
@@ -308,6 +361,37 @@ describe('Update app config', () => {
 
 
     expect(envMap.get('RANDOM_FIELD')).toBe('test');
     expect(envMap.get('RANDOM_FIELD')).toBe('test');
   });
   });
+
+  it('Should throw if app is exposed and domain is not provided', () => {
+    return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required');
+  });
+
+  it('Should throw if app is exposed and domain is not valid', () => {
+    return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
+  });
+
+  it('Should throw if app is exposed and config does not allow it', () => {
+    return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
+  });
+
+  it('Should throw if app is exposed and domain is already used', async () => {
+    const app2 = await createApp({ exposable: true, installed: true });
+    const app3 = await createApp({ exposable: true, installed: true });
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles));
+
+    await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
+    await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
+  });
+
+  it('Should not throw if updating with same domain', async () => {
+    const app2 = await createApp({ exposable: true, installed: true });
+    // @ts-ignore
+    fs.__createMockFiles(Object.assign(app2.MockFiles));
+
+    await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
+    await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
+  });
 });
 });
 
 
 describe('Get app config', () => {
 describe('Get app config', () => {

+ 9 - 1
packages/system-api/src/modules/apps/app.entity.ts

@@ -55,9 +55,17 @@ class App extends BaseEntity {
   @UpdateDateColumn()
   @UpdateDateColumn()
   updatedAt!: Date;
   updatedAt!: Date;
 
 
+  @Field(() => Boolean)
+  @Column({ type: 'boolean', default: false })
+  exposed!: boolean;
+
+  @Field(() => String, { nullable: true })
+  @Column({ type: 'varchar', nullable: true })
+  domain?: string;
+
   @Field(() => AppInfo, { nullable: true })
   @Field(() => AppInfo, { nullable: true })
   info(): AppInfo | null {
   info(): AppInfo | null {
-    return getAppInfo(this.id);
+    return getAppInfo(this.id, this.status);
   }
   }
 
 
   @Field(() => UpdateInfo, { nullable: true })
   @Field(() => UpdateInfo, { nullable: true })

+ 20 - 11
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -3,7 +3,7 @@ import { fileExists, getSeed, readdirSync, readFile, readJsonFile, runScript, wr
 import InternalIp from 'internal-ip';
 import InternalIp from 'internal-ip';
 import crypto from 'crypto';
 import crypto from 'crypto';
 import config from '../../config';
 import config from '../../config';
-import { AppInfo } from './apps.types';
+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';
 
 
@@ -74,19 +74,19 @@ const getEntropy = (name: string, length: number) => {
   return hash.digest('hex').substring(0, length);
   return hash.digest('hex').substring(0, length);
 };
 };
 
 
-export const generateEnvFile = (appName: string, form: Record<string, string>) => {
-  const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
+export const generateEnvFile = (app: App) => {
+  const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
 
 
   if (!configFile) {
   if (!configFile) {
-    throw new Error(`App ${appName} not found`);
+    throw new Error(`App ${app.id} not found`);
   }
   }
 
 
   const baseEnvFile = readFile('/.env').toString();
   const baseEnvFile = readFile('/.env').toString();
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
-  const envMap = getEnvMap(appName);
+  const envMap = getEnvMap(app.id);
 
 
   configFile.form_fields?.forEach((field) => {
   configFile.form_fields?.forEach((field) => {
-    const formValue = form[field.env_variable];
+    const formValue = app.config[field.env_variable];
     const envVar = field.env_variable;
     const envVar = field.env_variable;
 
 
     if (formValue) {
     if (formValue) {
@@ -105,7 +105,12 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
     }
     }
   });
   });
 
 
-  writeFile(`/app-data/${appName}/app.env`, envFile);
+  if (app.exposed && app.domain) {
+    envFile += 'APP_EXPOSED=true\n';
+    envFile += `APP_DOMAIN=${app.domain}\n`;
+  }
+
+  writeFile(`/app-data/${app.id}/app.env`, envFile);
 };
 };
 
 
 export const getAvailableApps = async (): Promise<string[]> => {
 export const getAvailableApps = async (): Promise<string[]> => {
@@ -126,15 +131,18 @@ export const getAvailableApps = async (): Promise<string[]> => {
   return apps;
   return apps;
 };
 };
 
 
-export const getAppInfo = (id: string): AppInfo => {
+export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
   try {
   try {
     const repoId = config.APPS_REPO_ID;
     const repoId = config.APPS_REPO_ID;
 
 
-    if (fileExists(`/apps/${id}/config.json`)) {
+    // Check if app is installed
+    const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
+
+    if (installed && fileExists(`/apps/${id}/config.json`)) {
       const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
       const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
       configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
       configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
       return configFile;
       return configFile;
-    } else if (fileExists(`/repos/${repoId}`)) {
+    } else if (fileExists(`/repos/${repoId}/apps/${id}/config.json`)) {
       const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
       const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
       configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
       configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
 
 
@@ -143,8 +151,9 @@ export const getAppInfo = (id: string): AppInfo => {
       }
       }
     }
     }
 
 
-    throw new Error('No repository found');
+    return null;
   } catch (e) {
   } catch (e) {
+    console.error(e);
     throw new Error(`Error loading app ${id}`);
     throw new Error(`Error loading app ${id}`);
   }
   }
 };
 };

+ 4 - 4
packages/system-api/src/modules/apps/apps.resolver.ts

@@ -24,9 +24,9 @@ export default class AppsResolver {
   @Authorized()
   @Authorized()
   @Mutation(() => App)
   @Mutation(() => App)
   async installApp(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
   async installApp(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
-    const { id, form } = input;
+    const { id, form, exposed, domain } = input;
 
 
-    return AppsService.installApp(id, form);
+    return AppsService.installApp(id, form, exposed, domain);
   }
   }
 
 
   @Authorized()
   @Authorized()
@@ -50,9 +50,9 @@ export default class AppsResolver {
   @Authorized()
   @Authorized()
   @Mutation(() => App)
   @Mutation(() => App)
   async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
   async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
-    const { id, form } = input;
+    const { id, form, exposed, domain } = input;
 
 
-    return AppsService.updateAppConfig(id, form);
+    return AppsService.updateAppConfig(id, form, exposed, domain);
   }
   }
 
 
   @Authorized()
   @Authorized()

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

@@ -1,9 +1,11 @@
+import validator from 'validator';
 import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
 import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import config from '../../config';
 import config from '../../config';
+import { Not } from 'typeorm';
 
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 
 
@@ -15,7 +17,7 @@ const startAllApps = async (): Promise<void> => {
       // Regenerate env file
       // Regenerate env file
       try {
       try {
         ensureAppFolder(app.id);
         ensureAppFolder(app.id);
-        generateEnvFile(app.id, app.config);
+        generateEnvFile(app);
         checkEnvFile(app.id);
         checkEnvFile(app.id);
 
 
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
@@ -40,7 +42,7 @@ const startApp = async (appName: string): Promise<App> => {
   ensureAppFolder(appName);
   ensureAppFolder(appName);
 
 
   // Regenerate env file
   // Regenerate env file
-  generateEnvFile(appName, app.config);
+  generateEnvFile(app);
 
 
   checkEnvFile(appName);
   checkEnvFile(appName);
 
 
@@ -59,13 +61,21 @@ const startApp = async (appName: string): Promise<App> => {
   return app;
   return app;
 };
 };
 
 
-const installApp = async (id: string, form: Record<string, string>): Promise<App> => {
+const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
   if (app) {
   if (app) {
     await startApp(id);
     await startApp(id);
   } else {
   } else {
-    ensureAppFolder(id);
+    if (exposed && !domain) {
+      throw new Error('Domain is required if app is exposed');
+    }
+
+    if (domain && !validator.isFQDN(domain)) {
+      throw new Error(`Domain ${domain} is not valid`);
+    }
+
+    ensureAppFolder(id, true);
     const appIsValid = await checkAppRequirements(id);
     const appIsValid = await checkAppRequirements(id);
 
 
     if (!appIsValid) {
     if (!appIsValid) {
@@ -75,11 +85,23 @@ const installApp = async (id: string, form: Record<string, string>): Promise<App
     // Create app folder
     // Create app folder
     createFolder(`/app-data/${id}`);
     createFolder(`/app-data/${id}`);
 
 
-    // Create env file
-    generateEnvFile(id, form);
-
     const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
     const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
-    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0) }).save();
+
+    if (!appInfo?.exposable && exposed) {
+      throw new Error(`App ${id} is not exposable`);
+    }
+
+    if (exposed) {
+      const appsWithSameDomain = await App.find({ where: { domain, exposed: true } });
+      if (appsWithSameDomain.length > 0) {
+        throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
+      }
+    }
+
+    app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0), exposed: exposed || false, domain }).save();
+
+    // Create env file
+    generateEnvFile(app);
 
 
     // Run script
     // Run script
     try {
     try {
@@ -116,15 +138,38 @@ const listApps = async (): Promise<ListAppsResonse> => {
   return { apps: apps.sort(sortApps), total: apps.length };
   return { apps: apps.sort(sortApps), total: apps.length };
 };
 };
 
 
-const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
+const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
+  if (exposed && !domain) {
+    throw new Error('Domain is required if app is exposed');
+  }
+
+  if (domain && !validator.isFQDN(domain)) {
+    throw new Error(`Domain ${domain} is not valid`);
+  }
+
+  const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+
+  if (!appInfo?.exposable && exposed) {
+    throw new Error(`App ${id} is not exposable`);
+  }
+
+  if (exposed) {
+    const appsWithSameDomain = await App.find({ where: { domain, exposed: true, id: Not(id) } });
+    if (appsWithSameDomain.length > 0) {
+      throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
+    }
+  }
+
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
   if (!app) {
   if (!app) {
     throw new Error(`App ${id} not found`);
     throw new Error(`App ${id} not found`);
   }
   }
 
 
-  generateEnvFile(id, form);
-  await App.update({ id }, { config: form });
+  await App.update({ id }, { config: form, exposed: exposed || false, domain });
+  app = (await App.findOne({ where: { id } })) as App;
+
+  generateEnvFile(app);
   app = (await App.findOne({ where: { id } })) as App;
   app = (await App.findOne({ where: { id } })) as App;
 
 
   return app;
   return app;
@@ -185,7 +230,7 @@ const getApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
   if (!app) {
   if (!app) {
-    app = { id, status: AppStatusEnum.MISSING, config: {} } as App;
+    app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as App;
   }
   }
 
 
   return app;
   return app;

+ 13 - 0
packages/system-api/src/modules/apps/apps.types.ts

@@ -15,6 +15,7 @@ export enum AppCategoriesEnum {
   DATA = 'data',
   DATA = 'data',
   MUSIC = 'music',
   MUSIC = 'music',
   FINANCE = 'finance',
   FINANCE = 'finance',
+  GAMING = 'gaming',
 }
 }
 
 
 export enum FieldTypes {
 export enum FieldTypes {
@@ -121,6 +122,12 @@ class AppInfo {
 
 
   @Field(() => GraphQLJSONObject, { nullable: true })
   @Field(() => GraphQLJSONObject, { nullable: true })
   requirements?: Requirements;
   requirements?: Requirements;
+
+  @Field(() => Boolean, { nullable: true })
+  https?: boolean;
+
+  @Field(() => Boolean, { nullable: true })
+  exposable?: boolean;
 }
 }
 
 
 @ObjectType()
 @ObjectType()
@@ -139,6 +146,12 @@ class AppInputType {
 
 
   @Field(() => GraphQLJSONObject)
   @Field(() => GraphQLJSONObject)
   form!: Record<string, string>;
   form!: Record<string, string>;
+
+  @Field(() => Boolean)
+  exposed!: boolean;
+
+  @Field(() => String)
+  domain!: string;
 }
 }
 
 
 export { ListAppsResonse, AppInfo, AppInputType };
 export { ListAppsResonse, AppInfo, AppInputType };

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

@@ -0,0 +1,173 @@
+import { faker } from '@faker-js/faker';
+import { DataSource } from 'typeorm';
+import { setupConnection, teardownConnection } from '../../../test/connection';
+import { gcall } from '../../../test/gcall';
+import { loginMutation, registerMutation } from '../../../test/mutations';
+import { isConfiguredQuery, MeQuery } from '../../../test/queries';
+import User from '../../auth/user.entity';
+import { UserResponse } from '../auth.types';
+import { createUser } from './user.factory';
+
+let db: DataSource | null = null;
+const TEST_SUITE = 'authresolver';
+
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
+});
+
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
+});
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+  await User.clear();
+});
+
+describe('Test: me', () => {
+  const email = faker.internet.email();
+  let user1: User;
+
+  beforeEach(async () => {
+    user1 = await createUser(email);
+  });
+
+  it('should return null if no user is logged in', async () => {
+    const { data } = await gcall<{ me: User }>({
+      source: MeQuery,
+    });
+
+    expect(data?.me).toBeNull();
+  });
+
+  it('should return the user if a user is logged in', async () => {
+    const { data } = await gcall<{ me: User | null }>({
+      source: MeQuery,
+      userId: user1.id,
+    });
+
+    expect(data?.me?.username).toEqual(user1.username);
+  });
+});
+
+describe('Test: register', () => {
+  const email = faker.internet.email();
+  const password = faker.internet.password();
+
+  it('should register a user', async () => {
+    const { data } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: email, password },
+      },
+    });
+
+    expect(data?.register.user?.username).toEqual(email.toLowerCase());
+  });
+
+  it('should not register a user with an existing username', async () => {
+    await createUser(email);
+
+    const { errors } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: email, password },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('User already exists');
+  });
+
+  it('should not register a user with a malformed email', async () => {
+    const { errors } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: 'not an email', password },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('Invalid username');
+  });
+});
+
+describe('Test: login', () => {
+  const email = faker.internet.email();
+
+  beforeEach(async () => {
+    await createUser(email);
+  });
+
+  it('should login a user', async () => {
+    const { data } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: email, password: 'password' },
+      },
+    });
+
+    expect(data?.login.user?.username).toEqual(email.toLowerCase());
+  });
+
+  it('should not login a user with an incorrect password', async () => {
+    const { errors } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: email, password: 'wrong password' },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('Wrong password');
+  });
+
+  it('should not login a user with a malformed email', async () => {
+    const { errors } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: 'not an email', password: 'password' },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('User not found');
+  });
+});
+
+describe('Test: logout', () => {
+  const email = faker.internet.email();
+  let user1: User;
+
+  beforeEach(async () => {
+    user1 = await createUser(email);
+  });
+
+  it('should logout a user', async () => {
+    const { data } = await gcall<{ logout: boolean }>({
+      source: 'mutation { logout }',
+      userId: user1.id,
+    });
+
+    expect(data?.logout).toBeTruthy();
+  });
+});
+
+describe('Test: isConfigured', () => {
+  it('should return false if no users exist', async () => {
+    const { data } = await gcall<{ isConfigured: boolean }>({
+      source: isConfiguredQuery,
+    });
+
+    expect(data?.isConfigured).toBeFalsy();
+  });
+
+  it('should return true if a user exists', async () => {
+    await createUser(faker.internet.email());
+
+    const { data } = await gcall<{ isConfigured: boolean }>({
+      source: isConfiguredQuery,
+    });
+
+    expect(data?.isConfigured).toBeTruthy();
+  });
+});

+ 8 - 2
packages/system-api/src/modules/auth/auth.service.ts

@@ -1,4 +1,5 @@
 import * as argon2 from 'argon2';
 import * as argon2 from 'argon2';
+import validator from 'validator';
 import { UsernamePasswordInput, UserResponse } from './auth.types';
 import { UsernamePasswordInput, UserResponse } from './auth.types';
 import User from './user.entity';
 import User from './user.entity';
 
 
@@ -22,19 +23,24 @@ const login = async (input: UsernamePasswordInput): Promise<UserResponse> => {
 
 
 const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
 const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
   const { password, username } = input;
   const { password, username } = input;
+  const email = username.trim().toLowerCase();
 
 
   if (!username || !password) {
   if (!username || !password) {
     throw new Error('Missing email or password');
     throw new Error('Missing email or password');
   }
   }
 
 
-  const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
+  if (username.length < 3 || !validator.isEmail(email)) {
+    throw new Error('Invalid username');
+  }
+
+  const user = await User.findOne({ where: { username: email } });
 
 
   if (user) {
   if (user) {
     throw new Error('User already exists');
     throw new Error('User already exists');
   }
   }
 
 
   const hash = await argon2.hash(password);
   const hash = await argon2.hash(password);
-  const newUser = await User.create({ username: username.trim().toLowerCase(), password: hash }).save();
+  const newUser = await User.create({ username: email, password: hash }).save();
 
 
   return { user: newUser };
   return { user: newUser };
 };
 };

+ 200 - 0
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts

@@ -0,0 +1,200 @@
+import childProcess from 'child_process';
+import config from '../../../config';
+import { getAbsolutePath, readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
+import fs from 'fs-extra';
+
+jest.mock('fs-extra');
+
+beforeEach(() => {
+  // @ts-ignore
+  fs.__resetAllMocks();
+});
+
+describe('Test: getAbsolutePath', () => {
+  it('should return the absolute path', () => {
+    expect(getAbsolutePath('/test')).toBe(`${config.ROOT_FOLDER}/test`);
+  });
+});
+
+describe('Test: readJsonFile', () => {
+  it('should return the json file', () => {
+    // Arrange
+    const rawFile = '{"test": "test"}';
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/test-file.json`]: rawFile,
+    };
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    const file = readJsonFile('/test-file.json');
+
+    // Assert
+    expect(file).toEqual({ test: 'test' });
+  });
+
+  it('should return null if the file does not exist', () => {
+    expect(readJsonFile('/test')).toBeNull();
+  });
+});
+
+describe('Test: readFile', () => {
+  it('should return the file', () => {
+    const rawFile = 'test';
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/test-file.txt`]: rawFile,
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    expect(readFile('/test-file.txt')).toEqual('test');
+  });
+
+  it('should return empty string if the file does not exist', () => {
+    expect(readFile('/test')).toEqual('');
+  });
+});
+
+describe('Test: readdirSync', () => {
+  it('should return the files', () => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/test/test-file.txt`]: 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    expect(readdirSync('/test')).toEqual(['test-file.txt']);
+  });
+
+  it('should return empty array if the directory does not exist', () => {
+    expect(readdirSync('/test')).toEqual([]);
+  });
+});
+
+describe('Test: fileExists', () => {
+  it('should return true if the file exists', () => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/test-file.txt`]: 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    expect(fileExists('/test-file.txt')).toBeTruthy();
+  });
+
+  it('should return false if the file does not exist', () => {
+    expect(fileExists('/test-file.txt')).toBeFalsy();
+  });
+});
+
+describe('Test: writeFile', () => {
+  it('should write the file', () => {
+    const spy = jest.spyOn(fs, 'writeFileSync');
+
+    writeFile('/test-file.txt', 'test');
+
+    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test-file.txt`, 'test');
+  });
+});
+
+describe('Test: createFolder', () => {
+  it('should create the folder', () => {
+    const spy = jest.spyOn(fs, 'mkdirSync');
+
+    createFolder('/test');
+
+    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`);
+  });
+});
+
+describe('Test: deleteFolder', () => {
+  it('should delete the folder', () => {
+    const spy = jest.spyOn(fs, 'rmSync');
+
+    deleteFolder('/test');
+
+    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, { recursive: true });
+  });
+});
+
+describe('Test: runScript', () => {
+  it('should run the script', () => {
+    const spy = jest.spyOn(childProcess, 'execFile');
+    const callback = jest.fn();
+
+    runScript('/test', [], callback);
+
+    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, [], {}, callback);
+  });
+});
+
+describe('Test: getSeed', () => {
+  it('should return the seed', () => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/state/seed`]: 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    expect(getSeed()).toEqual('test');
+  });
+});
+
+describe('Test: ensureAppFolder', () => {
+  beforeEach(() => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
+    };
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+  });
+
+  it('should copy the folder from repo', () => {
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    expect(files).toEqual(['test.yml']);
+  });
+
+  it('should not copy the folder if it already exists', () => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
+      [`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
+      [`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    expect(files).toEqual(['docker-compose.yml']);
+  });
+
+  it('Should overwrite the folder if clean up is true', () => {
+    const mockFiles = {
+      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
+      [`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
+      [`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test', true);
+
+    // Assert
+    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    expect(files).toEqual(['test.yml']);
+  });
+});

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

@@ -42,9 +42,13 @@ export const getSeed = () => {
   return seed.toString();
   return seed.toString();
 };
 };
 
 
-export const ensureAppFolder = (appName: string) => {
+export const ensureAppFolder = (appName: string, cleanup = false) => {
+  if (cleanup && fileExists(`/apps/${appName}`)) {
+    deleteFolder(`/apps/${appName}`);
+  }
+
   if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
   if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
-    fs.removeSync(getAbsolutePath(`/apps/${appName}`));
+    if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
     // Copy from apps repo
     // Copy from apps repo
     fs.copySync(getAbsolutePath(`/repos/${config.APPS_REPO_ID}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
     fs.copySync(getAbsolutePath(`/repos/${config.APPS_REPO_ID}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
   }
   }

+ 15 - 14
packages/system-api/src/server.ts

@@ -18,22 +18,23 @@ import recover from './core/updates/recover-migrations';
 import { cloneRepo, updateRepo } from './helpers/repo-helpers';
 import { cloneRepo, updateRepo } from './helpers/repo-helpers';
 import startJobs from './core/jobs/jobs';
 import startJobs from './core/jobs/jobs';
 
 
-let corsOptions = __prod__
-  ? {
-      credentials: true,
-      origin: function (origin: any, callback: any) {
-        // disallow requests with no origin
-        if (!origin) return callback(new Error('Not allowed by CORS'), false);
-
-        if (config.CLIENT_URLS.includes(origin)) {
-          return callback(null, true);
-        }
+let corsOptions = {
+  credentials: true,
+  origin: function (origin: any, callback: any) {
+    if (!__prod__) {
+      return callback(null, true);
+    }
+    // disallow requests with no origin
+    if (!origin) return callback(new Error('Not allowed by CORS'), false);
 
 
-        const message = "The CORS policy for this origin doesn't allow access from the particular origin.";
-        return callback(new Error(message), false);
-      },
+    if (config.CLIENT_URLS.includes(origin)) {
+      return callback(null, true);
     }
     }
-  : {};
+
+    const message = "The CORS policy for this origin doesn't allow access from the particular origin.";
+    return callback(new Error(message), false);
+  },
+};
 
 
 const main = async () => {
 const main = async () => {
   try {
   try {

+ 4 - 0
packages/system-api/src/test/mutations/index.ts

@@ -8,6 +8,8 @@ import * as stopApp from './stopApp.graphql';
 import * as uninstallApp from './uninstallApp.graphql';
 import * as uninstallApp from './uninstallApp.graphql';
 import * as updateAppConfig from './updateAppConfig.graphql';
 import * as updateAppConfig from './updateAppConfig.graphql';
 import * as updateApp from './updateApp.graphql';
 import * as updateApp from './updateApp.graphql';
+import * as register from './register.graphql';
+import * as login from './login.graphql';
 
 
 export const installAppMutation = print(installApp);
 export const installAppMutation = print(installApp);
 export const startAppMutation = print(startApp);
 export const startAppMutation = print(startApp);
@@ -15,3 +17,5 @@ export const stopAppMutation = print(stopApp);
 export const uninstallAppMutation = print(uninstallApp);
 export const uninstallAppMutation = print(uninstallApp);
 export const updateAppConfigMutation = print(updateAppConfig);
 export const updateAppConfigMutation = print(updateAppConfig);
 export const updateAppMutation = print(updateApp);
 export const updateAppMutation = print(updateApp);
+export const registerMutation = print(register);
+export const loginMutation = print(login);

+ 9 - 0
packages/system-api/src/test/mutations/login.graphql

@@ -0,0 +1,9 @@
+# Write your query or mutation here
+mutation Login($input: UsernamePasswordInput!) {
+  login(input: $input) {
+    user {
+      id
+      username
+    }
+  }
+}

+ 9 - 0
packages/system-api/src/test/mutations/register.graphql

@@ -0,0 +1,9 @@
+# Write your query or mutation here
+mutation Register($input: UsernamePasswordInput!) {
+  register(input: $input) {
+    user {
+      id
+      username
+    }
+  }
+}

+ 4 - 0
packages/system-api/src/test/queries/index.ts

@@ -5,7 +5,11 @@ import { print } from 'graphql/language/printer';
 import * as listAppInfos from './listAppInfos.graphql';
 import * as listAppInfos from './listAppInfos.graphql';
 import * as getApp from './getApp.graphql';
 import * as getApp from './getApp.graphql';
 import * as InstalledApps from './installedApps.graphql';
 import * as InstalledApps from './installedApps.graphql';
+import * as Me from './me.graphql';
+import * as isConfigured from './isConfigured.graphql';
 
 
 export const listAppInfosQuery = print(listAppInfos);
 export const listAppInfosQuery = print(listAppInfos);
 export const getAppQuery = print(getApp);
 export const getAppQuery = print(getApp);
 export const InstalledAppsQuery = print(InstalledApps);
 export const InstalledAppsQuery = print(InstalledApps);
+export const MeQuery = print(Me);
+export const isConfiguredQuery = print(isConfigured);

+ 3 - 0
packages/system-api/src/test/queries/isConfigured.graphql

@@ -0,0 +1,3 @@
+query IsConfigured {
+  isConfigured
+}

+ 88 - 60
pnpm-lock.yaml

@@ -7,28 +7,16 @@ importers:
       '@commitlint/cli': ^17.0.3
       '@commitlint/cli': ^17.0.3
       '@commitlint/config-conventional': ^17.0.3
       '@commitlint/config-conventional': ^17.0.3
       '@commitlint/cz-commitlint': ^17.0.3
       '@commitlint/cz-commitlint': ^17.0.3
-      '@types/jest': ^27.5.0
-      '@types/js-yaml': ^4.0.5
-      '@types/node': 17.0.31
       commitizen: ^4.2.4
       commitizen: ^4.2.4
       husky: ^8.0.1
       husky: ^8.0.1
-      jest: ^28.1.0
-      js-yaml: ^4.1.0
-      ts-jest: ^28.0.2
-      typescript: 4.6.4
+      inquirer: 8.2.4
     devDependencies:
     devDependencies:
       '@commitlint/cli': 17.0.3
       '@commitlint/cli': 17.0.3
       '@commitlint/config-conventional': 17.0.3
       '@commitlint/config-conventional': 17.0.3
-      '@commitlint/cz-commitlint': 17.0.3_commitizen@4.2.5
-      '@types/jest': 27.5.0
-      '@types/js-yaml': 4.0.5
-      '@types/node': 17.0.31
+      '@commitlint/cz-commitlint': 17.0.3_yes7iyjckc3rubj3ixzwc3ince
       commitizen: 4.2.5
       commitizen: 4.2.5
       husky: 8.0.1
       husky: 8.0.1
-      jest: 28.1.0_@types+node@17.0.31
-      js-yaml: 4.1.0
-      ts-jest: 28.0.2_z3fx76c5ksuwr36so7o5uc2kcy
-      typescript: 4.6.4
+      inquirer: 8.2.4
 
 
   packages/dashboard:
   packages/dashboard:
     specifiers:
     specifiers:
@@ -62,6 +50,7 @@ importers:
       graphql: ^15.8.0
       graphql: ^15.8.0
       graphql-tag: ^2.12.6
       graphql-tag: ^2.12.6
       immer: ^9.0.12
       immer: ^9.0.12
+      jest: ^28.1.0
       js-cookie: ^3.0.1
       js-cookie: ^3.0.1
       next: 12.1.6
       next: 12.1.6
       postcss: ^8.4.12
       postcss: ^8.4.12
@@ -77,6 +66,7 @@ importers:
       swr: ^1.3.0
       swr: ^1.3.0
       systeminformation: ^5.11.9
       systeminformation: ^5.11.9
       tailwindcss: ^3.0.23
       tailwindcss: ^3.0.23
+      ts-jest: ^28.0.2
       tslib: ^2.4.0
       tslib: ^2.4.0
       typescript: 4.6.4
       typescript: 4.6.4
       validator: ^13.7.0
       validator: ^13.7.0
@@ -129,8 +119,10 @@ importers:
       eslint-config-airbnb-typescript: 17.0.0_r46exuh3jlhq2wmrnqx2ufqspa
       eslint-config-airbnb-typescript: 17.0.0_r46exuh3jlhq2wmrnqx2ufqspa
       eslint-config-next: 12.1.4_e6a2zi6fqdwfehht5cxvkmo3zu
       eslint-config-next: 12.1.4_e6a2zi6fqdwfehht5cxvkmo3zu
       eslint-plugin-import: 2.26.0_hhyjdrupy4c2vgtpytri6cjwoy
       eslint-plugin-import: 2.26.0_hhyjdrupy4c2vgtpytri6cjwoy
+      jest: 28.1.0_@types+node@17.0.31
       postcss: 8.4.13
       postcss: 8.4.13
       tailwindcss: 3.0.24
       tailwindcss: 3.0.24
+      ts-jest: 28.0.2_ps5qfvt5fosg52obpfzuxthwve
       typescript: 4.6.4
       typescript: 4.6.4
 
 
   packages/system-api:
   packages/system-api:
@@ -157,7 +149,7 @@ importers:
       '@typescript-eslint/parser': ^5.22.0
       '@typescript-eslint/parser': ^5.22.0
       apollo-server-core: ^3.10.0
       apollo-server-core: ^3.10.0
       apollo-server-express: ^3.9.0
       apollo-server-express: ^3.9.0
-      argon2: ^0.28.5
+      argon2: ^0.29.1
       axios: ^0.26.1
       axios: ^0.26.1
       class-validator: ^0.13.2
       class-validator: ^0.13.2
       compression: ^1.7.4
       compression: ^1.7.4
@@ -199,11 +191,12 @@ importers:
       type-graphql: ^1.1.1
       type-graphql: ^1.1.1
       typeorm: ^0.3.6
       typeorm: ^0.3.6
       typescript: 4.6.4
       typescript: 4.6.4
+      validator: ^13.7.0
       winston: ^3.7.2
       winston: ^3.7.2
     dependencies:
     dependencies:
       apollo-server-core: 3.10.0_graphql@15.8.0
       apollo-server-core: 3.10.0_graphql@15.8.0
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
-      argon2: 0.28.5
+      argon2: 0.29.1
       axios: 0.26.1
       axios: 0.26.1
       class-validator: 0.13.2
       class-validator: 0.13.2
       compression: 1.7.4
       compression: 1.7.4
@@ -231,6 +224,7 @@ importers:
       tcp-port-used: 1.0.2
       tcp-port-used: 1.0.2
       type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
       type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
       typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
       typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
+      validator: 13.7.0
       winston: 3.7.2
       winston: 3.7.2
     devDependencies:
     devDependencies:
       '@faker-js/faker': 7.3.0
       '@faker-js/faker': 7.3.0
@@ -761,7 +755,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.17.10:
   /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.17.10:
@@ -770,7 +764,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.17.10:
   /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.17.10:
@@ -798,7 +792,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.17.10:
   /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.17.10:
@@ -807,7 +801,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-jsx/7.16.7_@babel+core@7.17.10:
   /@babel/plugin-syntax-jsx/7.16.7_@babel+core@7.17.10:
@@ -835,7 +829,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.17.10:
   /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.17.10:
@@ -844,7 +838,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.17.10:
   /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.17.10:
@@ -853,7 +847,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.17.10:
   /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.17.10:
@@ -871,7 +865,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.17.10:
   /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.17.10:
@@ -880,7 +874,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.17.10:
   /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.17.10:
@@ -890,7 +884,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-syntax-typescript/7.17.10_@babel+core@7.17.10:
   /@babel/plugin-syntax-typescript/7.17.10_@babel+core@7.17.10:
@@ -900,7 +894,7 @@ packages:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
     dev: true
 
 
   /@babel/plugin-transform-arrow-functions/7.17.12_@babel+core@7.17.10:
   /@babel/plugin-transform-arrow-functions/7.17.12_@babel+core@7.17.10:
@@ -2033,7 +2027,7 @@ packages:
       ajv: 8.11.0
       ajv: 8.11.0
     dev: true
     dev: true
 
 
-  /@commitlint/cz-commitlint/17.0.3_commitizen@4.2.5:
+  /@commitlint/cz-commitlint/17.0.3_yes7iyjckc3rubj3ixzwc3ince:
     resolution: {integrity: sha512-360I6wnaUWzc23D8Xn4B/cu8thy8GDJPZ4QsYk4xjVzDDyXZ6oXJB0+OlwkpWpSvjuLYAmEKiImvo0yLTASmlg==}
     resolution: {integrity: sha512-360I6wnaUWzc23D8Xn4B/cu8thy8GDJPZ4QsYk4xjVzDDyXZ6oXJB0+OlwkpWpSvjuLYAmEKiImvo0yLTASmlg==}
     engines: {node: '>=v14'}
     engines: {node: '>=v14'}
     peerDependencies:
     peerDependencies:
@@ -2045,6 +2039,7 @@ packages:
       '@commitlint/types': 17.0.0
       '@commitlint/types': 17.0.0
       chalk: 4.1.2
       chalk: 4.1.2
       commitizen: 4.2.5
       commitizen: 4.2.5
+      inquirer: 8.2.4
       lodash: 4.17.21
       lodash: 4.17.21
       word-wrap: 1.2.3
       word-wrap: 1.2.3
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -3034,7 +3029,7 @@ packages:
       chalk: 4.1.2
       chalk: 4.1.2
       collect-v8-coverage: 1.0.1
       collect-v8-coverage: 1.0.1
       exit: 0.1.2
       exit: 0.1.2
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       graceful-fs: 4.2.10
       istanbul-lib-coverage: 3.2.0
       istanbul-lib-coverage: 3.2.0
       istanbul-lib-instrument: 5.2.0
       istanbul-lib-instrument: 5.2.0
@@ -3650,8 +3645,8 @@ packages:
   /@types/babel__core/7.1.19:
   /@types/babel__core/7.1.19:
     resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==}
     resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==}
     dependencies:
     dependencies:
-      '@babel/parser': 7.17.10
-      '@babel/types': 7.17.10
+      '@babel/parser': 7.18.5
+      '@babel/types': 7.18.4
       '@types/babel__generator': 7.6.4
       '@types/babel__generator': 7.6.4
       '@types/babel__template': 7.4.1
       '@types/babel__template': 7.4.1
       '@types/babel__traverse': 7.17.1
       '@types/babel__traverse': 7.17.1
@@ -3660,20 +3655,20 @@ packages:
   /@types/babel__generator/7.6.4:
   /@types/babel__generator/7.6.4:
     resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==}
     resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==}
     dependencies:
     dependencies:
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
     dev: true
     dev: true
 
 
   /@types/babel__template/7.4.1:
   /@types/babel__template/7.4.1:
     resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==}
     resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==}
     dependencies:
     dependencies:
-      '@babel/parser': 7.17.10
-      '@babel/types': 7.17.10
+      '@babel/parser': 7.18.5
+      '@babel/types': 7.18.4
     dev: true
     dev: true
 
 
   /@types/babel__traverse/7.17.1:
   /@types/babel__traverse/7.17.1:
     resolution: {integrity: sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==}
     resolution: {integrity: sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==}
     dependencies:
     dependencies:
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
     dev: true
     dev: true
 
 
   /@types/body-parser/1.19.2:
   /@types/body-parser/1.19.2:
@@ -4636,14 +4631,14 @@ packages:
     resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==}
     resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==}
     dev: true
     dev: true
 
 
-  /argon2/0.28.5:
-    resolution: {integrity: sha512-kGFCctzc3VWmR1aCOYjNgvoTmVF5uVBUtWlXCKKO54d1K+31zRz45KAcDIqMo2746ozv/52d25nfEekitaXP0w==}
-    engines: {node: '>=12.0.0'}
+  /argon2/0.29.1:
+    resolution: {integrity: sha512-bWXzAsQA0B6EFWZh5li+YBk+muoknAb8KacAi1h/bC6Gigy9p5ANbrPvpnjTIb7i9I11/8Df6FeSxpJDK3vy4g==}
+    engines: {node: '>=14.0.0'}
     requiresBuild: true
     requiresBuild: true
     dependencies:
     dependencies:
       '@mapbox/node-pre-gyp': 1.0.9
       '@mapbox/node-pre-gyp': 1.0.9
       '@phc/format': 1.0.0
       '@phc/format': 1.0.0
-      node-addon-api: 4.3.0
+      node-addon-api: 5.0.0
     transitivePeerDependencies:
     transitivePeerDependencies:
       - encoding
       - encoding
       - supports-color
       - supports-color
@@ -4824,7 +4819,7 @@ packages:
     resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
     resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
     dependencies:
     dependencies:
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
       '@istanbuljs/load-nyc-config': 1.1.0
       '@istanbuljs/load-nyc-config': 1.1.0
       '@istanbuljs/schema': 0.1.3
       '@istanbuljs/schema': 0.1.3
       istanbul-lib-instrument: 5.2.0
       istanbul-lib-instrument: 5.2.0
@@ -4838,7 +4833,7 @@ packages:
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
     dependencies:
     dependencies:
       '@babel/template': 7.16.7
       '@babel/template': 7.16.7
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
       '@types/babel__core': 7.1.19
       '@types/babel__core': 7.1.19
       '@types/babel__traverse': 7.17.1
       '@types/babel__traverse': 7.17.1
     dev: true
     dev: true
@@ -5369,7 +5364,7 @@ packages:
     dev: false
     dev: false
 
 
   /co/4.6.0:
   /co/4.6.0:
-    resolution: {integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=}
+    resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
     engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
     engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
     dev: true
     dev: true
 
 
@@ -6788,7 +6783,7 @@ packages:
       strip-final-newline: 2.0.0
       strip-final-newline: 2.0.0
 
 
   /exit/0.1.2:
   /exit/0.1.2:
-    resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=}
+    resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
     engines: {node: '>= 0.8.0'}
     engines: {node: '>= 0.8.0'}
     dev: true
     dev: true
 
 
@@ -7332,7 +7327,6 @@ packages:
       minimatch: 3.1.2
       minimatch: 3.1.2
       once: 1.4.0
       once: 1.4.0
       path-is-absolute: 1.0.1
       path-is-absolute: 1.0.1
-    dev: true
 
 
   /global-dirs/0.1.1:
   /global-dirs/0.1.1:
     resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
     resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
@@ -7800,7 +7794,7 @@ packages:
       mute-stream: 0.0.8
       mute-stream: 0.0.8
       ora: 5.4.1
       ora: 5.4.1
       run-async: 2.4.1
       run-async: 2.4.1
-      rxjs: 7.5.5
+      rxjs: 7.5.6
       string-width: 4.2.3
       string-width: 4.2.3
       strip-ansi: 6.0.1
       strip-ansi: 6.0.1
       through: 2.3.8
       through: 2.3.8
@@ -8171,7 +8165,7 @@ packages:
     engines: {node: '>=8'}
     engines: {node: '>=8'}
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/parser': 7.17.10
+      '@babel/parser': 7.18.5
       '@istanbuljs/schema': 0.1.3
       '@istanbuljs/schema': 0.1.3
       istanbul-lib-coverage: 3.2.0
       istanbul-lib-coverage: 3.2.0
       semver: 6.3.0
       semver: 6.3.0
@@ -8322,7 +8316,7 @@ packages:
       chalk: 4.1.2
       chalk: 4.1.2
       ci-info: 3.3.0
       ci-info: 3.3.0
       deepmerge: 4.2.2
       deepmerge: 4.2.2
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       graceful-fs: 4.2.10
       jest-circus: 28.1.0
       jest-circus: 28.1.0
       jest-environment-node: 28.1.0
       jest-environment-node: 28.1.0
@@ -8597,7 +8591,7 @@ packages:
       cjs-module-lexer: 1.2.2
       cjs-module-lexer: 1.2.2
       collect-v8-coverage: 1.0.1
       collect-v8-coverage: 1.0.1
       execa: 5.1.1
       execa: 5.1.1
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       graceful-fs: 4.2.10
       jest-haste-map: 28.1.0
       jest-haste-map: 28.1.0
       jest-message-util: 28.1.0
       jest-message-util: 28.1.0
@@ -8617,10 +8611,10 @@ packages:
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
     dependencies:
     dependencies:
       '@babel/core': 7.17.10
       '@babel/core': 7.17.10
-      '@babel/generator': 7.17.10
+      '@babel/generator': 7.18.2
       '@babel/plugin-syntax-typescript': 7.17.10_@babel+core@7.17.10
       '@babel/plugin-syntax-typescript': 7.17.10_@babel+core@7.17.10
-      '@babel/traverse': 7.17.10
-      '@babel/types': 7.17.10
+      '@babel/traverse': 7.18.5
+      '@babel/types': 7.18.4
       '@jest/expect-utils': 28.1.0
       '@jest/expect-utils': 28.1.0
       '@jest/transform': 28.1.0
       '@jest/transform': 28.1.0
       '@jest/types': 28.1.0
       '@jest/types': 28.1.0
@@ -9043,7 +9037,7 @@ packages:
     dev: true
     dev: true
 
 
   /lodash.memoize/4.1.2:
   /lodash.memoize/4.1.2:
-    resolution: {integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=}
+    resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
     dev: true
     dev: true
 
 
   /lodash.merge/4.6.2:
   /lodash.merge/4.6.2:
@@ -9950,8 +9944,8 @@ packages:
       tslib: 2.4.0
       tslib: 2.4.0
     dev: true
     dev: true
 
 
-  /node-addon-api/4.3.0:
-    resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
+  /node-addon-api/5.0.0:
+    resolution: {integrity: sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==}
     dev: false
     dev: false
 
 
   /node-cache/5.1.2:
   /node-cache/5.1.2:
@@ -11246,7 +11240,7 @@ packages:
     resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
     resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
     hasBin: true
     hasBin: true
     dependencies:
     dependencies:
-      glob: 7.2.0
+      glob: 7.2.3
 
 
   /run-async/2.4.1:
   /run-async/2.4.1:
     resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
     resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
@@ -11266,8 +11260,8 @@ packages:
       tslib: 1.14.1
       tslib: 1.14.1
     dev: true
     dev: true
 
 
-  /rxjs/7.5.5:
-    resolution: {integrity: sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==}
+  /rxjs/7.5.6:
+    resolution: {integrity: sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==}
     dependencies:
     dependencies:
       tslib: 2.4.0
       tslib: 2.4.0
     dev: true
     dev: true
@@ -11533,7 +11527,7 @@ packages:
     dev: true
     dev: true
 
 
   /sprintf-js/1.0.3:
   /sprintf-js/1.0.3:
-    resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=}
+    resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
     dev: true
     dev: true
 
 
   /stack-trace/0.0.10:
   /stack-trace/0.0.10:
@@ -11861,7 +11855,7 @@ packages:
     engines: {node: '>=8'}
     engines: {node: '>=8'}
     dependencies:
     dependencies:
       '@istanbuljs/schema': 0.1.3
       '@istanbuljs/schema': 0.1.3
-      glob: 7.2.0
+      glob: 7.2.3
       minimatch: 3.1.2
       minimatch: 3.1.2
     dev: true
     dev: true
 
 
@@ -11986,6 +11980,40 @@ packages:
       tslib: 2.4.0
       tslib: 2.4.0
     dev: false
     dev: false
 
 
+  /ts-jest/28.0.2_ps5qfvt5fosg52obpfzuxthwve:
+    resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
+    engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
+    hasBin: true
+    peerDependencies:
+      '@babel/core': '>=7.0.0-beta.0 <8'
+      '@types/jest': ^27.0.0
+      babel-jest: ^28.0.0
+      esbuild: '*'
+      jest: ^28.0.0
+      typescript: '>=4.3'
+    peerDependenciesMeta:
+      '@babel/core':
+        optional: true
+      '@types/jest':
+        optional: true
+      babel-jest:
+        optional: true
+      esbuild:
+        optional: true
+    dependencies:
+      '@babel/core': 7.17.10
+      bs-logger: 0.2.6
+      fast-json-stable-stringify: 2.1.0
+      jest: 28.1.0_@types+node@17.0.31
+      jest-util: 28.1.0
+      json5: 2.2.1
+      lodash.memoize: 4.1.2
+      make-error: 1.3.6
+      semver: 7.3.7
+      typescript: 4.6.4
+      yargs-parser: 20.2.9
+    dev: true
+
   /ts-jest/28.0.2_z3fx76c5ksuwr36so7o5uc2kcy:
   /ts-jest/28.0.2_z3fx76c5ksuwr36so7o5uc2kcy:
     resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
     resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
     engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}

+ 32 - 0
scripts/start.sh

@@ -9,7 +9,9 @@ else
 fi
 fi
 
 
 NGINX_PORT=80
 NGINX_PORT=80
+NGINX_PORT_SSL=443
 PROXY_PORT=8080
 PROXY_PORT=8080
+DOMAIN=tipi.localhost
 
 
 while [ -n "$1" ]; do # while loop starts
 while [ -n "$1" ]; do # while loop starts
   case "$1" in
   case "$1" in
@@ -26,6 +28,17 @@ while [ -n "$1" ]; do # while loop starts
     fi
     fi
     shift
     shift
     ;;
     ;;
+  --ssl-port)
+    ssl_port="$2"
+
+    if [[ "${ssl_port}" =~ ^[0-9]+$ ]]; then
+      NGINX_PORT_SSL="${ssl_port}"
+    else
+      echo "--ssl-port must be a number"
+      exit 1
+    fi
+    shift
+    ;;
   --proxy-port)
   --proxy-port)
     proxy_port="$2"
     proxy_port="$2"
 
 
@@ -37,6 +50,17 @@ while [ -n "$1" ]; do # while loop starts
     fi
     fi
     shift
     shift
     ;;
     ;;
+  --domain)
+    domain="$2"
+
+    if [[ "${domain}" =~ ^[a-zA-Z0-9.-]+$ ]]; then
+      DOMAIN="${domain}"
+    else
+      echo "--domain must be a valid domain"
+      exit 1
+    fi
+    shift
+    ;;
   --)
   --)
     shift # The double dash makes them parameters
     shift # The double dash makes them parameters
     break
     break
@@ -58,6 +82,12 @@ if [[ "$(uname)" != "Linux" ]]; then
   exit 1
   exit 1
 fi
 fi
 
 
+# If port is not 80 and domain is not tipi.localhost, we exit
+if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
+  echo "Using a custom domain with a custom port is not supported"
+  exit 1
+fi
+
 ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
 ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 SED_ROOT_FOLDER="$(echo $ROOT_FOLDER | sed 's/\//\\\//g')"
 SED_ROOT_FOLDER="$(echo $ROOT_FOLDER | sed 's/\//\\\//g')"
@@ -150,10 +180,12 @@ for template in ${ENV_FILE}; do
   sed -i "s/<tipi_version>/${TIPI_VERSION}/g" "${template}"
   sed -i "s/<tipi_version>/${TIPI_VERSION}/g" "${template}"
   sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
   sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
   sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
   sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
+  sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
   sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
   sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
   sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
   sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
   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}"
 done
 done
 
 
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"

+ 1 - 3
scripts/unsafe-cleanup.sh

@@ -25,7 +25,5 @@ rm -rf "${ROOT_FOLDER}/app-data"
 rm -rf "${ROOT_FOLDER}/data/postgres"
 rm -rf "${ROOT_FOLDER}/data/postgres"
 mkdir -p "${ROOT_FOLDER}/app-data"
 mkdir -p "${ROOT_FOLDER}/app-data"
 
 
-# Put {"installed":""} in state/apps.json
-echo '{"installed":""}' >"${ROOT_FOLDER}/state/apps.json"
-
+cd "$ROOT_FOLDER"
 "${ROOT_FOLDER}/scripts/start.sh"
 "${ROOT_FOLDER}/scripts/start.sh"

+ 3 - 1
templates/env-sample

@@ -11,5 +11,7 @@ TIPI_VERSION=<tipi_version>
 JWT_SECRET=<jwt_secret>
 JWT_SECRET=<jwt_secret>
 ROOT_FOLDER_HOST=<root_folder>
 ROOT_FOLDER_HOST=<root_folder>
 NGINX_PORT=<nginx_port>
 NGINX_PORT=<nginx_port>
+NGINX_PORT_SSL=<nginx_port_ssl>
 PROXY_PORT=<proxy_port>
 PROXY_PORT=<proxy_port>
-POSTGRES_PASSWORD=<postgres_password>
+POSTGRES_PASSWORD=<postgres_password>
+DOMAIN=<domain>

+ 0 - 14
traefik/dynamic.yml

@@ -1,14 +0,0 @@
-http:
-  routers:
-    traefik:
-      rule: "Host(`proxy.tipi.local`)"
-      service: "api@internal"
-      tls:
-        domains:
-          - main: "tipi.local"
-            sans:
-              - "*.tipi.local"
-tls:
-  certificates:
-    - certFile: "/root/.config/ssl/local-cert.pem"
-      keyFile: "/root/.config/ssl/local-key.pem"

+ 0 - 0
traefik/letsencrypt/.gitkeep


File diff suppressed because it is too large
+ 0 - 10
traefik/letsencrypt/acme.json


+ 0 - 0
traefik/ssl/.gitkeep


+ 12 - 15
traefik/traefik.yml

@@ -8,22 +8,19 @@ providers:
     watch: true
     watch: true
     exposedByDefault: false
     exposedByDefault: false
 
 
-  # TODO: Add TLS support
-  # file:
-  #   filename: /root/.config/dynamic.yml
-  #   watch: true
-
 entryPoints:
 entryPoints:
-  webinsecure:
+  web:
     address: ":80"
     address: ":80"
-    # TODO: Redirect when TLS is working
-    # http:
-    #   redirections:
-    #     entryPoint:
-    #       to: websecure
-    #       scheme: https
-  # websecure:
-  #   address: ":443"
+  websecure:
+    address: ":443"
+
+certificatesResolvers:
+  myresolver:
+    acme:
+      email: acme@thisprops.com 
+      storage: /shared/acme.json
+      httpChallenge:
+        entryPoint: web
 
 
 log:
 log:
-  level: DEBUG
+  level: ERROR

Some files were not shown because too many files changed in this diff