diff --git a/.gitignore b/.gitignore
index 79432f2b..ac6e25da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ repos/*
!repos/.gitkeep
apps/*
!apps/.gitkeep
+traefik/shared
scripts/pacapt
diff --git a/Dockerfile b/Dockerfile
index e2475cdb..0210479b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,8 @@ FROM alpine:3.16.0 as app
WORKDIR /
# Install dependencies
-RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
+RUN apk --no-cache add docker-compose nodejs npm bash git
+
RUN npm install node-gyp -g
WORKDIR /api
diff --git a/Dockerfile.dev b/Dockerfile.dev
index a2e8244c..b90154a7 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -3,7 +3,7 @@ FROM alpine:3.16.0 as app
WORKDIR /
# Install docker
-RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
+RUN apk --no-cache add docker-compose nodejs npm bash git
RUN npm install node-gyp -g
diff --git a/README.md b/README.md
index 414217f7..99179dab 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,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.
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index ecd9e989..ae4285e0 100644
--- a/docker-compose.dev.yml
+++ b/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,26 @@ 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
+ # 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:
build:
@@ -66,16 +101,40 @@ 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
+
+ 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:
diff --git a/docker-compose.rc.yml b/docker-compose.rc.yml
index 019fadfb..29311059 100644
--- a/docker-compose.rc.yml
+++ b/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:
diff --git a/docker-compose.yml b/docker-compose.yml
index 34b91691..b8174f65 100644
--- a/docker-compose.yml
+++ b/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:
diff --git a/packages/dashboard/next.config.js b/packages/dashboard/next.config.js
index efdb715c..368e7900 100644
--- a/packages/dashboard/next.config.js
+++ b/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;
diff --git a/packages/dashboard/src/components/AppLogo/AppLogo.tsx b/packages/dashboard/src/components/AppLogo/AppLogo.tsx
index 763c87de..2bb11a9e 100644
--- a/packages/dashboard/src/components/AppLogo/AppLogo.tsx
+++ b/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 (
diff --git a/packages/dashboard/src/components/Form/FormSwitch.tsx b/packages/dashboard/src/components/Form/FormSwitch.tsx
new file mode 100644
index 00000000..65d5305a
--- /dev/null
+++ b/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
[0]['type'];
+ label?: string;
+ className?: string;
+ size?: Parameters[0]['size'];
+ checked?: boolean;
+}
+
+const FormSwitch: React.FC = ({ placeholder, type, label, className, size, ...rest }) => {
+ return (
+
+ {label && }
+
+
+ );
+};
+
+export default FormSwitch;
diff --git a/packages/dashboard/src/components/Form/validators.ts b/packages/dashboard/src/components/Form/validators.ts
index c22598d3..112954eb 100644
--- a/packages/dashboard/src/components/Form/validators.ts
+++ b/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, 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;
};
diff --git a/packages/dashboard/src/components/Layout/Header.tsx b/packages/dashboard/src/components/Layout/Header.tsx
index 72a0a0ea..dd57f313 100644
--- a/packages/dashboard/src/components/Layout/Header.tsx
+++ b/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 = ({ onClickMenu }) => {
-
+
diff --git a/packages/dashboard/src/components/Layout/SideMenu.tsx b/packages/dashboard/src/components/Layout/SideMenu.tsx
index 2b8cb111..8a77d2b6 100644
--- a/packages/dashboard/src/components/Layout/SideMenu.tsx
+++ b/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 (
-
+
{renderMenuItem('Dashboard', '', AiOutlineDashboard)}
{renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
diff --git a/packages/dashboard/src/core/api.ts b/packages/dashboard/src/core/api.ts
index c960a177..bd54d44d 100644
--- a/packages/dashboard/src/core/api.ts
+++ b/packages/dashboard/src/core/api.ts
@@ -12,7 +12,7 @@ const api = async (fetchParams: IFetchParams): Promise => {
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({
method,
diff --git a/packages/dashboard/src/core/apollo/client.ts b/packages/dashboard/src/core/apollo/client.ts
index 083f2e7b..d49d504e 100644
--- a/packages/dashboard/src/core/apollo/client.ts
+++ b/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> => {
- const additiveLink = from([links.errorLink, links.httpLink(ip)]);
+export const createApolloClient = async (url: string): Promise> => {
+ const additiveLink = from([links.errorLink, links.httpLink(url)]);
return new ApolloClient({
link: additiveLink,
diff --git a/packages/dashboard/src/core/apollo/links/httpLink.ts b/packages/dashboard/src/core/apollo/links/httpLink.ts
index 646e999b..fd2fcb13 100644
--- a/packages/dashboard/src/core/apollo/links/httpLink.ts
+++ b/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;
diff --git a/packages/dashboard/src/core/fetcher.ts b/packages/dashboard/src/core/fetcher.ts
index 9f121112..3e8f063a 100644
--- a/packages/dashboard/src/core/fetcher.ts
+++ b/packages/dashboard/src/core/fetcher.ts
@@ -3,10 +3,9 @@ import axios from 'axios';
import { useSytemStore } from '../state/systemStore';
const fetcher: BareFetcher = (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;
diff --git a/packages/dashboard/src/core/helpers/url-helpers.ts b/packages/dashboard/src/core/helpers/url-helpers.ts
new file mode 100644
index 00000000..ed4e3024
--- /dev/null
+++ b/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}`;
+};
diff --git a/packages/dashboard/src/generated/graphql.tsx b/packages/dashboard/src/generated/graphql.tsx
index 708700fa..36ddb313 100644
--- a/packages/dashboard/src/generated/graphql.tsx
+++ b/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;
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,6 +58,7 @@ export type AppInfo = {
available: Scalars['Boolean'];
categories: Array;
description: Scalars['String'];
+ exposable?: Maybe;
form_fields: Array;
https?: Maybe;
id: Scalars['String'];
@@ -69,6 +73,8 @@ export type AppInfo = {
};
export type AppInputType = {
+ domain: Scalars['String'];
+ exposed: Scalars['Boolean'];
form: Scalars['JSONObject'];
id: Scalars['String'];
};
@@ -287,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';
@@ -303,6 +311,7 @@ export type GetAppQuery = {
categories: Array;
url_suffix?: string | null;
https?: boolean | null;
+ exposable?: boolean | null;
form_fields: Array<{
__typename?: 'FormField';
type: FieldTypesEnum;
@@ -696,6 +705,8 @@ export const GetAppDocument = gql`
status
config
version
+ exposed
+ domain
updateInfo {
current
latest
@@ -715,6 +726,7 @@ export const GetAppDocument = gql`
categories
url_suffix
https
+ exposable
form_fields {
type
label
diff --git a/packages/dashboard/src/graphql/queries/getApp.graphql b/packages/dashboard/src/graphql/queries/getApp.graphql
index 1ffb7a38..0ef6d4f5 100644
--- a/packages/dashboard/src/graphql/queries/getApp.graphql
+++ b/packages/dashboard/src/graphql/queries/getApp.graphql
@@ -4,6 +4,8 @@ query GetApp($appId: String!) {
status
config
version
+ exposed
+ domain
updateInfo {
current
latest
@@ -23,6 +25,7 @@ query GetApp($appId: String!) {
categories
url_suffix
https
+ exposable
form_fields {
type
label
diff --git a/packages/dashboard/src/hooks/useCachedRessources.ts b/packages/dashboard/src/hooks/useCachedRessources.ts
index 55c548c2..e009665e 100644
--- a/packages/dashboard/src/hooks/useCachedRessources.ts
+++ b/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;
@@ -11,18 +12,18 @@ interface IReturnProps {
}
const fetcher: BareFetcher = (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>();
- 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 };
}
diff --git a/packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts b/packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts
index 97b6ebcb..b932cf93 100644
--- a/packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts
+++ b/packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts
@@ -39,4 +39,5 @@ export const colorSchemeForCategory: Record = {
[AppCategoriesEnum.Books]: 'blue',
[AppCategoriesEnum.Music]: 'green',
[AppCategoriesEnum.Finance]: 'orange',
+ [AppCategoriesEnum.Gaming]: 'purple',
};
diff --git a/packages/dashboard/src/modules/Apps/components/AppActions.tsx b/packages/dashboard/src/modules/Apps/components/AppActions.tsx
index af08221a..2e889af8 100644
--- a/packages/dashboard/src/modules/Apps/components/AppActions.tsx
+++ b/packages/dashboard/src/modules/Apps/components/AppActions.tsx
@@ -41,7 +41,7 @@ const ActionButton: React.FC = (props) => {
};
const AppActions: React.FC = ({ 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[] = [];
diff --git a/packages/dashboard/src/modules/Apps/components/InstallForm.tsx b/packages/dashboard/src/modules/Apps/components/InstallForm.tsx
index 7265d376..d250004d 100644
--- a/packages/dashboard/src/modules/Apps/components/InstallForm.tsx
+++ b/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) => void;
initalValues?: Record;
+ 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 = ({ formFields, onSubmit, initalValues }) => {
+const InstallForm: React.FC = ({ formFields, onSubmit, initalValues, exposable }) => {
const renderField = (field: FormField) => {
return (
= ({ formFields, onSubmit, initalValues }) =
);
};
+ const renderExposeForm = (isExposedChecked?: boolean) => {
+ return (
+ <>
+ } />
+ {isExposedChecked && (
+ <>
+ }
+ />
+
+ Make sure this exact domain contains an A record pointing to your IP.
+
+ >
+ )}
+ >
+ );
+ };
+
return (
-
)}
/>
diff --git a/packages/dashboard/src/modules/Apps/components/InstallModal.tsx b/packages/dashboard/src/modules/Apps/components/InstallModal.tsx
index b2ef5269..61b8e12a 100644
--- a/packages/dashboard/src/modules/Apps/components/InstallModal.tsx
+++ b/packages/dashboard/src/modules/Apps/components/InstallModal.tsx
@@ -18,7 +18,7 @@ const InstallModal: React.FC = ({ app, isOpen, onClose, onSubmit }) => {
Install {app.name}
-
+
diff --git a/packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx b/packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx
index 54cc921e..fdd598bc 100644
--- a/packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx
+++ b/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) => void;
}
-const UpdateSettingsModal: React.FC = ({ app, config, isOpen, onClose, onSubmit }) => {
+const UpdateSettingsModal: React.FC = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => {
return (
@@ -19,7 +21,7 @@ const UpdateSettingsModal: React.FC = ({ app, config, isOpen, onClose, o
Update {app.name} config
-
+
diff --git a/packages/dashboard/src/modules/Apps/containers/AppDetails.tsx b/packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
index a37b98be..c1b278f7 100644
--- a/packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
+++ b/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?: Pick;
info: AppInfo;
}
@@ -61,11 +62,12 @@ const AppDetails: React.FC = ({ app, info }) => {
}
};
- const handleInstallSubmit = async (values: Record) => {
+ 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,12 +101,13 @@ const AppDetails: React.FC = ({ app, info }) => {
}
};
- const handleUpdateSettingsSubmit = async (values: Record) => {
+ 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',
});
@@ -133,7 +136,9 @@ const AppDetails: React.FC = ({ app, info }) => {
const { https } = info;
const protocol = https ? 'https' : 'http';
- window.open(`${protocol}://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
+ 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(' ');
@@ -183,7 +188,15 @@ const AppDetails: React.FC = ({ app, info }) => {
-
+
diff --git a/packages/dashboard/src/modules/Auth/components/AuthFormLayout.tsx b/packages/dashboard/src/modules/Auth/components/AuthFormLayout.tsx
index f105e11c..71b6923f 100644
--- a/packages/dashboard/src/modules/Auth/components/AuthFormLayout.tsx
+++ b/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 = ({ children, title, description }) => {
-
+
{title}
diff --git a/packages/dashboard/src/pages/_document.tsx b/packages/dashboard/src/pages/_document.tsx
index e31d4f75..506060f8 100644
--- a/packages/dashboard/src/pages/_document.tsx
+++ b/packages/dashboard/src/pages/_document.tsx
@@ -2,16 +2,18 @@ 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 (
-
-
-
-
-
+ Tipi - Dashboard
+
+
+
+
+
diff --git a/packages/dashboard/src/pages/api/ip.tsx b/packages/dashboard/src/pages/api/ip.tsx
index 9bd4094b..a24882bc 100644
--- a/packages/dashboard/src/pages/api/ip.tsx
+++ b/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 });
}
diff --git a/packages/dashboard/src/state/networkStore.ts b/packages/dashboard/src/state/networkStore.ts
deleted file mode 100644
index 3e8ac9c3..00000000
--- a/packages/dashboard/src/state/networkStore.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import create from 'zustand';
-import api from '../core/api';
-
-type AppsStore = {
- internalIp: string;
- fetchInternalIp: () => void;
-};
-
-export const useNetworkStore = create((set) => ({
- internalIp: '',
- fetchInternalIp: async () => {
- const response = await api.fetch({
- endpoint: '/network/internal-ip',
- method: 'get',
- });
-
- set({ internalIp: response });
- },
-}));
diff --git a/packages/dashboard/src/state/systemStore.ts b/packages/dashboard/src/state/systemStore.ts
index 49c7d49c..42730448 100644
--- a/packages/dashboard/src/state/systemStore.ts
+++ b/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((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 })),
}));
diff --git a/packages/system-api/package.json b/packages/system-api/package.json
index 18998d28..38f3a43c 100644
--- a/packages/system-api/package.json
+++ b/packages/system-api/package.json
@@ -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",
diff --git a/packages/system-api/src/config/config.ts b/packages/system-api/src/config/config.ts
index 369aefb9..c8f1d321 100644
--- a/packages/system-api/src/config/config.ts
+++ b/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,
diff --git a/packages/system-api/src/config/migrations/1662036689477-AppExposedDomain.ts b/packages/system-api/src/config/migrations/1662036689477-AppExposedDomain.ts
new file mode 100644
index 00000000..9d7e9729
--- /dev/null
+++ b/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 {
+ 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 {
+ 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"');
+ }
+}
diff --git a/packages/system-api/src/core/middlewares/sessionMiddleware.ts b/packages/system-api/src/core/middlewares/sessionMiddleware.ts
index 5a9373ff..cd2acc01 100644
--- a/packages/system-api/src/core/middlewares/sessionMiddleware.ts
+++ b/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,
diff --git a/packages/system-api/src/modules/apps/__tests__/apps.factory.ts b/packages/system-api/src/modules/apps/__tests__/apps.factory.ts
index 8223651c..a2360dfe 100644
--- a/packages/system-api/src/modules/apps/__tests__/apps.factory.ts
+++ b/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) {
@@ -57,11 +61,14 @@ const createApp = async (props: IProps) => {
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}`] = '';
@@ -70,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 };
diff --git a/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
index 1105c7be..8d77326d 100644
--- a/packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
+++ b/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();
});
});
diff --git a/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
index 0f320560..1855d7d4 100644
--- a/packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
+++ b/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!');
diff --git a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
index 63cb046e..f41ff1a7 100644
--- a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
+++ b/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
@@ -135,6 +135,22 @@ describe('Install app', () => {
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');
+ });
});
describe('Uninstall app', () => {
@@ -334,6 +350,18 @@ 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`);
+ });
});
describe('Get app config', () => {
diff --git a/packages/system-api/src/modules/apps/app.entity.ts b/packages/system-api/src/modules/apps/app.entity.ts
index b4035b16..cdb719e7 100644
--- a/packages/system-api/src/modules/apps/app.entity.ts
+++ b/packages/system-api/src/modules/apps/app.entity.ts
@@ -55,6 +55,14 @@ class App extends BaseEntity {
@UpdateDateColumn()
updatedAt!: Date;
+ @Field(() => Boolean)
+ @Column({ type: 'boolean', default: false })
+ exposed!: boolean;
+
+ @Field(() => String)
+ @Column({ type: 'varchar', nullable: true })
+ domain?: string;
+
@Field(() => AppInfo, { nullable: true })
info(): AppInfo | null {
return getAppInfo(this.id);
diff --git a/packages/system-api/src/modules/apps/apps.helpers.ts b/packages/system-api/src/modules/apps/apps.helpers.ts
index f57a50a1..edcca4d8 100644
--- a/packages/system-api/src/modules/apps/apps.helpers.ts
+++ b/packages/system-api/src/modules/apps/apps.helpers.ts
@@ -74,19 +74,19 @@ const getEntropy = (name: string, length: number) => {
return hash.digest('hex').substring(0, length);
};
-export const generateEnvFile = (appName: string, form: Record) => {
- 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) =
}
});
- 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 => {
@@ -126,7 +131,7 @@ export const getAvailableApps = async (): Promise => {
return apps;
};
-export const getAppInfo = (id: string): AppInfo => {
+export const getAppInfo = (id: string): AppInfo | null => {
try {
const repoId = config.APPS_REPO_ID;
@@ -134,7 +139,7 @@ export const getAppInfo = (id: string): AppInfo => {
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 +148,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}`);
}
};
diff --git a/packages/system-api/src/modules/apps/apps.resolver.ts b/packages/system-api/src/modules/apps/apps.resolver.ts
index eeff7b4c..c02a7b5e 100644
--- a/packages/system-api/src/modules/apps/apps.resolver.ts
+++ b/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 {
- 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 {
- const { id, form } = input;
+ const { id, form, exposed, domain } = input;
- return AppsService.updateAppConfig(id, form);
+ return AppsService.updateAppConfig(id, form, exposed, domain);
}
@Authorized()
diff --git a/packages/system-api/src/modules/apps/apps.service.ts b/packages/system-api/src/modules/apps/apps.service.ts
index e67b0c6e..2028023c 100644
--- a/packages/system-api/src/modules/apps/apps.service.ts
+++ b/packages/system-api/src/modules/apps/apps.service.ts
@@ -1,3 +1,4 @@
+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';
@@ -15,7 +16,7 @@ const startAllApps = async (): Promise => {
// 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 +41,7 @@ const startApp = async (appName: string): Promise => {
ensureAppFolder(appName);
// Regenerate env file
- generateEnvFile(appName, app.config);
+ generateEnvFile(app);
checkEnvFile(appName);
@@ -59,12 +60,20 @@ const startApp = async (appName: string): Promise => {
return app;
};
-const installApp = async (id: string, form: Record): Promise => {
+const installApp = async (id: string, form: Record, exposed?: boolean, domain?: string): Promise => {
let app = await App.findOne({ where: { id } });
if (app) {
await startApp(id);
} else {
+ 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);
@@ -75,11 +84,16 @@ const installApp = async (id: string, form: Record): Promise => {
return { apps: apps.sort(sortApps), total: apps.length };
};
-const updateAppConfig = async (id: string, form: Record): Promise => {
+const updateAppConfig = async (id: string, form: Record, exposed?: boolean, domain?: string): Promise => {
+ 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`);
+ }
+
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 +215,7 @@ const getApp = async (id: string): Promise => {
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;
diff --git a/packages/system-api/src/modules/apps/apps.types.ts b/packages/system-api/src/modules/apps/apps.types.ts
index 90b844d4..b6453446 100644
--- a/packages/system-api/src/modules/apps/apps.types.ts
+++ b/packages/system-api/src/modules/apps/apps.types.ts
@@ -125,6 +125,9 @@ class AppInfo {
@Field(() => Boolean, { nullable: true })
https?: boolean;
+
+ @Field(() => Boolean, { nullable: true })
+ exposable?: boolean;
}
@ObjectType()
@@ -143,6 +146,12 @@ class AppInputType {
@Field(() => GraphQLJSONObject)
form!: Record;
+
+ @Field(() => Boolean)
+ exposed!: boolean;
+
+ @Field(() => String)
+ domain!: string;
}
export { ListAppsResonse, AppInfo, AppInputType };
diff --git a/packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts b/packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts
new file mode 100644
index 00000000..f593023a
--- /dev/null
+++ b/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();
+ });
+});
diff --git a/packages/system-api/src/modules/auth/auth.service.ts b/packages/system-api/src/modules/auth/auth.service.ts
index 65475eb5..f69208dd 100644
--- a/packages/system-api/src/modules/auth/auth.service.ts
+++ b/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 => {
const register = async (input: UsernamePasswordInput): Promise => {
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 };
};
diff --git a/packages/system-api/src/server.ts b/packages/system-api/src/server.ts
index 9fadf17a..525fcbd4 100644
--- a/packages/system-api/src/server.ts
+++ b/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);
- }
-
- const message = "The CORS policy for this origin doesn't allow access from the particular origin.";
- return callback(new Error(message), false);
- },
+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);
+
+ 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 {
diff --git a/packages/system-api/src/test/mutations/index.ts b/packages/system-api/src/test/mutations/index.ts
index bc48cb1f..f13af6c4 100644
--- a/packages/system-api/src/test/mutations/index.ts
+++ b/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);
diff --git a/packages/system-api/src/test/mutations/login.graphql b/packages/system-api/src/test/mutations/login.graphql
new file mode 100644
index 00000000..126c2e4f
--- /dev/null
+++ b/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
+ }
+ }
+}
diff --git a/packages/system-api/src/test/mutations/register.graphql b/packages/system-api/src/test/mutations/register.graphql
new file mode 100644
index 00000000..3e32ef0e
--- /dev/null
+++ b/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
+ }
+ }
+}
diff --git a/packages/system-api/src/test/queries/index.ts b/packages/system-api/src/test/queries/index.ts
index 77e12685..1ffa6b2e 100644
--- a/packages/system-api/src/test/queries/index.ts
+++ b/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);
diff --git a/packages/system-api/src/test/queries/isConfigured.graphql b/packages/system-api/src/test/queries/isConfigured.graphql
new file mode 100644
index 00000000..2d994b50
--- /dev/null
+++ b/packages/system-api/src/test/queries/isConfigured.graphql
@@ -0,0 +1,3 @@
+query IsConfigured {
+ isConfigured
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ed4fa884..22df5140 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,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
@@ -195,7 +195,7 @@ importers:
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
@@ -4629,14 +4629,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
@@ -9942,8 +9942,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:
diff --git a/scripts/start.sh b/scripts/start.sh
index 8d76c200..739ab268 100755
--- a/scripts/start.sh
+++ b/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}/g" "${template}"
sed -i "s//${ARCHITECTURE}/g" "${template}"
sed -i "s//${NGINX_PORT}/g" "${template}"
+ sed -i "s//${NGINX_PORT_SSL}/g" "${template}"
sed -i "s//${PROXY_PORT}/g" "${template}"
sed -i "s//${POSTGRES_PASSWORD}/g" "${template}"
sed -i "s//${REPO_ID}/g" "${template}"
sed -i "s//${APPS_REPOSITORY_ESCAPED}/g" "${template}"
+ sed -i "s//${DOMAIN}/g" "${template}"
done
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
diff --git a/templates/env-sample b/templates/env-sample
index 6124eeca..c7b637f7 100644
--- a/templates/env-sample
+++ b/templates/env-sample
@@ -11,5 +11,7 @@ TIPI_VERSION=
JWT_SECRET=
ROOT_FOLDER_HOST=
NGINX_PORT=
+NGINX_PORT=
PROXY_PORT=
-POSTGRES_PASSWORD=
\ No newline at end of file
+POSTGRES_PASSWORD=
+DOMAIN=
\ No newline at end of file
diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml
deleted file mode 100644
index 59aab670..00000000
--- a/traefik/dynamic.yml
+++ /dev/null
@@ -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"
\ No newline at end of file
diff --git a/traefik/letsencrypt/.gitkeep b/traefik/letsencrypt/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/traefik/letsencrypt/acme.json b/traefik/letsencrypt/acme.json
deleted file mode 100644
index cc94e185..00000000
--- a/traefik/letsencrypt/acme.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "myresolver": {
- "Account": {
- "Email": "",
- "Registration": {
- "body": {
- "status": "valid"
- },
- "uri": "https://acme-v02.api.letsencrypt.org/acme/acct/476208700"
- },
- "PrivateKey": "MIIJKAIBAAKCAgEAn42EtgYsqNgwXbt6e/Ix1er5hMUjiWOql0Cf6wQ09FRTpBTc4iKxxjINH03k8tJe501rAYliPfrBtt6TwQ4FNlPRFWULS8pTQ9Fq6Ra1QvgGgQMrwroQDd5fuXdUbWMMd0HB1SgwqY+m6xNfVZt4Wk0O0J2dmHHFeYHleztW8Bb34Sd0EjyLhIhG6FLC4hCb0s64r7TsdDz8nL0SbCZmEA1zXBPycb7QAzCi1Ruhdr2ts7+LcvbP7L6nXcAgGEMNSFp2EEOug//hwDJ9WoHdmaEUQEssghOpahK2aIY7jP4ge5vnz07/SNJaJJVVETNQm7DMjx4bANJALeECttaBt/5u4TSXWyIJ9kSKJTPLlRbH3JeC3Yo51PBGZ49Wz/IRK5zWD0IHWAdrruuYscpwiNfMuGk0C7S24GmVLTzgvcqVKtpnR3xcLbnoatln0djC7kWpbhHfPE9x5FJbkE5PfGLbG6iA88YFYtKVuzcM2o7SwdpF+vArArEPB5uH3BLYo85rv7sO182kXnOJ95ii0iQ1yj//FH1t8BOiZIvkn25wbMumv9k3iFMS+Gm4U9SRjbyR8sPgZS7LHLRPKhh0wqpBUb5X/9PHmEPaF10wS71UTJv7sRE9k82K1Oz9/NgMcHhOiU2gP5orgBJKF6TsIyrwRJiSrj3oL52OkWAKnOsCAwEAAQKCAgBElgUSah0Qh75izJCeb0JU/qk8FbJtANb4JeOYlzpcPVOnGQDKhLd+x000w7tDVoNNUs5I3tHIat6SyaMiPfCnpegfFkyAy/x3DrKyd/x7STsigkZxcqIsFAd6Jn24d/eH3FCCXMBuYz4Rl0ZH+okF6FISA28XdPC6hsgq7Rs2Iel0dA1FOZmP4zT38Xusyg7x08M4ZMGwRfchOXWN4APHqsCIOFrj4m5wsJuOmE4USP0+Y3yCcu52io5PkqM5SrmO/LP70dxXCcv1Xr7cBS9JNyEJckczs1gELP8Ud39p4GP+PsqrJv4+Q45UY40p07E2/A0zCHH7LGZCUpNkHVmtHISyrrebgvlyG0Tk/jC5v4X9gGpVz5n1h7IhrhP3NwNSZ4OgO2Kptay8M/1DFVGMS3Gp2kLqLAYhcvrikgmd22Kj+t/ZCwqGV3zAvNhe15Q1Di2VNLCtOHTazqMbt9DhP/9NfcyjhpA1r2R/afM3O4iVe9f6LwU46Y4WHICymuXzTrMHJ/8z2Jh21d2ZAB/kxqlK4755IWRUC9RRE1Vle7Hz4vyxy/L6kyFujKQ9sULnQNWOE1xAuFxGtReojPATbc1HegIRqewGAQdqbtqVFTX5s4FBMshktOKgYyQHNxyHkOerc3c0Ey2nm70uAFczhpnNS1ajkOvAnZWEo5RSIQKCAQEAyXtrYv9sAK2B0YopW13n+XUllnHjaqts2oBNvL76X0ahK6xlQoW8pLWEoU150VXwarSWTrqOhMTECjuwuDjAWk5tbs9voYbC0TqRxeujlD0TnfnU+0a27nz1E9j5b00SEP2ntn++/xMe12cVw+7GLm4PGUqSwSs33rDSkoZBBMx65TOalaS43b2/qWTRf/CD46a+Awc2DrscMTAOJBkpoz0ziFeVFrjCIxTGZ1Ng21Ti+jzxPZLk63aYFsDVtV4QxSYDSeaYEAPT4IJzhANxj3ihbv//IxcYQef+FHQwkxrFh2d1ctuHe+rnbJBssuvRCLUuXr7i5dngvI3o7d47lQKCAQEAyrmshysDyfi0q355ErcZnItDZSusNiq6zGPW3hBs+QP1PlRgHHxc5mpA0clRnIT4xqy2cN382gQW+5txtuM9CepZRHKfmRVzUsmdqzEyhVcaya8O8dSo6rvWmtEnEvIMiJmvul9olJtIPM6gFZHfwXsdplyn+9OOo+yV/Q79aqhT56vlp6DmIHWL3g9ZqKnUEBaA1WZlSQdS9MyU7WyoIEKHsZWQ9wLgcrEV7gOWauRot4eA4VVsl9SeC/XMQgGSw40HOA3/Amng/AcrjTwVLI7f+eMeB4HB52KHHkleGxwPy0oM4jIYT2EaQI+regqby4dGA2otMnpkv5RIsetWfwKCAQEAqbS0GfmsXdHHU9h8x0GMn9ilZVfeRr3HfS+uyrlNqCyUmnWmAOcmotFlunvIjKNHUolzRTLr0jbuLPRkAHeExUvj7v74NuSMebFMkZnN+ZGMUXbahx/j+3Ly9tm+F5qiCf+tYRGurajMRIDGm3cmJHt9aj8e52fgskjbxKEiaMlXBnF11m+dauBlbGfH8myCmqCa0XAkfznpICEq+ArdwGpPWpryr+XFV8kq6GMZZQTV/hKQ2907xnzo09lu6Eon8/b1tCxvjqW6tBMM+3fvEfp4d0dW/pZ4TyL6Jv5K380f7dId4jW4o46TiSUI+ZeZRS1etl0wPoxLOGaLeLfEFQKCAQAp0w7OQEii1cXoj8pI2y/UhULdT5pS/pPVcU+2NutUoMVrG5tMpTfBbfB7l65XvXNaAe4N8S6miCt5s4NNeSpxrkDGh2N4AN3vGZuG4zqKGgNz0sMhj39eFmzbOgV2uittz09bAy4fYr4PlY2fhZ4FW/ItDXa21Nnb5ga30+zioWHWLTfPUrnHvpihsscLriYLP6lK3bpNy84IpWCgb0dsiG1YbQQggh5uayycE29oFEGqg7FKTAaAeKQ20XpXr91orOLtZK3VAKUjOhN5KwkvTTbWZk4evF2V8FTyIa7hpvN3PIrV7AHp9p2k7j8xiZjE7964+6HhhTDd+ajZ1DTfAoIBAHnnLVPmFsQktM5dNcESNcJPoeZTggO+vQsHYPn6dxMxXRnzR3/vC7Be/WECq0u4k8Kt0SET5fEw3cbWIeDUNd8q+oETtePO7rPOC6DUYtEkXMoQr7lvsmfVq0qbTXsqxW1MCCB8HXUHD/uZHjueUHLBZETlB6s0r2IlRsf+8CBt9CF8hA/qZHqVqkePfP/uAe69wICDcVxdAJ/C8juMv0BY6g7+BtCAqgSqKYy4iQDP1EswwUV7z5V8HjuTmkS2Bhs0fziP1UoLq3fk86ZefB3wcYkBv8kXHPgDbpo/PaIyGeGvE8/hT7nJh50nAgwFwp8f7tcmafiNKudZVZq3Sso=",
- "KeyType": "4096"
- },
- "Certificates": null
- }
-}
\ No newline at end of file
diff --git a/traefik/ssl/.gitkeep b/traefik/ssl/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/traefik/traefik.yml b/traefik/traefik.yml
index d1c70517..7f49468c 100644
--- a/traefik/traefik.yml
+++ b/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