Merge pull request #167 from meienberger/feature/link-domain

Feature/link domain
This commit is contained in:
Nicolas Meienberger 2022-09-05 19:53:14 +00:00 committed by GitHub
commit c6338d2deb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 853 additions and 219 deletions

1
.gitignore vendored
View file

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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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;

View file

@ -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 }}>

View 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;

View file

@ -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;
};

View file

@ -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>

View file

@ -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)}

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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;

View 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}`;
};

View file

@ -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

View file

@ -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

View file

@ -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 };
}

View file

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

View file

@ -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[] = [];

View file

@ -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>
)}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 });
}

View file

@ -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 });
},
}));

View file

@ -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 })),
}));

View file

@ -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",

View file

@ -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,

View file

@ -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"');
}
}

View file

@ -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,

View file

@ -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 };

View file

@ -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();
});
});

View file

@ -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!');

View file

@ -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', () => {

View file

@ -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);

View file

@ -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}`);
}
};

View file

@ -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()

View file

@ -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;

View file

@ -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 };

View file

@ -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();
});
});

View file

@ -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 };
};

View file

@ -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 {

View file

@ -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);

View file

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

View file

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

View file

@ -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);

View file

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

View file

@ -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:

View file

@ -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"

View file

@ -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>

View file

@ -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"

View file

@ -1,16 +0,0 @@
{
"myresolver": {
"Account": {
"Email": "",
"Registration": {
"body": {
"status": "valid"
},
"uri": "https://acme-v02.api.letsencrypt.org/acme/acct/476208700"
},
"PrivateKey": "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",
"KeyType": "4096"
},
"Certificates": null
}
}

View file

View file

@ -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