소스 검색

Merge pull request #168 from meienberger/release/0.6.0

Release/0.6.0
Nicolas Meienberger 2 년 전
부모
커밋
160eb2623a
71개의 변경된 파일1261개의 추가작업 그리고 298개의 파일을 삭제
  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
         run: pnpm -r test
 
-      - uses: codecov/codecov-action@v2
+      - uses: codecov/codecov-action@v3
         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
 apps/*
 !apps/.gitkeep
+traefik/shared
 
 scripts/pacapt
 

+ 1 - 1
Dockerfile

@@ -42,4 +42,4 @@ COPY ./packages/system-api /api
 COPY --from=build /dashboard/.next /dashboard/.next
 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.
 
-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
 - [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
 ```
 
+## 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
 
 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"
 
 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:
     container_name: tipi-db
     image: postgres:latest
@@ -51,8 +68,19 @@ services:
       POSTGRES_HOST: tipi-db
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     networks:
       - 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:
     build:
@@ -66,16 +94,27 @@ services:
       - tipi_main_network
     environment:
       - INTERNAL_IP=${INTERNAL_IP}
+      - DOMAIN=${DOMAIN}
     volumes:
       - ${PWD}/packages/dashboard/src:/dashboard/src
       # - /dashboard/node_modules
       # - /dashboard/.next
     labels:
       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.entrypoints: web
       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:
   tipi_main_network:

+ 49 - 5
docker-compose.rc.yml

@@ -3,15 +3,17 @@ version: "3.7"
 services:
   reverse-proxy:
     container_name: reverse-proxy
-    image: traefik:v2.6
+    image: traefik:v2.8
     restart: always
     ports:
       - ${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:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}/traefik:/root/.config
+      - ${PWD}/traefik/shared:/shared
     networks:
       - tipi_main_network
 
@@ -62,10 +64,28 @@ services:
       NODE_ENV: production
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     dns:
       - ${DNS_IP}
     networks:
       - 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:
     image: meienberger/runtipi:rc-${TIPI_VERSION}
@@ -78,12 +98,36 @@ services:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
+      DOMAIN: ${DOMAIN}
     labels:
       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.entrypoints: web
       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:
   tipi_main_network:

+ 48 - 5
docker-compose.yml

@@ -3,15 +3,16 @@ version: "3.9"
 services:
   reverse-proxy:
     container_name: reverse-proxy
-    image: traefik:v2.6
+    image: traefik:v2.8
     restart: always
     ports:
       - ${NGINX_PORT-80}:80
-      - ${PROXY_PORT-8080}:8080
-    command: --api.insecure=true --providers.docker
+      - ${NGINX_PORT_SSL-443}:443
+    command: --providers.docker
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - ${PWD}/traefik:/root/.config
+      - ${PWD}/traefik/shared:/shared
     networks:
       - tipi_main_network
 
@@ -63,10 +64,28 @@ services:
       NODE_ENV: production
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
+      DOMAIN: ${DOMAIN}
     dns:
       - ${DNS_IP}
     networks:
       - 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:
     image: meienberger/runtipi:${TIPI_VERSION}
@@ -80,12 +99,36 @@ services:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
+      DOMAIN: ${DOMAIN}
     labels:
       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.entrypoints: web
       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:
   tipi_main_network:

+ 4 - 13
package.json

@@ -1,9 +1,8 @@
 {
   "name": "runtipi",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "A homeserver for everyone",
   "scripts": {
-    "test": "jest",
     "prepare": "husky install",
     "commit": "git-cz",
     "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:rc": "docker-compose -f docker-compose.rc.yml --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",
     "version": "echo $npm_package_version"
   },
   "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/config-conventional": "^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": {
     "type": "git",
@@ -39,7 +31,6 @@
     "url": "https://github.com/meienberger/runtipi/issues"
   },
   "homepage": "https://github.com/meienberger/runtipi#readme",
-  "dependencies": {},
   "config": {
     "commitizen": {
       "path": "@commitlint/cz-commitlint"

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

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

+ 3 - 1
packages/dashboard/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dashboard",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "private": true,
   "scripts": {
     "test": "jest --colors",
@@ -60,8 +60,10 @@
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
     "eslint-plugin-import": "^2.25.3",
+    "jest": "^28.1.0",
     "postcss": "^8.4.12",
     "tailwindcss": "^3.0.23",
+    "ts-jest": "^28.0.2",
     "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';
 
 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 (
     <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 { 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) {
     return `${field.label} is required`;
   }
 
-  if (!value) {
+  if (!value || typeof value !== 'string') {
     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 = {};
 
   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;
 };

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

@@ -2,6 +2,7 @@ import React from 'react';
 import Link from 'next/link';
 import { Flex } from '@chakra-ui/react';
 import { FiMenu } from 'react-icons/fi';
+import { getUrl } from '../../core/helpers/url-helpers';
 
 interface IProps {
   onClickMenu: () => void;
@@ -16,7 +17,7 @@ const Header: React.FC<IProps> = ({ onClickMenu }) => {
         </div>
         <Flex justifyContent="center" flex="1">
           <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>
         </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 { IconType } from 'react-icons';
 import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
+import { getUrl } from '../../core/helpers/url-helpers';
 
 const SideMenu: React.FC = () => {
   const router = useRouter();
@@ -45,7 +46,7 @@ const SideMenu: React.FC = () => {
 
   return (
     <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">
         {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
         {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 { getState } = useSytemStore;
-  const BASE_URL = `http://${getState().internalIp}:3001`;
+  const BASE_URL = getState().baseUrl;
 
   const response = await axios.request<T & { error?: string }>({
     method,

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

@@ -1,8 +1,8 @@
 import { ApolloClient, from, InMemoryCache } from '@apollo/client';
 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({
     link: additiveLink,

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

@@ -1,9 +1,10 @@
 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',
   });
+};
 
 export default httpLink;

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

@@ -3,10 +3,9 @@ import axios from 'axios';
 import { useSytemStore } from '../state/systemStore';
 
 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;

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

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

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

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

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

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

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

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

@@ -4,6 +4,7 @@ import axios from 'axios';
 import useSWR, { BareFetcher } from 'swr';
 import { createApolloClient } from '../core/apollo/client';
 import { useSytemStore } from '../state/systemStore';
+import { getUrl } from '../core/helpers/url-helpers';
 
 interface IReturnProps {
   client?: ApolloClient<unknown>;
@@ -11,18 +12,18 @@ interface IReturnProps {
 }
 
 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 {
-  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 [client, setClient] = useState<ApolloClient<unknown>>();
 
-  async function loadResourcesAndDataAsync(ip: string) {
+  async function loadResourcesAndDataAsync(url: string) {
     try {
-      const restoredClient = await createApolloClient(ip);
+      const restoredClient = await createApolloClient(url);
 
       setClient(restoredClient);
     } catch (error) {
@@ -34,16 +35,24 @@ export default function useCachedResources(): IReturnProps {
   }
 
   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(() => {
-    if (internalIp) {
-      loadResourcesAndDataAsync(internalIp);
+    if (baseUrl) {
+      loadResourcesAndDataAsync(baseUrl);
     }
-  }, [internalIp]);
+  }, [baseUrl]);
 
   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.Music]: 'green',
   [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 hasSettings = Object.keys(app.form_fields).length > 0;
+  const hasSettings = Object.keys(app.form_fields).length > 0 || app.exposable;
 
   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 { Form, Field } from 'react-final-form';
 import FormInput from '../../../components/Form/FormInput';
+import FormSwitch from '../../../components/Form/FormSwitch';
 import { validateAppConfig } from '../../../components/Form/validators';
 import { AppInfo, FormField } from '../../../generated/graphql';
 
@@ -9,12 +10,19 @@ interface IProps {
   formFields: AppInfo['form_fields'];
   onSubmit: (values: Record<string, unknown>) => void;
   initalValues?: Record<string, string>;
+  exposable?: boolean | null;
 }
 
+export type IFormValues = {
+  exposed?: boolean;
+  domain?: string;
+  [key: string]: string | boolean | undefined;
+};
+
 const hiddenTypes = ['random'];
 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) => {
     return (
       <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 (
-    <Form<Record<string, string>>
+    <Form<IFormValues>
       initialValues={initalValues}
       onSubmit={onSubmit}
       validateOnBlur={true}
       validate={(values) => validateAppConfig(values, formFields)}
-      render={({ handleSubmit, validating, submitting }) => (
+      render={({ handleSubmit, validating, submitting, values }) => (
         <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>
       )}
     />

+ 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>
         <ModalCloseButton />
         <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} />
+          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
         </ModalBody>
       </ModalContent>
     </Modal>

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

@@ -7,11 +7,13 @@ interface IProps {
   app: AppInfo;
   config: App['config'];
   isOpen: boolean;
+  exposed?: boolean;
+  domain?: string;
   onClose: () => 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 (
     <Modal isOpen={isOpen} onClose={onClose}>
       <ModalOverlay />
@@ -19,7 +21,7 @@ const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, o
         <ModalHeader>Update {app.name} config</ModalHeader>
         <ModalCloseButton />
         <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>
       </ModalContent>
     </Modal>

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

@@ -23,9 +23,10 @@ import {
   useUpdateAppMutation,
 } from '../../../generated/graphql';
 import UpdateModal from '../components/UpdateModal';
+import { IFormValues } from '../components/InstallForm';
 
 interface IProps {
-  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo'>;
+  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo' | 'exposed' | 'domain'>;
   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();
+    const { exposed, domain, ...form } = values;
     try {
       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' } },
       });
     } 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 {
-      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({
         title: 'Success',
-        description: 'App config updated successfully',
+        description: 'App config updated successfully. Restart the app to apply the changes.',
         position: 'top',
         status: 'success',
+        isClosable: true,
       });
       updateSettingsDisclosure.onClose();
     } catch (error) {
@@ -123,6 +127,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
         description: 'App updated successfully',
         position: 'top',
         status: 'success',
+        isClosable: true,
       });
     } catch (error) {
       handleError(error);
@@ -130,7 +135,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
   };
 
   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(' ');
@@ -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="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>
+              {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>
               <h3 className="text-center md:text-left text-sm">
                 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>
             </div>
+
             <div className="flex flex-1">
               <AppActions
                 updateAvailable={updateAvailable}
@@ -180,7 +200,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
         <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.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} />
-        <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} />
       </div>
     </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 React from 'react';
+import { getUrl } from '../../../core/helpers/url-helpers';
 
 interface IProps {
   title: string;
@@ -12,7 +13,7 @@ const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
     <Container maxW="1250px">
       <Flex flex={1} height="100vh" overflowY="hidden">
         <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">
             {title}
           </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 { ApolloProvider } from '@apollo/client';
 import useCachedResources from '../hooks/useCachedRessources';
+import Head from 'next/head';
 
 function MyApp({ Component, pageProps }: AppProps) {
   const { client } = useCachedResources();
@@ -18,6 +19,9 @@ function MyApp({ Component, pageProps }: AppProps) {
   return (
     <ApolloProvider client={client}>
       <ChakraProvider theme={theme}>
+        <Head>
+          <title>Tipi</title>
+        </Head>
         <AuthWrapper>
           <Component {...pageProps} />
         </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 { ColorModeScript } from '@chakra-ui/react';
 import { theme } from '../styles/theme';
+import { getUrl } from '../core/helpers/url-helpers';
 
 export default function MyDocument() {
   return (
     <Html lang="en">
       <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="theme-color" content="#ffffff" />
       </Head>

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

@@ -1,5 +1,6 @@
 export default function ip(_: any, res: any) {
   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';
 
 type Store = {
+  baseUrl: 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) => ({
+  baseUrl: '',
   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": {
     "parser": {
       "syntax": "typescript",

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

@@ -1,6 +1,7 @@
 import path from 'path';
 const fs: {
   __createMockFiles: typeof createMockFiles;
+  __resetAllMocks: typeof resetAllMocks;
   readFileSync: typeof readFileSync;
   existsSync: typeof existsSync;
   writeFileSync: typeof writeFileSync;
@@ -9,6 +10,7 @@ const fs: {
   readdirSync: typeof readdirSync;
   copyFileSync: typeof copyFileSync;
   copySync: typeof copyFileSync;
+  createFileSync: typeof createFileSync;
 } = jest.genMockFromModule('fs-extra');
 
 let mockFiles = Object.create(null);
@@ -45,12 +47,14 @@ const mkdirSync = (p: string) => {
   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) => {
@@ -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.existsSync = existsSync;
 fs.readFileSync = readFileSync;
@@ -93,6 +105,8 @@ fs.mkdirSync = mkdirSync;
 fs.rmSync = rmSync;
 fs.copyFileSync = copyFileSync;
 fs.copySync = copySync;
+fs.createFileSync = createFileSync;
 fs.__createMockFiles = createMockFiles;
+fs.__resetAllMocks = resetAllMocks;
 
 module.exports = fs;

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

@@ -1,6 +1,6 @@
 {
   "name": "system-api",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "description": "",
   "exports": "./dist/server.js",
   "type": "module",
@@ -13,7 +13,7 @@
     "lint:fix": "eslint . --ext .ts --fix",
     "test": "jest --colors",
     "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",
     "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\"",
@@ -27,7 +27,7 @@
   "dependencies": {
     "apollo-server-core": "^3.10.0",
     "apollo-server-express": "^3.9.0",
-    "argon2": "^0.28.5",
+    "argon2": "^0.29.1",
     "axios": "^0.26.1",
     "class-validator": "^0.13.2",
     "compression": "^1.7.4",
@@ -55,6 +55,7 @@
     "tcp-port-used": "^1.0.2",
     "type-graphql": "^1.1.1",
     "typeorm": "^0.3.6",
+    "validator": "^13.7.0",
     "winston": "^3.7.2"
   },
   "devDependencies": {

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

@@ -34,6 +34,7 @@ const {
   NGINX_PORT = '80',
   APPS_REPO_ID = '',
   APPS_REPO_URL = '',
+  DOMAIN = '',
 } = process.env;
 
 const config: IConfig = {
@@ -45,7 +46,7 @@ const config: IConfig = {
   NODE_ENV,
   ROOT_FOLDER: '/tipi',
   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,
   ROOT_FOLDER_HOST,
   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 config from '../../config';
 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 FileStore = SessionFileStore(session);
 
+  const sameSite = __prod__ ? 'lax' : 'none';
+
   return session({
     name: 'qid',
     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,
     resave: false,
     saveUninitialized: false,

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

@@ -8,10 +8,13 @@ interface IProps {
   status?: AppStatusEnum;
   requiredPort?: number;
   randomField?: boolean;
+  exposed?: boolean;
+  domain?: string;
+  exposable?: boolean;
 }
 
 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);
 
@@ -34,6 +37,7 @@ const createApp = async (props: IProps) => {
     author: faker.name.firstName(),
     source: faker.internet.url(),
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
+    exposable,
   };
 
   if (randomField) {
@@ -54,13 +58,17 @@ const createApp = async (props: IProps) => {
   MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
   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}/docker-compose.yml`] = 'compose';
   MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
 
+  let appEntity = new App();
   if (installed) {
-    await App.create({
+    appEntity = await App.create({
       id: appInfo.id,
       config: { TEST_FIELD: 'test' },
       status,
+      exposed,
+      domain,
     }).save();
 
     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';
   }
 
-  return { appInfo, MockFiles };
+  return { appInfo, MockFiles, appEntity };
 };
 
 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 config from '../../../config';
 import { setupConnection, teardownConnection } from '../../../test/connection';
+import App from '../app.entity';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
 import { AppInfo } from '../apps.types';
 import { createApp } from './apps.factory';
@@ -127,16 +128,19 @@ describe('runAppScript', () => {
 
 describe('generateEnvFile', () => {
   let app1: AppInfo;
+  let appEntity1: App;
   beforeEach(async () => {
     const app1create = await createApp({ installed: true });
     app1 = app1create.appInfo;
+    appEntity1 = app1create.appEntity;
     // @ts-ignore
     fs.__createMockFiles(app1create.MockFiles);
   });
 
   it('Should generate an env file', async () => {
     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);
 
@@ -144,11 +148,11 @@ describe('generateEnvFile', () => {
   });
 
   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
     fs.__createMockFiles(MockFiles);
 
-    generateEnvFile(appInfo.id, { TEST_FIELD: 'test' });
+    generateEnvFile(appEntity);
 
     const envmap = await getEnvMap(appInfo.id);
 
@@ -157,7 +161,7 @@ describe('generateEnvFile', () => {
   });
 
   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
     fs.__createMockFiles(MockFiles);
 
@@ -165,7 +169,7 @@ describe('generateEnvFile', () => {
 
     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);
 
@@ -174,7 +178,7 @@ describe('generateEnvFile', () => {
 
   it('Should throw an error if required field is not provided', async () => {
     try {
-      generateEnvFile(app1.id, {});
+      generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: undefined } }));
       expect(true).toBe(false);
     } catch (e: any) {
       expect(e).toBeDefined();
@@ -184,13 +188,53 @@ describe('generateEnvFile', () => {
 
   it('Should throw an error if app does not exist', async () => {
     try {
-      generateEnvFile('not-existing-app', { TEST_FIELD: 'test' });
+      generateEnvFile(Object.assign(appEntity1, { id: 'not-existing-app' }));
       expect(true).toBe(false);
     } catch (e: any) {
       expect(e).toBeDefined();
       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', () => {
@@ -220,7 +264,7 @@ describe('getAppInfo', () => {
   it('Should return app info', async () => {
     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 () => {
@@ -232,17 +276,13 @@ describe('getAppInfo', () => {
 
     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 }>({
       source: installAppMutation,
       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);
@@ -179,7 +179,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       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');
@@ -189,7 +189,7 @@ describe('InstallApp', () => {
   it("Should throw an error if user doesn't exist", async () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
       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!');
@@ -199,7 +199,7 @@ describe('InstallApp', () => {
   it('Should throw an error if no userId is provided', async () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
       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!');
@@ -212,7 +212,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       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`);
@@ -229,7 +229,7 @@ describe('InstallApp', () => {
     const { data, errors } = await gcall<{ installApp: TApp }>({
       source: installAppMutation,
       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`);
@@ -429,7 +429,7 @@ describe('UpdateAppConfig', () => {
     const { data } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       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);
@@ -442,7 +442,7 @@ describe('UpdateAppConfig', () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       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');
@@ -453,7 +453,7 @@ describe('UpdateAppConfig', () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       source: updateAppConfigMutation,
       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!');
@@ -463,7 +463,7 @@ describe('UpdateAppConfig', () => {
   it('Should throw an error if no userId is provided', async () => {
     const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
       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!');

+ 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')).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', () => {
@@ -308,6 +361,37 @@ describe('Update app config', () => {
 
     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', () => {

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

@@ -55,9 +55,17 @@ class App extends BaseEntity {
   @UpdateDateColumn()
   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 })
   info(): AppInfo | null {
-    return getAppInfo(this.id);
+    return getAppInfo(this.id, this.status);
   }
 
   @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 crypto from 'crypto';
 import config from '../../config';
-import { AppInfo } from './apps.types';
+import { AppInfo, AppStatusEnum } from './apps.types';
 import logger from '../../config/logger/logger';
 import App from './app.entity';
 
@@ -74,19 +74,19 @@ const getEntropy = (name: string, length: number) => {
   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) {
-    throw new Error(`App ${appName} not found`);
+    throw new Error(`App ${app.id} not found`);
   }
 
   const baseEnvFile = readFile('/.env').toString();
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
-  const envMap = getEnvMap(appName);
+  const envMap = getEnvMap(app.id);
 
   configFile.form_fields?.forEach((field) => {
-    const formValue = form[field.env_variable];
+    const formValue = app.config[field.env_variable];
     const envVar = field.env_variable;
 
     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[]> => {
@@ -126,15 +131,18 @@ export const getAvailableApps = async (): Promise<string[]> => {
   return apps;
 };
 
-export const getAppInfo = (id: string): AppInfo => {
+export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
   try {
     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`);
       configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
       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`);
       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) {
+    console.error(e);
     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()
   @Mutation(() => 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()
@@ -50,9 +50,9 @@ export default class AppsResolver {
   @Authorized()
   @Mutation(() => 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()

+ 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 { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
 import config from '../../config';
+import { Not } from 'typeorm';
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 
@@ -15,7 +17,7 @@ const startAllApps = async (): Promise<void> => {
       // Regenerate env file
       try {
         ensureAppFolder(app.id);
-        generateEnvFile(app.id, app.config);
+        generateEnvFile(app);
         checkEnvFile(app.id);
 
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
@@ -40,7 +42,7 @@ const startApp = async (appName: string): Promise<App> => {
   ensureAppFolder(appName);
 
   // Regenerate env file
-  generateEnvFile(appName, app.config);
+  generateEnvFile(app);
 
   checkEnvFile(appName);
 
@@ -59,13 +61,21 @@ const startApp = async (appName: string): Promise<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 } });
 
   if (app) {
     await startApp(id);
   } 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);
 
     if (!appIsValid) {
@@ -75,11 +85,23 @@ const installApp = async (id: string, form: Record<string, string>): Promise<App
     // Create app folder
     createFolder(`/app-data/${id}`);
 
-    // Create env file
-    generateEnvFile(id, form);
-
     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
     try {
@@ -116,15 +138,38 @@ const listApps = async (): Promise<ListAppsResonse> => {
   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 } });
 
   if (!app) {
     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;
 
   return app;
@@ -185,7 +230,7 @@ const getApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
 
   if (!app) {
-    app = { id, status: AppStatusEnum.MISSING, config: {} } as App;
+    app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as App;
   }
 
   return app;

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

@@ -15,6 +15,7 @@ export enum AppCategoriesEnum {
   DATA = 'data',
   MUSIC = 'music',
   FINANCE = 'finance',
+  GAMING = 'gaming',
 }
 
 export enum FieldTypes {
@@ -121,6 +122,12 @@ class AppInfo {
 
   @Field(() => GraphQLJSONObject, { nullable: true })
   requirements?: Requirements;
+
+  @Field(() => Boolean, { nullable: true })
+  https?: boolean;
+
+  @Field(() => Boolean, { nullable: true })
+  exposable?: boolean;
 }
 
 @ObjectType()
@@ -139,6 +146,12 @@ class AppInputType {
 
   @Field(() => GraphQLJSONObject)
   form!: Record<string, string>;
+
+  @Field(() => Boolean)
+  exposed!: boolean;
+
+  @Field(() => String)
+  domain!: string;
 }
 
 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 validator from 'validator';
 import { UsernamePasswordInput, UserResponse } from './auth.types';
 import User from './user.entity';
 
@@ -22,19 +23,24 @@ const login = async (input: UsernamePasswordInput): Promise<UserResponse> => {
 
 const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
   const { password, username } = input;
+  const email = username.trim().toLowerCase();
 
   if (!username || !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) {
     throw new Error('User already exists');
   }
 
   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 };
 };

+ 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();
 };
 
-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`)) {
-    fs.removeSync(getAbsolutePath(`/apps/${appName}`));
+    if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
     // Copy from apps repo
     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 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 () => {
   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 updateAppConfig from './updateAppConfig.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 startAppMutation = print(startApp);
@@ -15,3 +17,5 @@ export const stopAppMutation = print(stopApp);
 export const uninstallAppMutation = print(uninstallApp);
 export const updateAppConfigMutation = print(updateAppConfig);
 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 getApp from './getApp.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 getAppQuery = print(getApp);
 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/config-conventional': ^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
       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:
       '@commitlint/cli': 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
       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:
     specifiers:
@@ -62,6 +50,7 @@ importers:
       graphql: ^15.8.0
       graphql-tag: ^2.12.6
       immer: ^9.0.12
+      jest: ^28.1.0
       js-cookie: ^3.0.1
       next: 12.1.6
       postcss: ^8.4.12
@@ -77,6 +66,7 @@ importers:
       swr: ^1.3.0
       systeminformation: ^5.11.9
       tailwindcss: ^3.0.23
+      ts-jest: ^28.0.2
       tslib: ^2.4.0
       typescript: 4.6.4
       validator: ^13.7.0
@@ -129,8 +119,10 @@ importers:
       eslint-config-airbnb-typescript: 17.0.0_r46exuh3jlhq2wmrnqx2ufqspa
       eslint-config-next: 12.1.4_e6a2zi6fqdwfehht5cxvkmo3zu
       eslint-plugin-import: 2.26.0_hhyjdrupy4c2vgtpytri6cjwoy
+      jest: 28.1.0_@types+node@17.0.31
       postcss: 8.4.13
       tailwindcss: 3.0.24
+      ts-jest: 28.0.2_ps5qfvt5fosg52obpfzuxthwve
       typescript: 4.6.4
 
   packages/system-api:
@@ -157,7 +149,7 @@ importers:
       '@typescript-eslint/parser': ^5.22.0
       apollo-server-core: ^3.10.0
       apollo-server-express: ^3.9.0
-      argon2: ^0.28.5
+      argon2: ^0.29.1
       axios: ^0.26.1
       class-validator: ^0.13.2
       compression: ^1.7.4
@@ -199,11 +191,12 @@ importers:
       type-graphql: ^1.1.1
       typeorm: ^0.3.6
       typescript: 4.6.4
+      validator: ^13.7.0
       winston: ^3.7.2
     dependencies:
       apollo-server-core: 3.10.0_graphql@15.8.0
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
-      argon2: 0.28.5
+      argon2: 0.29.1
       axios: 0.26.1
       class-validator: 0.13.2
       compression: 1.7.4
@@ -231,6 +224,7 @@ importers:
       tcp-port-used: 1.0.2
       type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
       typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
+      validator: 13.7.0
       winston: 3.7.2
     devDependencies:
       '@faker-js/faker': 7.3.0
@@ -761,7 +755,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.17.10:
@@ -770,7 +764,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.17.10:
@@ -798,7 +792,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.17.10:
@@ -807,7 +801,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-jsx/7.16.7_@babel+core@7.17.10:
@@ -835,7 +829,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@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
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.17.10:
@@ -853,7 +847,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@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
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.17.10:
@@ -880,7 +874,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@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
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-syntax-typescript/7.17.10_@babel+core@7.17.10:
@@ -900,7 +894,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
     dev: true
 
   /@babel/plugin-transform-arrow-functions/7.17.12_@babel+core@7.17.10:
@@ -2033,7 +2027,7 @@ packages:
       ajv: 8.11.0
     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==}
     engines: {node: '>=v14'}
     peerDependencies:
@@ -2045,6 +2039,7 @@ packages:
       '@commitlint/types': 17.0.0
       chalk: 4.1.2
       commitizen: 4.2.5
+      inquirer: 8.2.4
       lodash: 4.17.21
       word-wrap: 1.2.3
     transitivePeerDependencies:
@@ -3034,7 +3029,7 @@ packages:
       chalk: 4.1.2
       collect-v8-coverage: 1.0.1
       exit: 0.1.2
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       istanbul-lib-coverage: 3.2.0
       istanbul-lib-instrument: 5.2.0
@@ -3650,8 +3645,8 @@ packages:
   /@types/babel__core/7.1.19:
     resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==}
     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__template': 7.4.1
       '@types/babel__traverse': 7.17.1
@@ -3660,20 +3655,20 @@ packages:
   /@types/babel__generator/7.6.4:
     resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==}
     dependencies:
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
     dev: true
 
   /@types/babel__template/7.4.1:
     resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==}
     dependencies:
-      '@babel/parser': 7.17.10
-      '@babel/types': 7.17.10
+      '@babel/parser': 7.18.5
+      '@babel/types': 7.18.4
     dev: true
 
   /@types/babel__traverse/7.17.1:
     resolution: {integrity: sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==}
     dependencies:
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
     dev: true
 
   /@types/body-parser/1.19.2:
@@ -4636,14 +4631,14 @@ packages:
     resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==}
     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
     dependencies:
       '@mapbox/node-pre-gyp': 1.0.9
       '@phc/format': 1.0.0
-      node-addon-api: 4.3.0
+      node-addon-api: 5.0.0
     transitivePeerDependencies:
       - encoding
       - supports-color
@@ -4824,7 +4819,7 @@ packages:
     resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
     engines: {node: '>=8'}
     dependencies:
-      '@babel/helper-plugin-utils': 7.16.7
+      '@babel/helper-plugin-utils': 7.17.12
       '@istanbuljs/load-nyc-config': 1.1.0
       '@istanbuljs/schema': 0.1.3
       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}
     dependencies:
       '@babel/template': 7.16.7
-      '@babel/types': 7.17.10
+      '@babel/types': 7.18.4
       '@types/babel__core': 7.1.19
       '@types/babel__traverse': 7.17.1
     dev: true
@@ -5369,7 +5364,7 @@ packages:
     dev: false
 
   /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'}
     dev: true
 
@@ -6788,7 +6783,7 @@ packages:
       strip-final-newline: 2.0.0
 
   /exit/0.1.2:
-    resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=}
+    resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
     engines: {node: '>= 0.8.0'}
     dev: true
 
@@ -7332,7 +7327,6 @@ packages:
       minimatch: 3.1.2
       once: 1.4.0
       path-is-absolute: 1.0.1
-    dev: true
 
   /global-dirs/0.1.1:
     resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
@@ -7800,7 +7794,7 @@ packages:
       mute-stream: 0.0.8
       ora: 5.4.1
       run-async: 2.4.1
-      rxjs: 7.5.5
+      rxjs: 7.5.6
       string-width: 4.2.3
       strip-ansi: 6.0.1
       through: 2.3.8
@@ -8171,7 +8165,7 @@ packages:
     engines: {node: '>=8'}
     dependencies:
       '@babel/core': 7.17.10
-      '@babel/parser': 7.17.10
+      '@babel/parser': 7.18.5
       '@istanbuljs/schema': 0.1.3
       istanbul-lib-coverage: 3.2.0
       semver: 6.3.0
@@ -8322,7 +8316,7 @@ packages:
       chalk: 4.1.2
       ci-info: 3.3.0
       deepmerge: 4.2.2
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       jest-circus: 28.1.0
       jest-environment-node: 28.1.0
@@ -8597,7 +8591,7 @@ packages:
       cjs-module-lexer: 1.2.2
       collect-v8-coverage: 1.0.1
       execa: 5.1.1
-      glob: 7.2.0
+      glob: 7.2.3
       graceful-fs: 4.2.10
       jest-haste-map: 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}
     dependencies:
       '@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/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/transform': 28.1.0
       '@jest/types': 28.1.0
@@ -9043,7 +9037,7 @@ packages:
     dev: true
 
   /lodash.memoize/4.1.2:
-    resolution: {integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=}
+    resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
     dev: true
 
   /lodash.merge/4.6.2:
@@ -9950,8 +9944,8 @@ packages:
       tslib: 2.4.0
     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
 
   /node-cache/5.1.2:
@@ -11246,7 +11240,7 @@ packages:
     resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
     hasBin: true
     dependencies:
-      glob: 7.2.0
+      glob: 7.2.3
 
   /run-async/2.4.1:
     resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
@@ -11266,8 +11260,8 @@ packages:
       tslib: 1.14.1
     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:
       tslib: 2.4.0
     dev: true
@@ -11533,7 +11527,7 @@ packages:
     dev: true
 
   /sprintf-js/1.0.3:
-    resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=}
+    resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
     dev: true
 
   /stack-trace/0.0.10:
@@ -11861,7 +11855,7 @@ packages:
     engines: {node: '>=8'}
     dependencies:
       '@istanbuljs/schema': 0.1.3
-      glob: 7.2.0
+      glob: 7.2.3
       minimatch: 3.1.2
     dev: true
 
@@ -11986,6 +11980,40 @@ packages:
       tslib: 2.4.0
     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:
     resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
     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
 
 NGINX_PORT=80
+NGINX_PORT_SSL=443
 PROXY_PORT=8080
+DOMAIN=tipi.localhost
 
 while [ -n "$1" ]; do # while loop starts
   case "$1" in
@@ -26,6 +28,17 @@ while [ -n "$1" ]; do # while loop starts
     fi
     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="$2"
 
@@ -37,6 +50,17 @@ while [ -n "$1" ]; do # while loop starts
     fi
     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
     break
@@ -58,6 +82,12 @@ if [[ "$(uname)" != "Linux" ]]; then
   exit 1
 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]}")/..)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 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/<architecture>/${ARCHITECTURE}/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/<postgres_password>/${POSTGRES_PASSWORD}/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/<domain>/${DOMAIN}/g" "${template}"
 done
 
 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"
 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"

+ 3 - 1
templates/env-sample

@@ -11,5 +11,7 @@ TIPI_VERSION=<tipi_version>
 JWT_SECRET=<jwt_secret>
 ROOT_FOLDER_HOST=<root_folder>
 NGINX_PORT=<nginx_port>
+NGINX_PORT_SSL=<nginx_port_ssl>
 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


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 10
traefik/letsencrypt/acme.json


+ 0 - 0
traefik/ssl/.gitkeep


+ 12 - 15
traefik/traefik.yml

@@ -8,22 +8,19 @@ providers:
     watch: true
     exposedByDefault: false
 
-  # TODO: Add TLS support
-  # file:
-  #   filename: /root/.config/dynamic.yml
-  #   watch: true
-
 entryPoints:
-  webinsecure:
+  web:
     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:
-  level: DEBUG
+  level: ERROR

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.