commit
e59648d517
71 changed files with 1262 additions and 305 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
@ -65,7 +65,6 @@ jobs:
|
|||
- name: Run tests
|
||||
run: pnpm -r test
|
||||
|
||||
- uses: codecov/codecov-action@v2
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/system-api/coverage/clover.xml,./packages/dashboard/coverage/clover.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -11,6 +11,7 @@ repos/*
|
|||
!repos/.gitkeep
|
||||
apps/*
|
||||
!apps/.gitkeep
|
||||
traefik/shared
|
||||
|
||||
scripts/pacapt
|
||||
|
||||
|
|
|
@ -42,4 +42,4 @@ COPY ./packages/system-api /api
|
|||
COPY --from=build /dashboard/.next /dashboard/.next
|
||||
COPY ./packages/dashboard /dashboard
|
||||
|
||||
WORKDIR /
|
||||
WORKDIR /
|
11
README.md
11
README.md
|
@ -18,7 +18,7 @@
|
|||
|
||||
Tipi is a personal homeserver orchestrator. It is running docker containers under the hood and provides a simple web interface to manage them. Every service comes with an opinionated configuration in order to remove the need for manual configuration and network setup.
|
||||
|
||||
Check our demo instance : **95.179.210.152** / username: **user@runtipi.com** / password: **runtipi**
|
||||
Check our demo instance : **[demo.runtipi.com](https://demo.runtipi.com)** / username: **user@runtipi.com** / password: **runtipi**
|
||||
|
||||
## Apps available
|
||||
- [Adguard Home](https://github.com/AdguardTeam/AdGuardHome) - Adguard Home DNS adblocker
|
||||
|
@ -94,6 +94,15 @@ To stop Tipi, run the stop script.
|
|||
sudo ./scripts/stop.sh
|
||||
```
|
||||
|
||||
## Linking a domain to your dashboard
|
||||
If you want to link a domain to your dashboard, you can do so by providing the `--domain` option in the start script.
|
||||
|
||||
```bash
|
||||
sudo ./scripts/start.sh --domain mydomain.com
|
||||
```
|
||||
|
||||
A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
|
||||
|
||||
## ❤️ Contributing
|
||||
|
||||
Tipi is made to be very easy to plug in new apps. We welcome and appreciate new contributions.
|
||||
|
|
|
@ -1,6 +1,23 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
reverse-proxy:
|
||||
container_name: reverse-proxy
|
||||
image: traefik:v2.8
|
||||
restart: always
|
||||
ports:
|
||||
- ${NGINX_PORT-80}:80
|
||||
- ${NGINX_PORT_SSL-443}:443
|
||||
- 8080:8080
|
||||
command: --providers.docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${PWD}/traefik:/root/.config
|
||||
- ${PWD}/traefik/shared:/shared
|
||||
- ${PWD}/traefik/letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-db:
|
||||
container_name: tipi-db
|
||||
image: postgres:latest
|
||||
|
@ -51,8 +68,19 @@ services:
|
|||
POSTGRES_HOST: tipi-db
|
||||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api`)
|
||||
traefik.http.routers.api.service: api
|
||||
traefik.http.routers.api.entrypoints: web
|
||||
traefik.http.routers.api.middlewares: api-stripprefix
|
||||
traefik.http.services.api.loadbalancer.server.port: 3001
|
||||
# Middlewares
|
||||
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
|
@ -66,16 +94,27 @@ services:
|
|||
- tipi_main_network
|
||||
environment:
|
||||
- INTERNAL_IP=${INTERNAL_IP}
|
||||
- DOMAIN=${DOMAIN}
|
||||
volumes:
|
||||
- ${PWD}/packages/dashboard/src:/dashboard/src
|
||||
# - /dashboard/node_modules
|
||||
# - /dashboard/.next
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/") # Host(`tipi.local`) &&
|
||||
traefik.http.routers.dashboard.entrypoints: webinsecure
|
||||
traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
|
||||
traefik.http.routers.dashboard-redirect.entrypoints: web
|
||||
traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
|
||||
traefik.http.routers.dashboard-redirect.service: dashboard
|
||||
traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
|
||||
|
||||
# Web
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
|
||||
traefik.http.routers.dashboard.service: dashboard
|
||||
traefik.http.routers.dashboard.entrypoints: web
|
||||
traefik.http.services.dashboard.loadbalancer.server.port: 3000
|
||||
# Middlewares
|
||||
traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
|
||||
traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
|
||||
|
||||
networks:
|
||||
tipi_main_network:
|
||||
|
|
|
@ -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:
|
||||
|
|
17
package.json
17
package.json
|
@ -1,9 +1,8 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"act:test-install": "act --container-architecture linux/amd64 -j test-install",
|
||||
|
@ -11,23 +10,16 @@
|
|||
"start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
|
||||
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
|
||||
"start:prod": "docker-compose --env-file .env up --build",
|
||||
"build:common": "cd packages/common && npm run build",
|
||||
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
|
||||
"version": "echo $npm_package_version"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/node": "17.0.31",
|
||||
"husky": "^8.0.1",
|
||||
"jest": "^28.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typescript": "4.6.4",
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
"@commitlint/config-conventional": "^17.0.3",
|
||||
"@commitlint/cz-commitlint": "^17.0.3",
|
||||
"commitizen": "^4.2.4"
|
||||
"commitizen": "^4.2.4",
|
||||
"husky": "^8.0.1",
|
||||
"inquirer": "8.2.4"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -39,7 +31,6 @@
|
|||
"url": "https://github.com/meienberger/runtipi/issues"
|
||||
},
|
||||
"homepage": "https://github.com/meienberger/runtipi#readme",
|
||||
"dependencies": {},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "@commitlint/cz-commitlint"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --colors",
|
||||
|
@ -60,8 +60,10 @@
|
|||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-next": "12.1.4",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"jest": "^28.1.0",
|
||||
"postcss": "^8.4.12",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,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,7 +58,9 @@ export type AppInfo = {
|
|||
available: Scalars['Boolean'];
|
||||
categories: Array<AppCategoriesEnum>;
|
||||
description: Scalars['String'];
|
||||
exposable?: Maybe<Scalars['Boolean']>;
|
||||
form_fields: Array<FormField>;
|
||||
https?: Maybe<Scalars['Boolean']>;
|
||||
id: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
port: Scalars['Float'];
|
||||
|
@ -68,6 +73,8 @@ export type AppInfo = {
|
|||
};
|
||||
|
||||
export type AppInputType = {
|
||||
domain: Scalars['String'];
|
||||
exposed: Scalars['Boolean'];
|
||||
form: Scalars['JSONObject'];
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
@ -286,6 +293,8 @@ export type GetAppQuery = {
|
|||
status: AppStatusEnum;
|
||||
config: any;
|
||||
version?: number | null;
|
||||
exposed: boolean;
|
||||
domain: string;
|
||||
updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
|
||||
info?: {
|
||||
__typename?: 'AppInfo';
|
||||
|
@ -301,6 +310,8 @@ export type GetAppQuery = {
|
|||
source: string;
|
||||
categories: Array<AppCategoriesEnum>;
|
||||
url_suffix?: string | null;
|
||||
https?: boolean | null;
|
||||
exposable?: boolean | null;
|
||||
form_fields: Array<{
|
||||
__typename?: 'FormField';
|
||||
type: FieldTypesEnum;
|
||||
|
@ -326,7 +337,7 @@ export type InstalledAppsQuery = {
|
|||
config: any;
|
||||
version?: number | null;
|
||||
updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
|
||||
info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string } | null;
|
||||
info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string; https?: boolean | null } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
@ -352,6 +363,7 @@ export type ListAppsQuery = {
|
|||
short_desc: string;
|
||||
author: string;
|
||||
categories: Array<AppCategoriesEnum>;
|
||||
https?: boolean | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
@ -693,6 +705,8 @@ export const GetAppDocument = gql`
|
|||
status
|
||||
config
|
||||
version
|
||||
exposed
|
||||
domain
|
||||
updateInfo {
|
||||
current
|
||||
latest
|
||||
|
@ -711,6 +725,8 @@ export const GetAppDocument = gql`
|
|||
source
|
||||
categories
|
||||
url_suffix
|
||||
https
|
||||
exposable
|
||||
form_fields {
|
||||
type
|
||||
label
|
||||
|
@ -770,6 +786,7 @@ export const InstalledAppsDocument = gql`
|
|||
description
|
||||
tipi_version
|
||||
short_desc
|
||||
https
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -846,6 +863,7 @@ export const ListAppsDocument = gql`
|
|||
short_desc
|
||||
author
|
||||
categories
|
||||
https
|
||||
}
|
||||
total
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ query GetApp($appId: String!) {
|
|||
status
|
||||
config
|
||||
version
|
||||
exposed
|
||||
domain
|
||||
updateInfo {
|
||||
current
|
||||
latest
|
||||
|
@ -22,6 +24,8 @@ query GetApp($appId: String!) {
|
|||
source
|
||||
categories
|
||||
url_suffix
|
||||
https
|
||||
exposable
|
||||
form_fields {
|
||||
type
|
||||
label
|
||||
|
|
|
@ -15,6 +15,7 @@ query InstalledApps {
|
|||
description
|
||||
tipi_version
|
||||
short_desc
|
||||
https
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ query ListApps {
|
|||
short_desc
|
||||
author
|
||||
categories
|
||||
https
|
||||
}
|
||||
total
|
||||
}
|
||||
|
|
|
@ -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,14 +101,16 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUpdateSettingsSubmit = async (values: Record<string, any>) => {
|
||||
const handleUpdateSettingsSubmit = async (values: IFormValues) => {
|
||||
try {
|
||||
await updateConfig({ variables: { input: { form: values, id: info.id } } });
|
||||
const { exposed, domain, ...form } = values;
|
||||
await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'App config updated successfully',
|
||||
description: 'App config updated successfully. Restart the app to apply the changes.',
|
||||
position: 'top',
|
||||
status: 'success',
|
||||
isClosable: true,
|
||||
});
|
||||
updateSettingsDisclosure.onClose();
|
||||
} catch (error) {
|
||||
|
@ -123,6 +127,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
description: 'App updated successfully',
|
||||
position: 'top',
|
||||
status: 'success',
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
|
@ -130,7 +135,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
window.open(`http://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
|
||||
const { https } = info;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(`${protocol}://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
|
||||
|
@ -144,6 +154,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
<div className="flex flex-col justify-between flex-1 ml-0 md:ml-4">
|
||||
<div className="mt-3 items-center self-center flex flex-col md:items-start md:self-start md:mt-0">
|
||||
<h1 className="font-bold text-2xl">{info.name}</h1>
|
||||
{app?.domain && app.exposed && (
|
||||
<a target="_blank" rel="noreferrer" className="text-blue-500 text-md" href={`https://${app.domain}`}>
|
||||
<Flex className="items-center">
|
||||
{app.domain}
|
||||
<FiExternalLink className="ml-1" />
|
||||
</Flex>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<h2 className="text-center md:text-left">{info.short_desc}</h2>
|
||||
<h3 className="text-center md:text-left text-sm">
|
||||
version: <b>{version}</b>
|
||||
|
@ -158,6 +177,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
)}
|
||||
<p className="text-xs text-gray-600">By {info.author}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1">
|
||||
<AppActions
|
||||
updateAvailable={updateAvailable}
|
||||
|
@ -180,7 +200,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
|
|||
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
|
||||
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
|
||||
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
|
||||
<UpdateSettingsModal onSubmit={handleUpdateSettingsSubmit} isOpen={updateSettingsDisclosure.isOpen} onClose={updateSettingsDisclosure.onClose} app={info} config={app?.config} />
|
||||
<UpdateSettingsModal
|
||||
onSubmit={handleUpdateSettingsSubmit}
|
||||
isOpen={updateSettingsDisclosure.isOpen}
|
||||
onClose={updateSettingsDisclosure.onClose}
|
||||
app={info}
|
||||
config={app?.config}
|
||||
exposed={app?.exposed}
|
||||
domain={app?.domain}
|
||||
/>
|
||||
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
|
||||
</div>
|
||||
</SlideFade>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { theme } from '../styles/theme';
|
|||
import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
import useCachedResources from '../hooks/useCachedRessources';
|
||||
import Head from 'next/head';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const { client } = useCachedResources();
|
||||
|
@ -18,6 +19,9 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
return (
|
||||
<ApolloProvider client={client}>
|
||||
<ChakraProvider theme={theme}>
|
||||
<Head>
|
||||
<title>Tipi</title>
|
||||
</Head>
|
||||
<AuthWrapper>
|
||||
<Component {...pageProps} />
|
||||
</AuthWrapper>
|
||||
|
|
|
@ -2,16 +2,17 @@ import React from 'react';
|
|||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { ColorModeScript } from '@chakra-ui/react';
|
||||
import { theme } from '../styles/theme';
|
||||
import { getUrl } from '../core/helpers/url-helpers';
|
||||
|
||||
export default function MyDocument() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={getUrl('apple-touch-icon.png')} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={getUrl('favicon-32x32.png')} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={getUrl('favicon-16x16.png')} />
|
||||
<link rel="manifest" href={getUrl('site.webmanifest')} />
|
||||
<link rel="mask-icon" href={getUrl('safari-pinned-tab.svg')} color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</Head>
|
||||
|
|
|
@ -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 })),
|
||||
}));
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import path from 'path';
|
||||
const fs: {
|
||||
__createMockFiles: typeof createMockFiles;
|
||||
__resetAllMocks: typeof resetAllMocks;
|
||||
readFileSync: typeof readFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
writeFileSync: typeof writeFileSync;
|
||||
|
@ -9,6 +10,7 @@ const fs: {
|
|||
readdirSync: typeof readdirSync;
|
||||
copyFileSync: typeof copyFileSync;
|
||||
copySync: typeof copyFileSync;
|
||||
createFileSync: typeof createFileSync;
|
||||
} = jest.genMockFromModule('fs-extra');
|
||||
|
||||
let mockFiles = Object.create(null);
|
||||
|
@ -45,12 +47,14 @@ const mkdirSync = (p: string) => {
|
|||
mockFiles[p] = Object.create(null);
|
||||
};
|
||||
|
||||
const rmSync = (p: string, options: { recursive: boolean }) => {
|
||||
if (options.recursive) {
|
||||
delete mockFiles[p];
|
||||
} else {
|
||||
delete mockFiles[p][Object.keys(mockFiles[p])[0]];
|
||||
const rmSync = (p: string) => {
|
||||
if (mockFiles[p] instanceof Array) {
|
||||
mockFiles[p].forEach((file: string) => {
|
||||
delete mockFiles[path.join(p, file)];
|
||||
});
|
||||
}
|
||||
|
||||
delete mockFiles[p];
|
||||
};
|
||||
|
||||
const readdirSync = (p: string) => {
|
||||
|
@ -85,6 +89,14 @@ const copySync = (source: string, destination: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const createFileSync = (p: string) => {
|
||||
mockFiles[p] = '';
|
||||
};
|
||||
|
||||
const resetAllMocks = () => {
|
||||
mockFiles = Object.create(null);
|
||||
};
|
||||
|
||||
fs.readdirSync = readdirSync;
|
||||
fs.existsSync = existsSync;
|
||||
fs.readFileSync = readFileSync;
|
||||
|
@ -93,6 +105,8 @@ fs.mkdirSync = mkdirSync;
|
|||
fs.rmSync = rmSync;
|
||||
fs.copyFileSync = copyFileSync;
|
||||
fs.copySync = copySync;
|
||||
fs.createFileSync = createFileSync;
|
||||
fs.__createMockFiles = createMockFiles;
|
||||
fs.__resetAllMocks = resetAllMocks;
|
||||
|
||||
module.exports = fs;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "system-api",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "",
|
||||
"exports": "./dist/server.js",
|
||||
"type": "module",
|
||||
|
@ -13,7 +13,7 @@
|
|||
"lint:fix": "eslint . --ext .ts --fix",
|
||||
"test": "jest --colors",
|
||||
"test:watch": "jest --watch",
|
||||
"build": "rm -rf dist && swc ./src --ignore **/*.test.* -d dist",
|
||||
"build": "rm -rf dist && swc ./src -d dist",
|
||||
"build:watch": "swc ./src -d dist --watch",
|
||||
"start:dev": "NODE_ENV=development && nodemon --experimental-specifier-resolution=node --trace-deprecation --trace-warnings --watch dist dist/server.js",
|
||||
"dev": "concurrently \"npm run build:watch\" \"npm run start:dev\"",
|
||||
|
@ -27,7 +27,7 @@
|
|||
"dependencies": {
|
||||
"apollo-server-core": "^3.10.0",
|
||||
"apollo-server-express": "^3.9.0",
|
||||
"argon2": "^0.28.5",
|
||||
"argon2": "^0.29.1",
|
||||
"axios": "^0.26.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"compression": "^1.7.4",
|
||||
|
@ -55,6 +55,7 @@
|
|||
"tcp-port-used": "^1.0.2",
|
||||
"type-graphql": "^1.1.1",
|
||||
"typeorm": "^0.3.6",
|
||||
"validator": "^13.7.0",
|
||||
"winston": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -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) {
|
||||
|
@ -54,13 +58,17 @@ const createApp = async (props: IProps) => {
|
|||
MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
|
||||
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
|
||||
let appEntity = new App();
|
||||
if (installed) {
|
||||
await App.create({
|
||||
appEntity = await App.create({
|
||||
id: appInfo.id,
|
||||
config: { TEST_FIELD: 'test' },
|
||||
status,
|
||||
exposed,
|
||||
domain,
|
||||
}).save();
|
||||
|
||||
MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
|
||||
|
@ -69,7 +77,7 @@ const createApp = async (props: IProps) => {
|
|||
MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
|
||||
}
|
||||
|
||||
return { appInfo, MockFiles };
|
||||
return { appInfo, MockFiles, appEntity };
|
||||
};
|
||||
|
||||
export { createApp };
|
||||
|
|
|
@ -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!');
|
||||
|
|
|
@ -109,6 +109,59 @@ describe('Install app', () => {
|
|||
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
|
||||
expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('Should correctly copy app from repos to apps folder', async () => {
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
const appFolder = fs.readdirSync(`${config.ROOT_FOLDER}/apps/${app1.id}`);
|
||||
|
||||
expect(appFolder).toBeDefined();
|
||||
expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('Should cleanup any app folder existing before install', async () => {
|
||||
const { MockFiles, appInfo } = await createApp({});
|
||||
app1 = appInfo;
|
||||
MockFiles[`/tipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
|
||||
MockFiles[`/tipi/apps/${appInfo.id}/test.yml`] = 'test';
|
||||
MockFiles[`/tipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(true);
|
||||
|
||||
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
|
||||
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(false);
|
||||
expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not provided', async () => {
|
||||
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required if app is exposed');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and config does not allow it', async () => {
|
||||
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not valid', async () => {
|
||||
const { MockFiles, appInfo } = await createApp({ exposable: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is already used', async () => {
|
||||
const app2 = await createApp({ exposable: true });
|
||||
const app3 = await createApp({ exposable: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(Object.assign({}, app2.MockFiles, app3.MockFiles));
|
||||
|
||||
await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
|
||||
|
||||
await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Uninstall app', () => {
|
||||
|
@ -308,6 +361,37 @@ describe('Update app config', () => {
|
|||
|
||||
expect(envMap.get('RANDOM_FIELD')).toBe('test');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not provided', () => {
|
||||
return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is not valid', () => {
|
||||
return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and config does not allow it', () => {
|
||||
return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
|
||||
});
|
||||
|
||||
it('Should throw if app is exposed and domain is already used', async () => {
|
||||
const app2 = await createApp({ exposable: true, installed: true });
|
||||
const app3 = await createApp({ exposable: true, installed: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles));
|
||||
|
||||
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
|
||||
await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
|
||||
});
|
||||
|
||||
it('Should not throw if updating with same domain', async () => {
|
||||
const app2 = await createApp({ exposable: true, installed: true });
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(Object.assign(app2.MockFiles));
|
||||
|
||||
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
|
||||
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get app config', () => {
|
||||
|
|
|
@ -55,9 +55,17 @@ class App extends BaseEntity {
|
|||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ type: 'boolean', default: false })
|
||||
exposed!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
domain?: string;
|
||||
|
||||
@Field(() => AppInfo, { nullable: true })
|
||||
info(): AppInfo | null {
|
||||
return getAppInfo(this.id);
|
||||
return getAppInfo(this.id, this.status);
|
||||
}
|
||||
|
||||
@Field(() => UpdateInfo, { nullable: true })
|
||||
|
|
|
@ -3,7 +3,7 @@ import { fileExists, getSeed, readdirSync, readFile, readJsonFile, runScript, wr
|
|||
import InternalIp from 'internal-ip';
|
||||
import crypto from 'crypto';
|
||||
import config from '../../config';
|
||||
import { AppInfo } from './apps.types';
|
||||
import { AppInfo, AppStatusEnum } from './apps.types';
|
||||
import logger from '../../config/logger/logger';
|
||||
import App from './app.entity';
|
||||
|
||||
|
@ -74,19 +74,19 @@ const getEntropy = (name: string, length: number) => {
|
|||
return hash.digest('hex').substring(0, length);
|
||||
};
|
||||
|
||||
export const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
|
||||
export const generateEnvFile = (app: App) => {
|
||||
const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
|
||||
|
||||
if (!configFile) {
|
||||
throw new Error(`App ${appName} not found`);
|
||||
throw new Error(`App ${app.id} not found`);
|
||||
}
|
||||
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
const envMap = getEnvMap(appName);
|
||||
const envMap = getEnvMap(app.id);
|
||||
|
||||
configFile.form_fields?.forEach((field) => {
|
||||
const formValue = form[field.env_variable];
|
||||
const formValue = app.config[field.env_variable];
|
||||
const envVar = field.env_variable;
|
||||
|
||||
if (formValue) {
|
||||
|
@ -105,7 +105,12 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
|
|||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
if (app.exposed && app.domain) {
|
||||
envFile += 'APP_EXPOSED=true\n';
|
||||
envFile += `APP_DOMAIN=${app.domain}\n`;
|
||||
}
|
||||
|
||||
writeFile(`/app-data/${app.id}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getAvailableApps = async (): Promise<string[]> => {
|
||||
|
@ -126,15 +131,18 @@ export const getAvailableApps = async (): Promise<string[]> => {
|
|||
return apps;
|
||||
};
|
||||
|
||||
export const getAppInfo = (id: string): AppInfo => {
|
||||
export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
|
||||
try {
|
||||
const repoId = config.APPS_REPO_ID;
|
||||
|
||||
if (fileExists(`/apps/${id}/config.json`)) {
|
||||
// Check if app is installed
|
||||
const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
|
||||
|
||||
if (installed && fileExists(`/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
|
||||
return configFile;
|
||||
} else if (fileExists(`/repos/${repoId}`)) {
|
||||
} else if (fileExists(`/repos/${repoId}/apps/${id}/config.json`)) {
|
||||
const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
|
||||
configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
|
||||
|
||||
|
@ -143,8 +151,9 @@ export const getAppInfo = (id: string): AppInfo => {
|
|||
}
|
||||
}
|
||||
|
||||
throw new Error('No repository found');
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(`Error loading app ${id}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,9 +1,11 @@
|
|||
import validator from 'validator';
|
||||
import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
|
||||
import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
|
||||
import App from './app.entity';
|
||||
import logger from '../../config/logger/logger';
|
||||
import config from '../../config';
|
||||
import { Not } from 'typeorm';
|
||||
|
||||
const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
|
||||
|
||||
|
@ -15,7 +17,7 @@ const startAllApps = async (): Promise<void> => {
|
|||
// Regenerate env file
|
||||
try {
|
||||
ensureAppFolder(app.id);
|
||||
generateEnvFile(app.id, app.config);
|
||||
generateEnvFile(app);
|
||||
checkEnvFile(app.id);
|
||||
|
||||
await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
|
||||
|
@ -40,7 +42,7 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
ensureAppFolder(appName);
|
||||
|
||||
// Regenerate env file
|
||||
generateEnvFile(appName, app.config);
|
||||
generateEnvFile(app);
|
||||
|
||||
checkEnvFile(appName);
|
||||
|
||||
|
@ -59,13 +61,21 @@ const startApp = async (appName: string): Promise<App> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
const installApp = async (id: string, form: Record<string, string>): Promise<App> => {
|
||||
const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
if (app) {
|
||||
await startApp(id);
|
||||
} else {
|
||||
ensureAppFolder(id);
|
||||
if (exposed && !domain) {
|
||||
throw new Error('Domain is required if app is exposed');
|
||||
}
|
||||
|
||||
if (domain && !validator.isFQDN(domain)) {
|
||||
throw new Error(`Domain ${domain} is not valid`);
|
||||
}
|
||||
|
||||
ensureAppFolder(id, true);
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
|
@ -75,11 +85,23 @@ const installApp = async (id: string, form: Record<string, string>): Promise<App
|
|||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0) }).save();
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
}
|
||||
|
||||
if (exposed) {
|
||||
const appsWithSameDomain = await App.find({ where: { domain, exposed: true } });
|
||||
if (appsWithSameDomain.length > 0) {
|
||||
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
|
||||
}
|
||||
}
|
||||
|
||||
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: Number(appInfo?.tipi_version || 0), exposed: exposed || false, domain }).save();
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(app);
|
||||
|
||||
// Run script
|
||||
try {
|
||||
|
@ -116,15 +138,38 @@ const listApps = async (): Promise<ListAppsResonse> => {
|
|||
return { apps: apps.sort(sortApps), total: apps.length };
|
||||
};
|
||||
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
|
||||
if (exposed && !domain) {
|
||||
throw new Error('Domain is required if app is exposed');
|
||||
}
|
||||
|
||||
if (domain && !validator.isFQDN(domain)) {
|
||||
throw new Error(`Domain ${domain} is not valid`);
|
||||
}
|
||||
|
||||
const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
|
||||
|
||||
if (!appInfo?.exposable && exposed) {
|
||||
throw new Error(`App ${id} is not exposable`);
|
||||
}
|
||||
|
||||
if (exposed) {
|
||||
const appsWithSameDomain = await App.find({ where: { domain, exposed: true, id: Not(id) } });
|
||||
if (appsWithSameDomain.length > 0) {
|
||||
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
|
||||
}
|
||||
}
|
||||
|
||||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App ${id} not found`);
|
||||
}
|
||||
|
||||
generateEnvFile(id, form);
|
||||
await App.update({ id }, { config: form });
|
||||
await App.update({ id }, { config: form, exposed: exposed || false, domain });
|
||||
app = (await App.findOne({ where: { id } })) as App;
|
||||
|
||||
generateEnvFile(app);
|
||||
app = (await App.findOne({ where: { id } })) as App;
|
||||
|
||||
return app;
|
||||
|
@ -185,7 +230,7 @@ const getApp = async (id: string): Promise<App> => {
|
|||
let app = await App.findOne({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
app = { id, status: AppStatusEnum.MISSING, config: {} } as App;
|
||||
app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as App;
|
||||
}
|
||||
|
||||
return app;
|
||||
|
|
|
@ -15,6 +15,7 @@ export enum AppCategoriesEnum {
|
|||
DATA = 'data',
|
||||
MUSIC = 'music',
|
||||
FINANCE = 'finance',
|
||||
GAMING = 'gaming',
|
||||
}
|
||||
|
||||
export enum FieldTypes {
|
||||
|
@ -121,6 +122,12 @@ class AppInfo {
|
|||
|
||||
@Field(() => GraphQLJSONObject, { nullable: true })
|
||||
requirements?: Requirements;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
https?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
exposable?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
@ -139,6 +146,12 @@ class AppInputType {
|
|||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
form!: Record<string, string>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
exposed!: boolean;
|
||||
|
||||
@Field(() => String)
|
||||
domain!: string;
|
||||
}
|
||||
|
||||
export { ListAppsResonse, AppInfo, AppInputType };
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
200
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts
Normal file
200
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts
Normal file
|
@ -0,0 +1,200 @@
|
|||
import childProcess from 'child_process';
|
||||
import config from '../../../config';
|
||||
import { getAbsolutePath, readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Test: getAbsolutePath', () => {
|
||||
it('should return the absolute path', () => {
|
||||
expect(getAbsolutePath('/test')).toBe(`${config.ROOT_FOLDER}/test`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: readJsonFile', () => {
|
||||
it('should return the json file', () => {
|
||||
// Arrange
|
||||
const rawFile = '{"test": "test"}';
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.json`]: rawFile,
|
||||
};
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
const file = readJsonFile('/test-file.json');
|
||||
|
||||
// Assert
|
||||
expect(file).toEqual({ test: 'test' });
|
||||
});
|
||||
|
||||
it('should return null if the file does not exist', () => {
|
||||
expect(readJsonFile('/test')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: readFile', () => {
|
||||
it('should return the file', () => {
|
||||
const rawFile = 'test';
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.txt`]: rawFile,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(readFile('/test-file.txt')).toEqual('test');
|
||||
});
|
||||
|
||||
it('should return empty string if the file does not exist', () => {
|
||||
expect(readFile('/test')).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: readdirSync', () => {
|
||||
it('should return the files', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test/test-file.txt`]: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(readdirSync('/test')).toEqual(['test-file.txt']);
|
||||
});
|
||||
|
||||
it('should return empty array if the directory does not exist', () => {
|
||||
expect(readdirSync('/test')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: fileExists', () => {
|
||||
it('should return true if the file exists', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/test-file.txt`]: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(fileExists('/test-file.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false if the file does not exist', () => {
|
||||
expect(fileExists('/test-file.txt')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: writeFile', () => {
|
||||
it('should write the file', () => {
|
||||
const spy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
writeFile('/test-file.txt', 'test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test-file.txt`, 'test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: createFolder', () => {
|
||||
it('should create the folder', () => {
|
||||
const spy = jest.spyOn(fs, 'mkdirSync');
|
||||
|
||||
createFolder('/test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: deleteFolder', () => {
|
||||
it('should delete the folder', () => {
|
||||
const spy = jest.spyOn(fs, 'rmSync');
|
||||
|
||||
deleteFolder('/test');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: runScript', () => {
|
||||
it('should run the script', () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
const callback = jest.fn();
|
||||
|
||||
runScript('/test', [], callback);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, [], {}, callback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getSeed', () => {
|
||||
it('should return the seed', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/state/seed`]: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
expect(getSeed()).toEqual('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: ensureAppFolder', () => {
|
||||
beforeEach(() => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
};
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
});
|
||||
|
||||
it('should copy the folder from repo', () => {
|
||||
// Act
|
||||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
expect(files).toEqual(['test.yml']);
|
||||
});
|
||||
|
||||
it('should not copy the folder if it already exists', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
expect(files).toEqual(['docker-compose.yml']);
|
||||
});
|
||||
|
||||
it('Should overwrite the folder if clean up is true', () => {
|
||||
const mockFiles = {
|
||||
[`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
|
||||
[`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
ensureAppFolder('test', true);
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
|
||||
expect(files).toEqual(['test.yml']);
|
||||
});
|
||||
});
|
|
@ -42,9 +42,13 @@ export const getSeed = () => {
|
|||
return seed.toString();
|
||||
};
|
||||
|
||||
export const ensureAppFolder = (appName: string) => {
|
||||
export const ensureAppFolder = (appName: string, cleanup = false) => {
|
||||
if (cleanup && fileExists(`/apps/${appName}`)) {
|
||||
deleteFolder(`/apps/${appName}`);
|
||||
}
|
||||
|
||||
if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
|
||||
fs.removeSync(getAbsolutePath(`/apps/${appName}`));
|
||||
if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
|
||||
// Copy from apps repo
|
||||
fs.copySync(getAbsolutePath(`/repos/${config.APPS_REPO_ID}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
148
pnpm-lock.yaml
generated
148
pnpm-lock.yaml
generated
|
@ -7,28 +7,16 @@ importers:
|
|||
'@commitlint/cli': ^17.0.3
|
||||
'@commitlint/config-conventional': ^17.0.3
|
||||
'@commitlint/cz-commitlint': ^17.0.3
|
||||
'@types/jest': ^27.5.0
|
||||
'@types/js-yaml': ^4.0.5
|
||||
'@types/node': 17.0.31
|
||||
commitizen: ^4.2.4
|
||||
husky: ^8.0.1
|
||||
jest: ^28.1.0
|
||||
js-yaml: ^4.1.0
|
||||
ts-jest: ^28.0.2
|
||||
typescript: 4.6.4
|
||||
inquirer: 8.2.4
|
||||
devDependencies:
|
||||
'@commitlint/cli': 17.0.3
|
||||
'@commitlint/config-conventional': 17.0.3
|
||||
'@commitlint/cz-commitlint': 17.0.3_commitizen@4.2.5
|
||||
'@types/jest': 27.5.0
|
||||
'@types/js-yaml': 4.0.5
|
||||
'@types/node': 17.0.31
|
||||
'@commitlint/cz-commitlint': 17.0.3_yes7iyjckc3rubj3ixzwc3ince
|
||||
commitizen: 4.2.5
|
||||
husky: 8.0.1
|
||||
jest: 28.1.0_@types+node@17.0.31
|
||||
js-yaml: 4.1.0
|
||||
ts-jest: 28.0.2_z3fx76c5ksuwr36so7o5uc2kcy
|
||||
typescript: 4.6.4
|
||||
inquirer: 8.2.4
|
||||
|
||||
packages/dashboard:
|
||||
specifiers:
|
||||
|
@ -62,6 +50,7 @@ importers:
|
|||
graphql: ^15.8.0
|
||||
graphql-tag: ^2.12.6
|
||||
immer: ^9.0.12
|
||||
jest: ^28.1.0
|
||||
js-cookie: ^3.0.1
|
||||
next: 12.1.6
|
||||
postcss: ^8.4.12
|
||||
|
@ -77,6 +66,7 @@ importers:
|
|||
swr: ^1.3.0
|
||||
systeminformation: ^5.11.9
|
||||
tailwindcss: ^3.0.23
|
||||
ts-jest: ^28.0.2
|
||||
tslib: ^2.4.0
|
||||
typescript: 4.6.4
|
||||
validator: ^13.7.0
|
||||
|
@ -129,8 +119,10 @@ importers:
|
|||
eslint-config-airbnb-typescript: 17.0.0_r46exuh3jlhq2wmrnqx2ufqspa
|
||||
eslint-config-next: 12.1.4_e6a2zi6fqdwfehht5cxvkmo3zu
|
||||
eslint-plugin-import: 2.26.0_hhyjdrupy4c2vgtpytri6cjwoy
|
||||
jest: 28.1.0_@types+node@17.0.31
|
||||
postcss: 8.4.13
|
||||
tailwindcss: 3.0.24
|
||||
ts-jest: 28.0.2_ps5qfvt5fosg52obpfzuxthwve
|
||||
typescript: 4.6.4
|
||||
|
||||
packages/system-api:
|
||||
|
@ -157,7 +149,7 @@ importers:
|
|||
'@typescript-eslint/parser': ^5.22.0
|
||||
apollo-server-core: ^3.10.0
|
||||
apollo-server-express: ^3.9.0
|
||||
argon2: ^0.28.5
|
||||
argon2: ^0.29.1
|
||||
axios: ^0.26.1
|
||||
class-validator: ^0.13.2
|
||||
compression: ^1.7.4
|
||||
|
@ -199,11 +191,12 @@ importers:
|
|||
type-graphql: ^1.1.1
|
||||
typeorm: ^0.3.6
|
||||
typescript: 4.6.4
|
||||
validator: ^13.7.0
|
||||
winston: ^3.7.2
|
||||
dependencies:
|
||||
apollo-server-core: 3.10.0_graphql@15.8.0
|
||||
apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
|
||||
argon2: 0.28.5
|
||||
argon2: 0.29.1
|
||||
axios: 0.26.1
|
||||
class-validator: 0.13.2
|
||||
compression: 1.7.4
|
||||
|
@ -231,6 +224,7 @@ importers:
|
|||
tcp-port-used: 1.0.2
|
||||
type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
|
||||
typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
|
||||
validator: 13.7.0
|
||||
winston: 3.7.2
|
||||
devDependencies:
|
||||
'@faker-js/faker': 7.3.0
|
||||
|
@ -761,7 +755,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.17.10:
|
||||
|
@ -770,7 +764,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.17.10:
|
||||
|
@ -798,7 +792,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.17.10:
|
||||
|
@ -807,7 +801,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-jsx/7.16.7_@babel+core@7.17.10:
|
||||
|
@ -835,7 +829,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.17.10:
|
||||
|
@ -844,7 +838,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.17.10:
|
||||
|
@ -853,7 +847,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.17.10:
|
||||
|
@ -871,7 +865,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.17.10:
|
||||
|
@ -880,7 +874,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.17.10:
|
||||
|
@ -890,7 +884,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-syntax-typescript/7.17.10_@babel+core@7.17.10:
|
||||
|
@ -900,7 +894,7 @@ packages:
|
|||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
dev: true
|
||||
|
||||
/@babel/plugin-transform-arrow-functions/7.17.12_@babel+core@7.17.10:
|
||||
|
@ -2033,7 +2027,7 @@ packages:
|
|||
ajv: 8.11.0
|
||||
dev: true
|
||||
|
||||
/@commitlint/cz-commitlint/17.0.3_commitizen@4.2.5:
|
||||
/@commitlint/cz-commitlint/17.0.3_yes7iyjckc3rubj3ixzwc3ince:
|
||||
resolution: {integrity: sha512-360I6wnaUWzc23D8Xn4B/cu8thy8GDJPZ4QsYk4xjVzDDyXZ6oXJB0+OlwkpWpSvjuLYAmEKiImvo0yLTASmlg==}
|
||||
engines: {node: '>=v14'}
|
||||
peerDependencies:
|
||||
|
@ -2045,6 +2039,7 @@ packages:
|
|||
'@commitlint/types': 17.0.0
|
||||
chalk: 4.1.2
|
||||
commitizen: 4.2.5
|
||||
inquirer: 8.2.4
|
||||
lodash: 4.17.21
|
||||
word-wrap: 1.2.3
|
||||
transitivePeerDependencies:
|
||||
|
@ -3034,7 +3029,7 @@ packages:
|
|||
chalk: 4.1.2
|
||||
collect-v8-coverage: 1.0.1
|
||||
exit: 0.1.2
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.10
|
||||
istanbul-lib-coverage: 3.2.0
|
||||
istanbul-lib-instrument: 5.2.0
|
||||
|
@ -3650,8 +3645,8 @@ packages:
|
|||
/@types/babel__core/7.1.19:
|
||||
resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==}
|
||||
dependencies:
|
||||
'@babel/parser': 7.17.10
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/parser': 7.18.5
|
||||
'@babel/types': 7.18.4
|
||||
'@types/babel__generator': 7.6.4
|
||||
'@types/babel__template': 7.4.1
|
||||
'@types/babel__traverse': 7.17.1
|
||||
|
@ -3660,20 +3655,20 @@ packages:
|
|||
/@types/babel__generator/7.6.4:
|
||||
resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==}
|
||||
dependencies:
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/types': 7.18.4
|
||||
dev: true
|
||||
|
||||
/@types/babel__template/7.4.1:
|
||||
resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==}
|
||||
dependencies:
|
||||
'@babel/parser': 7.17.10
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/parser': 7.18.5
|
||||
'@babel/types': 7.18.4
|
||||
dev: true
|
||||
|
||||
/@types/babel__traverse/7.17.1:
|
||||
resolution: {integrity: sha512-kVzjari1s2YVi77D3w1yuvohV2idweYXMCDzqBiVNN63TcDWrIlTVOYpqVrvbbyOE/IyzBoTKF0fdnLPEORFxA==}
|
||||
dependencies:
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/types': 7.18.4
|
||||
dev: true
|
||||
|
||||
/@types/body-parser/1.19.2:
|
||||
|
@ -4636,14 +4631,14 @@ packages:
|
|||
resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==}
|
||||
dev: true
|
||||
|
||||
/argon2/0.28.5:
|
||||
resolution: {integrity: sha512-kGFCctzc3VWmR1aCOYjNgvoTmVF5uVBUtWlXCKKO54d1K+31zRz45KAcDIqMo2746ozv/52d25nfEekitaXP0w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
/argon2/0.29.1:
|
||||
resolution: {integrity: sha512-bWXzAsQA0B6EFWZh5li+YBk+muoknAb8KacAi1h/bC6Gigy9p5ANbrPvpnjTIb7i9I11/8Df6FeSxpJDK3vy4g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@mapbox/node-pre-gyp': 1.0.9
|
||||
'@phc/format': 1.0.0
|
||||
node-addon-api: 4.3.0
|
||||
node-addon-api: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
@ -4824,7 +4819,7 @@ packages:
|
|||
resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@babel/helper-plugin-utils': 7.16.7
|
||||
'@babel/helper-plugin-utils': 7.17.12
|
||||
'@istanbuljs/load-nyc-config': 1.1.0
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
istanbul-lib-instrument: 5.2.0
|
||||
|
@ -4838,7 +4833,7 @@ packages:
|
|||
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
|
||||
dependencies:
|
||||
'@babel/template': 7.16.7
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/types': 7.18.4
|
||||
'@types/babel__core': 7.1.19
|
||||
'@types/babel__traverse': 7.17.1
|
||||
dev: true
|
||||
|
@ -5369,7 +5364,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/co/4.6.0:
|
||||
resolution: {integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=}
|
||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
dev: true
|
||||
|
||||
|
@ -6788,7 +6783,7 @@ packages:
|
|||
strip-final-newline: 2.0.0
|
||||
|
||||
/exit/0.1.2:
|
||||
resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=}
|
||||
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dev: true
|
||||
|
||||
|
@ -7332,7 +7327,6 @@ packages:
|
|||
minimatch: 3.1.2
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
dev: true
|
||||
|
||||
/global-dirs/0.1.1:
|
||||
resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
|
||||
|
@ -7800,7 +7794,7 @@ packages:
|
|||
mute-stream: 0.0.8
|
||||
ora: 5.4.1
|
||||
run-async: 2.4.1
|
||||
rxjs: 7.5.5
|
||||
rxjs: 7.5.6
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
through: 2.3.8
|
||||
|
@ -8171,7 +8165,7 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/parser': 7.17.10
|
||||
'@babel/parser': 7.18.5
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
istanbul-lib-coverage: 3.2.0
|
||||
semver: 6.3.0
|
||||
|
@ -8322,7 +8316,7 @@ packages:
|
|||
chalk: 4.1.2
|
||||
ci-info: 3.3.0
|
||||
deepmerge: 4.2.2
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.10
|
||||
jest-circus: 28.1.0
|
||||
jest-environment-node: 28.1.0
|
||||
|
@ -8597,7 +8591,7 @@ packages:
|
|||
cjs-module-lexer: 1.2.2
|
||||
collect-v8-coverage: 1.0.1
|
||||
execa: 5.1.1
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.10
|
||||
jest-haste-map: 28.1.0
|
||||
jest-message-util: 28.1.0
|
||||
|
@ -8617,10 +8611,10 @@ packages:
|
|||
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
'@babel/generator': 7.17.10
|
||||
'@babel/generator': 7.18.2
|
||||
'@babel/plugin-syntax-typescript': 7.17.10_@babel+core@7.17.10
|
||||
'@babel/traverse': 7.17.10
|
||||
'@babel/types': 7.17.10
|
||||
'@babel/traverse': 7.18.5
|
||||
'@babel/types': 7.18.4
|
||||
'@jest/expect-utils': 28.1.0
|
||||
'@jest/transform': 28.1.0
|
||||
'@jest/types': 28.1.0
|
||||
|
@ -9043,7 +9037,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/lodash.memoize/4.1.2:
|
||||
resolution: {integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=}
|
||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||
dev: true
|
||||
|
||||
/lodash.merge/4.6.2:
|
||||
|
@ -9950,8 +9944,8 @@ packages:
|
|||
tslib: 2.4.0
|
||||
dev: true
|
||||
|
||||
/node-addon-api/4.3.0:
|
||||
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
|
||||
/node-addon-api/5.0.0:
|
||||
resolution: {integrity: sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==}
|
||||
dev: false
|
||||
|
||||
/node-cache/5.1.2:
|
||||
|
@ -11246,7 +11240,7 @@ packages:
|
|||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
|
||||
/run-async/2.4.1:
|
||||
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
|
||||
|
@ -11266,8 +11260,8 @@ packages:
|
|||
tslib: 1.14.1
|
||||
dev: true
|
||||
|
||||
/rxjs/7.5.5:
|
||||
resolution: {integrity: sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==}
|
||||
/rxjs/7.5.6:
|
||||
resolution: {integrity: sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==}
|
||||
dependencies:
|
||||
tslib: 2.4.0
|
||||
dev: true
|
||||
|
@ -11533,7 +11527,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/sprintf-js/1.0.3:
|
||||
resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=}
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
dev: true
|
||||
|
||||
/stack-trace/0.0.10:
|
||||
|
@ -11861,7 +11855,7 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
minimatch: 3.1.2
|
||||
dev: true
|
||||
|
||||
|
@ -11986,6 +11980,40 @@ packages:
|
|||
tslib: 2.4.0
|
||||
dev: false
|
||||
|
||||
/ts-jest/28.0.2_ps5qfvt5fosg52obpfzuxthwve:
|
||||
resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@babel/core': '>=7.0.0-beta.0 <8'
|
||||
'@types/jest': ^27.0.0
|
||||
babel-jest: ^28.0.0
|
||||
esbuild: '*'
|
||||
jest: ^28.0.0
|
||||
typescript: '>=4.3'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
'@types/jest':
|
||||
optional: true
|
||||
babel-jest:
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/core': 7.17.10
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
jest: 28.1.0_@types+node@17.0.31
|
||||
jest-util: 28.1.0
|
||||
json5: 2.2.1
|
||||
lodash.memoize: 4.1.2
|
||||
make-error: 1.3.6
|
||||
semver: 7.3.7
|
||||
typescript: 4.6.4
|
||||
yargs-parser: 20.2.9
|
||||
dev: true
|
||||
|
||||
/ts-jest/28.0.2_z3fx76c5ksuwr36so7o5uc2kcy:
|
||||
resolution: {integrity: sha512-IOZMb3D0gx6IHO9ywPgiQxJ3Zl4ECylEFwoVpENB55aTn5sdO0Ptyx/7noNBxAaUff708RqQL4XBNxxOVjY0vQ==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -25,7 +25,5 @@ rm -rf "${ROOT_FOLDER}/app-data"
|
|||
rm -rf "${ROOT_FOLDER}/data/postgres"
|
||||
mkdir -p "${ROOT_FOLDER}/app-data"
|
||||
|
||||
# Put {"installed":""} in state/apps.json
|
||||
echo '{"installed":""}' >"${ROOT_FOLDER}/state/apps.json"
|
||||
|
||||
cd "$ROOT_FOLDER"
|
||||
"${ROOT_FOLDER}/scripts/start.sh"
|
||||
|
|
|
@ -11,5 +11,7 @@ TIPI_VERSION=<tipi_version>
|
|||
JWT_SECRET=<jwt_secret>
|
||||
ROOT_FOLDER_HOST=<root_folder>
|
||||
NGINX_PORT=<nginx_port>
|
||||
NGINX_PORT_SSL=<nginx_port_ssl>
|
||||
PROXY_PORT=<proxy_port>
|
||||
POSTGRES_PASSWORD=<postgres_password>
|
||||
POSTGRES_PASSWORD=<postgres_password>
|
||||
DOMAIN=<domain>
|
|
@ -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…
Add table
Reference in a new issue