Merge pull request #167 from meienberger/feature/link-domain
Feature/link domain
This commit is contained in:
commit
c6338d2deb
61 changed files with 853 additions and 219 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -11,6 +11,7 @@ repos/*
|
|||
!repos/.gitkeep
|
||||
apps/*
|
||||
!apps/.gitkeep
|
||||
traefik/shared
|
||||
|
||||
scripts/pacapt
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
|||
import { useSytemStore } from '../../state/systemStore';
|
||||
|
||||
const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
|
||||
const { internalIp } = useSytemStore();
|
||||
const logoUrl = `http://${internalIp}:3001/apps/${id}/metadata/logo.jpg`;
|
||||
const { baseUrl } = useSytemStore();
|
||||
const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
|
||||
|
||||
return (
|
||||
<div aria-label={alt} className={`drop-shadow ${className}`} style={{ width: size, height: size }}>
|
||||
|
|
23
packages/dashboard/src/components/Form/FormSwitch.tsx
Normal file
23
packages/dashboard/src/components/Form/FormSwitch.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { Input, Switch } from '@chakra-ui/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface IProps {
|
||||
placeholder?: string;
|
||||
type?: Parameters<typeof Input>[0]['type'];
|
||||
label?: string;
|
||||
className?: string;
|
||||
size?: Parameters<typeof Input>[0]['size'];
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
|
||||
return (
|
||||
<div className={clsx('transition-all', className)}>
|
||||
{label && <label className="mr-2">{label}</label>}
|
||||
<Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormSwitch;
|
|
@ -1,12 +1,13 @@
|
|||
import validator from 'validator';
|
||||
import { FieldTypesEnum, FormField } from '../../generated/graphql';
|
||||
import { IFormValues } from '../../modules/Apps/components/InstallForm';
|
||||
|
||||
const validateField = (field: FormField, value: string): string | undefined => {
|
||||
const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
|
||||
if (field.required && !value) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -59,12 +60,24 @@ const validateField = (field: FormField, value: string): string | undefined => {
|
|||
}
|
||||
};
|
||||
|
||||
export const validateAppConfig = (values: Record<string, string>, fields: FormField[]) => {
|
||||
const validateDomain = (domain?: string): string | undefined => {
|
||||
if (!validator.isFQDN(domain || '')) {
|
||||
return `${domain} must be a valid domain`;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateAppConfig = (values: IFormValues, fields: FormField[]) => {
|
||||
const { exposed, domain, ...config } = values;
|
||||
|
||||
const errors: any = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
errors[field.env_variable] = validateField(field, values[field.env_variable]);
|
||||
errors[field.env_variable] = validateField(field, config[field.env_variable]);
|
||||
});
|
||||
|
||||
if (exposed) {
|
||||
errors.domain = validateDomain(domain);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import Link from 'next/link';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { FiMenu } from 'react-icons/fi';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
|
||||
interface IProps {
|
||||
onClickMenu: () => void;
|
||||
|
@ -16,7 +17,7 @@ const Header: React.FC<IProps> = ({ onClickMenu }) => {
|
|||
</div>
|
||||
<Flex justifyContent="center" flex="1">
|
||||
<Link href="/" passHref>
|
||||
<img src="/tipi.png" alt="Tipi Logo" width={30} height={30} />
|
||||
<img src={getUrl('tipi.png')} alt="Tipi Logo" width={30} height={30} />
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
|
|
@ -9,6 +9,7 @@ import clsx from 'clsx';
|
|||
import { useRouter } from 'next/router';
|
||||
import { IconType } from 'react-icons';
|
||||
import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
|
||||
const SideMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
@ -45,7 +46,7 @@ const SideMenu: React.FC = () => {
|
|||
|
||||
return (
|
||||
<Box className="flex-1 flex flex-col p-0 md:p-4">
|
||||
<img className="self-center mb-5 logo mt-0 md:mt-5" src="/tipi.png" width={512} height={512} />
|
||||
<img className="self-center mb-5 logo mt-0 md:mt-5" src={getUrl('tipi.png')} width={512} height={512} />
|
||||
<List spacing={3} className="pt-5">
|
||||
{renderMenuItem('Dashboard', '', AiOutlineDashboard)}
|
||||
{renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
|
||||
|
|
|
@ -12,7 +12,7 @@ const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
|
|||
const { endpoint, method = 'GET', params, data } = fetchParams;
|
||||
|
||||
const { getState } = useSytemStore;
|
||||
const BASE_URL = `http://${getState().internalIp}:3001`;
|
||||
const BASE_URL = getState().baseUrl;
|
||||
|
||||
const response = await axios.request<T & { error?: string }>({
|
||||
method,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { ApolloClient, from, InMemoryCache } from '@apollo/client';
|
||||
import links from './links';
|
||||
|
||||
export const createApolloClient = async (ip: string): Promise<ApolloClient<any>> => {
|
||||
const additiveLink = from([links.errorLink, links.httpLink(ip)]);
|
||||
export const createApolloClient = async (url: string): Promise<ApolloClient<any>> => {
|
||||
const additiveLink = from([links.errorLink, links.httpLink(url)]);
|
||||
|
||||
return new ApolloClient({
|
||||
link: additiveLink,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,10 +3,9 @@ import axios from 'axios';
|
|||
import { useSytemStore } from '../state/systemStore';
|
||||
|
||||
const fetcher: BareFetcher<any> = (url: string) => {
|
||||
const { getState } = useSytemStore;
|
||||
const BASE_URL = `http://${getState().internalIp}:3001`;
|
||||
const { baseUrl } = useSytemStore.getState();
|
||||
|
||||
return axios.get(url, { baseURL: BASE_URL, withCredentials: true }).then((res) => res.data);
|
||||
return axios.get(url, { baseURL: baseUrl, withCredentials: true }).then((res) => res.data);
|
||||
};
|
||||
|
||||
export default fetcher;
|
||||
|
|
10
packages/dashboard/src/core/helpers/url-helpers.ts
Normal file
10
packages/dashboard/src/core/helpers/url-helpers.ts
Normal file
|
@ -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}`;
|
||||
};
|
|
@ -23,6 +23,8 @@ export type App = {
|
|||
__typename?: 'App';
|
||||
config: Scalars['JSONObject'];
|
||||
createdAt: Scalars['DateTime'];
|
||||
domain: Scalars['String'];
|
||||
exposed: Scalars['Boolean'];
|
||||
id: Scalars['String'];
|
||||
info?: Maybe<AppInfo>;
|
||||
lastOpened: Scalars['DateTime'];
|
||||
|
@ -40,6 +42,7 @@ export enum AppCategoriesEnum {
|
|||
Development = 'DEVELOPMENT',
|
||||
Featured = 'FEATURED',
|
||||
Finance = 'FINANCE',
|
||||
Gaming = 'GAMING',
|
||||
Media = 'MEDIA',
|
||||
Music = 'MUSIC',
|
||||
Network = 'NETWORK',
|
||||
|
@ -55,6 +58,7 @@ export type AppInfo = {
|
|||
available: Scalars['Boolean'];
|
||||
categories: Array<AppCategoriesEnum>;
|
||||
description: Scalars['String'];
|
||||
exposable?: Maybe<Scalars['Boolean']>;
|
||||
form_fields: Array<FormField>;
|
||||
https?: Maybe<Scalars['Boolean']>;
|
||||
id: Scalars['String'];
|
||||
|
@ -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<AppCategoriesEnum>;
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -4,6 +4,7 @@ import axios from 'axios';
|
|||
import useSWR, { BareFetcher } from 'swr';
|
||||
import { createApolloClient } from '../core/apollo/client';
|
||||
import { useSytemStore } from '../state/systemStore';
|
||||
import { getUrl } from '../core/helpers/url-helpers';
|
||||
|
||||
interface IReturnProps {
|
||||
client?: ApolloClient<unknown>;
|
||||
|
@ -11,18 +12,18 @@ interface IReturnProps {
|
|||
}
|
||||
|
||||
const fetcher: BareFetcher<any> = (url: string) => {
|
||||
return axios.get(url).then((res) => res.data);
|
||||
return axios.get(getUrl(url)).then((res) => res.data);
|
||||
};
|
||||
|
||||
export default function useCachedResources(): IReturnProps {
|
||||
const { data } = useSWR('/api/ip', fetcher);
|
||||
const { internalIp, setInternalIp } = useSytemStore();
|
||||
const { data } = useSWR<{ ip: string; domain: string }>('api/ip', fetcher);
|
||||
const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSytemStore();
|
||||
const [isLoadingComplete, setLoadingComplete] = useState(false);
|
||||
const [client, setClient] = useState<ApolloClient<unknown>>();
|
||||
|
||||
async function loadResourcesAndDataAsync(ip: string) {
|
||||
async function loadResourcesAndDataAsync(url: string) {
|
||||
try {
|
||||
const restoredClient = await createApolloClient(ip);
|
||||
const restoredClient = await createApolloClient(url);
|
||||
|
||||
setClient(restoredClient);
|
||||
} catch (error) {
|
||||
|
@ -34,16 +35,24 @@ export default function useCachedResources(): IReturnProps {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.ip && !internalIp) {
|
||||
setInternalIp(data.ip);
|
||||
const { ip, domain } = data || {};
|
||||
if (ip && !baseUrl) {
|
||||
setInternalIp(ip);
|
||||
setDomain(domain);
|
||||
|
||||
if (!domain || domain === 'tipi.localhost') {
|
||||
setBaseUrl(`http://${ip}/api`);
|
||||
} else {
|
||||
setBaseUrl(`https://${domain}/api`);
|
||||
}
|
||||
}
|
||||
}, [data?.ip, internalIp, setInternalIp]);
|
||||
}, [baseUrl, setBaseUrl, data, setInternalIp, setDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalIp) {
|
||||
loadResourcesAndDataAsync(internalIp);
|
||||
if (baseUrl) {
|
||||
loadResourcesAndDataAsync(baseUrl);
|
||||
}
|
||||
}, [internalIp]);
|
||||
}, [baseUrl]);
|
||||
|
||||
return { client, isLoadingComplete };
|
||||
}
|
||||
|
|
|
@ -39,4 +39,5 @@ export const colorSchemeForCategory: Record<AppCategoriesEnum, string> = {
|
|||
[AppCategoriesEnum.Books]: 'blue',
|
||||
[AppCategoriesEnum.Music]: 'green',
|
||||
[AppCategoriesEnum.Finance]: 'orange',
|
||||
[AppCategoriesEnum.Gaming]: 'purple',
|
||||
};
|
||||
|
|
|
@ -41,7 +41,7 @@ const ActionButton: React.FC<BtnProps> = (props) => {
|
|||
};
|
||||
|
||||
const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
|
||||
const hasSettings = Object.keys(app.form_fields).length > 0;
|
||||
const hasSettings = Object.keys(app.form_fields).length > 0 || app.exposable;
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Button } from '@chakra-ui/react';
|
|||
import React from 'react';
|
||||
import { Form, Field } from 'react-final-form';
|
||||
import FormInput from '../../../components/Form/FormInput';
|
||||
import FormSwitch from '../../../components/Form/FormSwitch';
|
||||
import { validateAppConfig } from '../../../components/Form/validators';
|
||||
import { AppInfo, FormField } from '../../../generated/graphql';
|
||||
|
||||
|
@ -9,12 +10,19 @@ interface IProps {
|
|||
formFields: AppInfo['form_fields'];
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
initalValues?: Record<string, string>;
|
||||
exposable?: boolean | null;
|
||||
}
|
||||
|
||||
export type IFormValues = {
|
||||
exposed?: boolean;
|
||||
domain?: string;
|
||||
[key: string]: string | boolean | undefined;
|
||||
};
|
||||
|
||||
const hiddenTypes = ['random'];
|
||||
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
|
||||
|
||||
const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) => {
|
||||
const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exposable }) => {
|
||||
const renderField = (field: FormField) => {
|
||||
return (
|
||||
<Field
|
||||
|
@ -25,18 +33,41 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) =
|
|||
);
|
||||
};
|
||||
|
||||
const renderExposeForm = (isExposedChecked?: boolean) => {
|
||||
return (
|
||||
<>
|
||||
<Field key="exposed" name="exposed" type="checkbox" render={({ input }) => <FormSwitch className="mb-3" label="Expose app ?" {...input} />} />
|
||||
{isExposedChecked && (
|
||||
<>
|
||||
<Field
|
||||
key="domain"
|
||||
name="domain"
|
||||
render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label="Domain name" {...input} />}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form<Record<string, string>>
|
||||
<Form<IFormValues>
|
||||
initialValues={initalValues}
|
||||
onSubmit={onSubmit}
|
||||
validateOnBlur={true}
|
||||
validate={(values) => validateAppConfig(values, formFields)}
|
||||
render={({ handleSubmit, validating, submitting }) => (
|
||||
render={({ handleSubmit, validating, submitting, values }) => (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit}>
|
||||
{formFields.filter(typeFilter).map(renderField)}
|
||||
<Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
|
||||
{initalValues ? 'Update' : 'Install'}
|
||||
</Button>
|
||||
<>
|
||||
{formFields.filter(typeFilter).map(renderField)}
|
||||
{exposable && renderExposeForm(values.exposed)}
|
||||
<Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
|
||||
{initalValues ? 'Update' : 'Install'}
|
||||
</Button>
|
||||
</>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -18,7 +18,7 @@ const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
|
|||
<ModalHeader>Install {app.name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} />
|
||||
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
|
@ -7,11 +7,13 @@ interface IProps {
|
|||
app: AppInfo;
|
||||
config: App['config'];
|
||||
isOpen: boolean;
|
||||
exposed?: boolean;
|
||||
domain?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit }) => {
|
||||
const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
|
@ -19,7 +21,7 @@ const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, o
|
|||
<ModalHeader>Update {app.name} config</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={config} />
|
||||
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
|
@ -23,9 +23,10 @@ import {
|
|||
useUpdateAppMutation,
|
||||
} from '../../../generated/graphql';
|
||||
import UpdateModal from '../components/UpdateModal';
|
||||
import { IFormValues } from '../components/InstallForm';
|
||||
|
||||
interface IProps {
|
||||
app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo'>;
|
||||
app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo' | 'exposed' | 'domain'>;
|
||||
info: AppInfo;
|
||||
}
|
||||
|
||||
|
@ -61,11 +62,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleInstallSubmit = async (values: Record<string, any>) => {
|
||||
const handleInstallSubmit = async (values: IFormValues) => {
|
||||
installDisclosure.onClose();
|
||||
const { exposed, domain, ...form } = values;
|
||||
try {
|
||||
await install({
|
||||
variables: { input: { form: values, id: info.id } },
|
||||
variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
|
||||
optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -99,12 +101,13 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUpdateSettingsSubmit = async (values: Record<string, any>) => {
|
||||
const handleUpdateSettingsSubmit = async (values: IFormValues) => {
|
||||
try {
|
||||
await updateConfig({ variables: { input: { form: values, id: info.id } } });
|
||||
const { exposed, domain, ...form } = values;
|
||||
await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'App config updated successfully',
|
||||
description: 'App config updated successfully. Restart the app to apply the changes.',
|
||||
position: 'top',
|
||||
status: 'success',
|
||||
});
|
||||
|
@ -133,7 +136,9 @@ const AppDetails: React.FC<IProps> = ({ 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<IProps> = ({ app, info }) => {
|
|||
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
|
||||
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
|
||||
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
|
||||
<UpdateSettingsModal onSubmit={handleUpdateSettingsSubmit} isOpen={updateSettingsDisclosure.isOpen} onClose={updateSettingsDisclosure.onClose} app={info} config={app?.config} />
|
||||
<UpdateSettingsModal
|
||||
onSubmit={handleUpdateSettingsSubmit}
|
||||
isOpen={updateSettingsDisclosure.isOpen}
|
||||
onClose={updateSettingsDisclosure.onClose}
|
||||
app={info}
|
||||
config={app?.config}
|
||||
exposed={app?.exposed}
|
||||
domain={app?.domain}
|
||||
/>
|
||||
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
|
||||
</div>
|
||||
</SlideFade>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { getUrl } from '../../../core/helpers/url-helpers';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
|
@ -12,7 +13,7 @@ const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
|
|||
<Container maxW="1250px">
|
||||
<Flex flex={1} height="100vh" overflowY="hidden">
|
||||
<SlideFade in className="flex flex-1 flex-col justify-center items-center" offsetY="20px">
|
||||
<img className="self-center mb-5 logo" src="/tipi.png" width={512} height={512} />
|
||||
<img className="self-center mb-5 logo" src={getUrl('tipi.png')} width={512} height={512} />
|
||||
<Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
|
||||
{title}
|
||||
</Text>
|
||||
|
|
|
@ -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 (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<title>Tipi - Dashboard</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={getUrl('apple-touch-icon.png')} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={getUrl('favicon-32x32.png')} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={getUrl('favicon-16x16.png')} />
|
||||
<link rel="manifest" href={getUrl('site.webmanifest')} />
|
||||
<link rel="mask-icon" href={getUrl('safari-pinned-tab.svg')} color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</Head>
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import create from 'zustand';
|
||||
import api from '../core/api';
|
||||
|
||||
type AppsStore = {
|
||||
internalIp: string;
|
||||
fetchInternalIp: () => void;
|
||||
};
|
||||
|
||||
export const useNetworkStore = create<AppsStore>((set) => ({
|
||||
internalIp: '',
|
||||
fetchInternalIp: async () => {
|
||||
const response = await api.fetch<string>({
|
||||
endpoint: '/network/internal-ip',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
set({ internalIp: response });
|
||||
},
|
||||
}));
|
|
@ -1,11 +1,19 @@
|
|||
import create from 'zustand';
|
||||
|
||||
type Store = {
|
||||
baseUrl: string;
|
||||
internalIp: string;
|
||||
setInternalIp: (internalIp: string) => void;
|
||||
domain: string;
|
||||
setDomain: (domain?: string) => void;
|
||||
setBaseUrl: (url: string) => void;
|
||||
setInternalIp: (ip: string) => void;
|
||||
};
|
||||
|
||||
export const useSytemStore = create<Store>((set) => ({
|
||||
baseUrl: '',
|
||||
internalIp: '',
|
||||
setInternalIp: (internalIp: string) => set((state) => ({ ...state, internalIp })),
|
||||
domain: '',
|
||||
setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
|
||||
setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
|
||||
setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
|
||||
}));
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AppExposedDomain1662036689477 implements MigrationInterface {
|
||||
name = 'AppExposedDomain1662036689477';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "app" ADD "exposed" boolean DEFAULT false');
|
||||
// populate all apps with exposed to false
|
||||
await queryRunner.query('UPDATE "app" SET "exposed" = false');
|
||||
// add NOT NULL constraint
|
||||
await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "exposed" SET NOT NULL');
|
||||
|
||||
await queryRunner.query('ALTER TABLE "app" ADD "domain" character varying');
|
||||
await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "version" SET DEFAULT \'1\'');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "app" ALTER COLUMN "version" SET DEFAULT \'0\'');
|
||||
await queryRunner.query('ALTER TABLE "app" DROP COLUMN "domain"');
|
||||
await queryRunner.query('ALTER TABLE "app" DROP COLUMN "exposed"');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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!');
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -74,19 +74,19 @@ const getEntropy = (name: string, length: number) => {
|
|||
return hash.digest('hex').substring(0, length);
|
||||
};
|
||||
|
||||
export const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
|
||||
export const generateEnvFile = (app: App) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
|
||||
|
||||
if (!configFile) {
|
||||
throw new Error(`App ${appName} not found`);
|
||||
throw new Error(`App ${app.id} not found`);
|
||||
}
|
||||
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
const envMap = getEnvMap(appName);
|
||||
const envMap = getEnvMap(app.id);
|
||||
|
||||
configFile.form_fields?.forEach((field) => {
|
||||
const formValue = form[field.env_variable];
|
||||
const formValue = app.config[field.env_variable];
|
||||
const envVar = field.env_variable;
|
||||
|
||||
if (formValue) {
|
||||
|
@ -105,7 +105,12 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
|
|||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
if (app.exposed && app.domain) {
|
||||
envFile += 'APP_EXPOSED=true\n';
|
||||
envFile += `APP_DOMAIN=${app.domain}\n`;
|
||||
}
|
||||
|
||||
writeFile(`/app-data/${app.id}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getAvailableApps = async (): Promise<string[]> => {
|
||||
|
@ -126,7 +131,7 @@ export const getAvailableApps = async (): Promise<string[]> => {
|
|||
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}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -24,9 +24,9 @@ export default class AppsResolver {
|
|||
@Authorized()
|
||||
@Mutation(() => App)
|
||||
async installApp(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
|
||||
const { id, form } = input;
|
||||
const { id, form, exposed, domain } = input;
|
||||
|
||||
return AppsService.installApp(id, form);
|
||||
return AppsService.installApp(id, form, exposed, domain);
|
||||
}
|
||||
|
||||
@Authorized()
|
||||
|
@ -50,9 +50,9 @@ export default class AppsResolver {
|
|||
@Authorized()
|
||||
@Mutation(() => App)
|
||||
async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
|
||||
const { id, form } = input;
|
||||
const { id, form, exposed, domain } = input;
|
||||
|
||||
return AppsService.updateAppConfig(id, form);
|
||||
return AppsService.updateAppConfig(id, form, exposed, domain);
|
||||
}
|
||||
|
||||
@Authorized()
|
||||
|
|
|
@ -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<void> => {
|
|||
// Regenerate env file
|
||||
try {
|
||||
ensureAppFolder(app.id);
|
||||
generateEnvFile(app.id, app.config);
|
||||
generateEnvFile(app);
|
||||
checkEnvFile(app.id);
|
||||
|
||||
await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
|
||||
|
@ -40,7 +41,7 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
ensureAppFolder(appName);
|
||||
|
||||
// Regenerate env file
|
||||
generateEnvFile(appName, app.config);
|
||||
generateEnvFile(app);
|
||||
|
||||
checkEnvFile(appName);
|
||||
|
||||
|
@ -59,12 +60,20 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
const installApp = async (id: string, form: Record<string, string>): Promise<App> => {
|
||||
const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
if (app) {
|
||||
await startApp(id);
|
||||
} else {
|
||||
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<string, string>): Promise<App
|
|||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0) }).save();
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
}
|
||||
|
||||
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0), exposed: exposed || false, domain }).save();
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(app);
|
||||
|
||||
// Run script
|
||||
try {
|
||||
|
@ -116,15 +130,31 @@ const listApps = async (): Promise<ListAppsResonse> => {
|
|||
return { apps: apps.sort(sortApps), total: apps.length };
|
||||
};
|
||||
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
if (exposed && !domain) {
|
||||
throw new Error('Domain is required if app is exposed');
|
||||
}
|
||||
|
||||
if (domain && !validator.isFQDN(domain)) {
|
||||
throw new Error(`Domain ${domain} is not valid`);
|
||||
}
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
}
|
||||
|
||||
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<App> => {
|
|||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
app = { id, status: AppStatusEnum.MISSING, config: {} } as App;
|
||||
app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as App;
|
||||
}
|
||||
|
||||
return app;
|
||||
|
|
|
@ -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<string, string>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
exposed!: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
domain!: string;
|
||||
}
|
||||
|
||||
export { ListAppsResonse, AppInfo, AppInputType };
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -1,4 +1,5 @@
|
|||
import * as argon2 from 'argon2';
|
||||
import validator from 'validator';
|
||||
import { UsernamePasswordInput, UserResponse } from './auth.types';
|
||||
import User from './user.entity';
|
||||
|
||||
|
@ -22,19 +23,24 @@ const login = async (input: UsernamePasswordInput): Promise<UserResponse> => {
|
|||
|
||||
const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
|
||||
const { password, username } = input;
|
||||
const email = username.trim().toLowerCase();
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error('Missing email or password');
|
||||
}
|
||||
|
||||
const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
|
||||
if (username.length < 3 || !validator.isEmail(email)) {
|
||||
throw new Error('Invalid username');
|
||||
}
|
||||
|
||||
const user = await User.findOne({ where: { username: email } });
|
||||
|
||||
if (user) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const hash = await argon2.hash(password);
|
||||
const newUser = await User.create({ username: username.trim().toLowerCase(), password: hash }).save();
|
||||
const newUser = await User.create({ username: email, password: hash }).save();
|
||||
|
||||
return { user: newUser };
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -8,6 +8,8 @@ import * as stopApp from './stopApp.graphql';
|
|||
import * as uninstallApp from './uninstallApp.graphql';
|
||||
import * as updateAppConfig from './updateAppConfig.graphql';
|
||||
import * as updateApp from './updateApp.graphql';
|
||||
import * as register from './register.graphql';
|
||||
import * as login from './login.graphql';
|
||||
|
||||
export const installAppMutation = print(installApp);
|
||||
export const startAppMutation = print(startApp);
|
||||
|
@ -15,3 +17,5 @@ export const stopAppMutation = print(stopApp);
|
|||
export const uninstallAppMutation = print(uninstallApp);
|
||||
export const updateAppConfigMutation = print(updateAppConfig);
|
||||
export const updateAppMutation = print(updateApp);
|
||||
export const registerMutation = print(register);
|
||||
export const loginMutation = print(login);
|
||||
|
|
9
packages/system-api/src/test/mutations/login.graphql
Normal file
9
packages/system-api/src/test/mutations/login.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Write your query or mutation here
|
||||
mutation Login($input: UsernamePasswordInput!) {
|
||||
login(input: $input) {
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
9
packages/system-api/src/test/mutations/register.graphql
Normal file
9
packages/system-api/src/test/mutations/register.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Write your query or mutation here
|
||||
mutation Register($input: UsernamePasswordInput!) {
|
||||
register(input: $input) {
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
query IsConfigured {
|
||||
isConfigured
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -9,7 +9,9 @@ else
|
|||
fi
|
||||
|
||||
NGINX_PORT=80
|
||||
NGINX_PORT_SSL=443
|
||||
PROXY_PORT=8080
|
||||
DOMAIN=tipi.localhost
|
||||
|
||||
while [ -n "$1" ]; do # while loop starts
|
||||
case "$1" in
|
||||
|
@ -26,6 +28,17 @@ while [ -n "$1" ]; do # while loop starts
|
|||
fi
|
||||
shift
|
||||
;;
|
||||
--ssl-port)
|
||||
ssl_port="$2"
|
||||
|
||||
if [[ "${ssl_port}" =~ ^[0-9]+$ ]]; then
|
||||
NGINX_PORT_SSL="${ssl_port}"
|
||||
else
|
||||
echo "--ssl-port must be a number"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--proxy-port)
|
||||
proxy_port="$2"
|
||||
|
||||
|
@ -37,6 +50,17 @@ while [ -n "$1" ]; do # while loop starts
|
|||
fi
|
||||
shift
|
||||
;;
|
||||
--domain)
|
||||
domain="$2"
|
||||
|
||||
if [[ "${domain}" =~ ^[a-zA-Z0-9.-]+$ ]]; then
|
||||
DOMAIN="${domain}"
|
||||
else
|
||||
echo "--domain must be a valid domain"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift # The double dash makes them parameters
|
||||
break
|
||||
|
@ -58,6 +82,12 @@ if [[ "$(uname)" != "Linux" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# If port is not 80 and domain is not tipi.localhost, we exit
|
||||
if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
|
||||
echo "Using a custom domain with a custom port is not supported"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
||||
STATE_FOLDER="${ROOT_FOLDER}/state"
|
||||
SED_ROOT_FOLDER="$(echo $ROOT_FOLDER | sed 's/\//\\\//g')"
|
||||
|
@ -150,10 +180,12 @@ for template in ${ENV_FILE}; do
|
|||
sed -i "s/<tipi_version>/${TIPI_VERSION}/g" "${template}"
|
||||
sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
|
||||
sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
|
||||
sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
|
||||
sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
|
||||
sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
|
||||
sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
|
||||
sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
|
||||
sed -i "s/<domain>/${DOMAIN}/g" "${template}"
|
||||
done
|
||||
|
||||
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
|
||||
|
|
|
@ -11,5 +11,7 @@ TIPI_VERSION=<tipi_version>
|
|||
JWT_SECRET=<jwt_secret>
|
||||
ROOT_FOLDER_HOST=<root_folder>
|
||||
NGINX_PORT=<nginx_port>
|
||||
NGINX_PORT=<nginx_port_ssl>
|
||||
PROXY_PORT=<proxy_port>
|
||||
POSTGRES_PASSWORD=<postgres_password>
|
||||
POSTGRES_PASSWORD=<postgres_password>
|
||||
DOMAIN=<domain>
|
|
@ -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"
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue