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 }) => {
- Tipi Logo + Tipi Logo 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 ( - > + initialValues={initalValues} onSubmit={onSubmit} validateOnBlur={true} validate={(values) => validateAppConfig(values, formFields)} - render={({ handleSubmit, validating, submitting }) => ( + render={({ handleSubmit, validating, submitting, values }) => (
- {formFields.filter(typeFilter).map(renderField)} - + <> + {formFields.filter(typeFilter).map(renderField)} + {exposable && renderExposeForm(values.exposed)} + +
)} /> 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