Merge pull request #10 from meienberger/app/pihole

App/pihole
This commit is contained in:
Nicolas Meienberger 2022-05-03 21:01:16 +00:00 committed by GitHub
commit d4539f2207
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 1656 additions and 14804 deletions

2
.gitignore vendored
View file

@ -16,7 +16,9 @@ tipi.config.json
!nignx/.gitkeep
media/data/movies/*
media/data/tv/*
!media/data/movies/.gitkeep
!media/data/tv/.gitkeep
media/torrents/*
!media/torrents/.gitkeep

View file

@ -49,20 +49,6 @@
- name: Make docker-compose executable
shell: chmod +x /usr/local/bin/docker-compose
# - name: Disable iptables for docker by editing file /etc/default/docker
# lineinfile:
# path: /etc/default/docker
# regexp: "^DOCKER_OPTS="
# line: "DOCKER_OPTS=\"--iptables=false\""
# state: present
# - name: Create file /etc/docker/daemon.json with content hello world written inside
# lineinfile:
# path: /etc/docker/daemon.json
# regexp: "^"
# line: "{ \"iptables\": false }"
# state: present
- name: Create group docker
group:
name: docker

View file

@ -1,6 +1,3 @@
# - name: Change machine hostname to tipi.local
# shell: hostnamectl set-hostname tipi.local
# - name: Update packages
# apt:
# update_cache: yes

View file

@ -0,0 +1,12 @@
{
"name": "File Browser",
"port": 8096,
"id": "filebrowser",
"description": "Reliable and Performant File Management Desktop Sync and File Sharing",
"short_desc": "Access your homeserver files from your browser",
"author": "",
"website": "https://filebrowser.org/",
"source": "https://github.com/filebrowser/filebrowser",
"image": "https://avatars.githubusercontent.com/u/35781395?s=200&v=4",
"form_fields": {}
}

View file

@ -0,0 +1,8 @@
{
"port": 80,
"baseURL": "",
"address": "",
"log": "stdout",
"database": "/database/filebrowser.db",
"root": "/srv"
}

View file

@ -0,0 +1,15 @@
services:
filebrowser:
container_name: filebrowser
image: filebrowser/filebrowser:s6
ports:
- ${APP_PORT}:80
environment:
- PUID=1000
- PGID=1000
volumes:
- ${ROOT_FOLDER}:/srv
- ${APP_DATA_DIR}/data/filebrowser.db:/database/filebrowser.db
- ${APP_DATA_DIR}/data/settings.json:/config/settings.json
networks:
- tipi_main_network

View file

@ -0,0 +1,11 @@
{
"name": "Invidious",
"port": 8095,
"id": "invidious",
"description": "",
"short_desc": "",
"author": "",
"source": "https://github.com/iv-org/invidious",
"image": "https://raw.githubusercontent.com/iv-org/invidious/master/assets/invidious-colored-vector.svg",
"form_fields": {}
}

View file

@ -0,0 +1,45 @@
version: "3"
services:
invidious:
user: 1000:1000
container_name: invidious
image: quay.io/invidious/invidious:latest-arm64
# image: quay.io/invidious/invidious:latest-arm64 # ARM64/AArch64 devices
restart: unless-stopped
ports:
- "${APP_PORT}:3000"
environment:
# Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax:
# https://github.com/iv-org/invidious/blob/master/config/config.example.yml
INVIDIOUS_CONFIG: |
db:
dbname: invidious
user: tipi
password: tipi
host: invidious-db
port: 5432
check_tables: true
healthcheck:
test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1
interval: 30s
timeout: 5s
retries: 2
depends_on:
- invidious-db
invidious-db:
user: 1000:1000
container_name: invidious-db
image: docker.io/library/postgres:14
restart: unless-stopped
volumes:
- ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
- ${APP_DATA_DIR}/data/sql:/config/sql
- ./docker/init-invidious-db.sh:/docker-entrypoint-initdb.d/init-invidious-db.sh
environment:
POSTGRES_DB: invidious
POSTGRES_USER: tipi
POSTGRES_PASSWORD: tipi
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]

13
apps/jackett/config.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "Jackett",
"port": 8097,
"id": "jackett",
"description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
"short_desc": "API Support for your favorite torrent trackers ",
"author": "",
"source": "https://github.com/Jackett/Jackett",
"image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
"form_fields": {
}
}

View file

@ -0,0 +1,20 @@
version: "3.7"
services:
jackett:
image: lscr.io/linuxserver/jackett
container_name: jackett
environment:
- PUID=1000
- PGID=1000
- TZ=${TZ}
- AUTO_UPDATE=true
dns:
- ${DNS_IP}
volumes:
- ${APP_DATA_DIR}/data:/config
- ${ROOT_FOLDER}/media/torrents:/downloads
ports:
- ${APP_PORT}:9117
restart: unless-stopped
networks:
- tipi_main_network

View file

@ -6,7 +6,7 @@ services:
container_name: jellyfin
volumes:
- ${APP_DATA_DIR}/data/config:/config
- ${ROOT_FOLDER}/media/data/movies:/data/media
- ${ROOT_FOLDER}/media/data:/data/media
environment:
- PUID=1000
- PGID=1000

13
apps/joplin/README.md Normal file
View file

@ -0,0 +1,13 @@
Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in Markdown format.
Notes exported from Evernote can be imported into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
The notes can be securely synchronised using end-to-end encryption with various cloud services including Nextcloud, Dropbox, OneDrive and Joplin Cloud.
Full text search is available on all platforms to quickly find the information you need. The app can be customised using plugins and themes, and you can also easily create your own.
The application is available for Windows, Linux, macOS, Android and iOS. A Web Clipper, to save web pages and screenshots from your browser, is also available for Firefox and Chrome.
## Credentials
Username: admin@localhost
Password: admin

12
apps/joplin/config.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "Joplin Server",
"port": 8099,
"id": "joplin",
"description": "",
"short_desc": "Note taking and to-do application with synchronisation",
"author": "https://github.com/laurent22",
"source": "https://github.com/laurent22/joplin",
"website": "https://joplinapp.org",
"image": "https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png",
"form_fields": {}
}

View file

@ -0,0 +1,38 @@
version: "3.7"
services:
db-joplin:
container_name: db-joplin
image: postgres:14.2
volumes:
- ${APP_DATA_DIR}/data/postgres:/var/lib/postgresql/data
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=tipi
- POSTGRES_USER=tipi
- POSTGRES_DB=joplin
networks:
- tipi_main_network
joplin:
container_name: joplin
image: florider89/joplin-server:2.7.4
restart: unless-stopped
depends_on:
- db-joplin
ports:
- ${APP_PORT}:22300
dns:
- ${DNS_IP}
environment:
- APP_PORT=22300
- APP_BASE_URL=http://${INTERNAL_IP}:${APP_PORT}
- DB_CLIENT=pg
- POSTGRES_PASSWORD=tipi
- POSTGRES_USER=tipi
- POSTGRES_DATABASE=joplin
- POSTGRES_PORT=5432
- POSTGRES_HOST=db-joplin
- MAX_TIME_DRIFT=0
networks:
- tipi_main_network

12
apps/n8n/config.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "n8n",
"port": 8094,
"id": "n8n",
"description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",
"short_desc": "Workflow Automation Tool. Alternative to Zapier",
"author": "n8n.io",
"source": "https://github.com/n8n-io/n8n",
"website": "https://n8n.io/",
"image": "https://avatars.githubusercontent.com/u/45487711?s=200&v=4",
"form_fields": {}
}

View file

@ -0,0 +1,36 @@
version: "3.7"
services:
db-n8n:
container_name: db-n8n
image: postgres:14.2
restart: on-failure
volumes:
- ${APP_DATA_DIR}/data/db:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=tipi
- POSTGRES_USER=tipi
- POSTGRES_DB=n8n
networks:
- tipi_main_network
n8n:
container_name: n8n
image: n8nio/n8n:0.174.0
restart: unless-stopped
ports:
- ${APP_PORT}:5678
volumes:
- ${APP_DATA_DIR}/data/n8n:/home/node/.n8n
command: /bin/sh -c "sleep 5; n8n start"
environment:
- DB-TYPE=postgresdb
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_HOST=db-n8n
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_USER=tipi
- DB_POSTGRESDB_PASSWORD=tipi
depends_on:
- db-n8n
networks:
- tipi_main_network

View file

@ -13,7 +13,7 @@ services:
pihole:
depends_on: [unbound]
container_name: pihole:2022.04.3
container_name: pihole
image: pihole/pihole:latest
restart: unless-stopped
hostname: pihole

View file

@ -1,22 +1,5 @@
version: "3.7"
services:
jackett:
image: lscr.io/linuxserver/jackett
container_name: jackett
environment:
- PUID=1000
- PGID=1000
- TZ=${TZ}
- AUTO_UPDATE=true
volumes:
- ${APP_DATA_DIR}/data/jackett:/config
- ${ROOT_FOLDER}/media/torrents:/downloads
ports:
- 9117:9117
restart: unless-stopped
networks:
- tipi_main_network
radarr:
image: lscr.io/linuxserver/radarr
container_name: radarr
@ -24,8 +7,10 @@ services:
- PUID=1000
- PGID=1000
- TZ=${TZ}
dns:
- ${DNS_IP}
volumes:
- ${APP_DATA_DIR}/data/radarr:/config
- ${APP_DATA_DIR}/data:/config
- ${ROOT_FOLDER}/media/data/movies:/movies #optional
- ${ROOT_FOLDER}/media/torrents:/downloads #optional
ports:

13
apps/sonarr/config.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "Sonarr",
"port": 8098,
"id": "sonarr",
"description": "",
"short_desc": "",
"author": "",
"source": "",
"image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
"form_fields": {
}
}

View file

@ -0,0 +1,20 @@
version: "3.7"
services:
radarr:
image: lscr.io/linuxserver/sonarr
container_name: sonarr
environment:
- PUID=1000
- PGID=1000
- TZ=${TZ}
dns:
- ${DNS_IP}
volumes:
- ${APP_DATA_DIR}/data:/config
- ${ROOT_FOLDER}/media/data/tv:/tv #optional
- ${ROOT_FOLDER}/media/torrents:/downloads #optional
ports:
- ${APP_PORT}:8989
restart: unless-stopped
networks:
- tipi_main_network

View file

@ -1,7 +1,8 @@
version: "3.7"
services:
server:
syncthing:
container_name: syncthing
image: syncthing/syncthing:1.19
stop_grace_period: 1m
hostname: tipi

View file

@ -18,6 +18,7 @@
"final-form": "^4.20.6",
"framer-motion": "^6",
"immer": "^9.0.12",
"js-cookie": "^3.0.1",
"next": "12.1.4",
"react": "18.0.0",
"react-dom": "18.0.0",
@ -29,6 +30,7 @@
"zustand": "^3.7.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.2",
"@types/node": "17.0.23",
"@types/react": "17.0.43",
"@types/react-dom": "17.0.14",

View file

@ -6,16 +6,17 @@ interface IProps {
placeholder?: string;
error?: string;
type?: Parameters<typeof Input>[0]['type'];
label: string;
label?: string;
className?: string;
isInvalid?: boolean;
size?: Parameters<typeof Input>[0]['size'];
}
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, ...rest }) => {
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, ...rest }) => {
return (
<div className={clsx('transition-all', className)}>
<label>{label}</label>
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} {...rest} />
{label && <label>{label}</label>}
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
{isInvalid && <span className="text-red-500 text-sm">{error}</span>}
</div>
);

View file

@ -48,6 +48,7 @@ const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
<Head>
<title>Tipi</title>
</Head>
<Flex height="100vh" direction="column">
<MenuDrawer isOpen={isOpen} onClose={onClose}>
<Menu />

View file

@ -1,5 +1,6 @@
import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
import { FaRegMoon } from 'react-icons/fa';
import { FiLogOut } from 'react-icons/fi';
import Package from '../../../package.json';
import { Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
import React from 'react';
@ -7,10 +8,12 @@ import Link from 'next/link';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { IconType } from 'react-icons';
import { useAuthStore } from '../../state/authStore';
const SideMenu: React.FC = () => {
const router = useRouter();
const { colorMode, setColorMode } = useColorMode();
const { logout } = useAuthStore();
const path = router.pathname.split('/')[1];
const renderMenuItem = (title: string, name: string, Icon: IconType) => {
@ -49,6 +52,10 @@ const SideMenu: React.FC = () => {
<Flex flex="1" />
<List>
<div className="mx-3">
<ListItem onClick={logout} className="cursor-pointer hover:font-bold flex items-center mb-5">
<FiLogOut size={20} className="mr-3" />
<p className="flex-1">Log out</p>
</ListItem>
<ListItem className="flex items-center">
<FaRegMoon size={20} className="mr-3" />
<p className="flex-1">Dark mode</p>

View file

@ -0,0 +1,12 @@
import { Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
const LoadingScreen = () => {
return (
<Flex height="100vh" alignItems="center" justifyContent="center">
<Spinner size="lg" />
</Flex>
);
};
export default LoadingScreen;

View file

@ -17,6 +17,7 @@ const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
params,
data,
url: `${BASE_URL}${endpoint}`,
withCredentials: true,
});
if (response.data.error) {

View file

@ -13,6 +13,7 @@ interface FormField {
label: string;
max?: number;
min?: number;
hint?: string;
required?: boolean;
env_variable: string;
}
@ -49,3 +50,8 @@ export enum AppStatus {
STOPPING = 'stopping',
STARTING = 'starting',
}
export interface IUser {
name: string;
email: string;
}

View file

@ -3,7 +3,6 @@ import React from 'react';
import { FiExternalLink } from 'react-icons/fi';
import { AppConfig } from '../../../core/types';
import { useAppsStore } from '../../../state/appsStore';
import { useNetworkStore } from '../../../state/networkStore';
import AppActions from '../components/AppActions';
import InstallModal from '../components/InstallModal';
import StopModal from '../components/StopModal';
@ -21,7 +20,6 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const { internalIp } = useNetworkStore();
const { install, update, uninstall, stop, start, fetchApp } = useAppsStore();
const handleError = (error: unknown) => {
@ -88,7 +86,7 @@ const AppDetails: React.FC<IProps> = ({ app }) => {
};
const handleOpen = () => {
window.open(`http://${internalIp}:${app.port}`, '_blank');
window.open(`http://${process.env.INTERNAL_IP}:${app.port}`, '_blank');
};
return (

View file

@ -0,0 +1,28 @@
import { Container, Flex, SlideFade, Text } from '@chakra-ui/react';
import React from 'react';
interface IProps {
title: string;
description: string;
}
const AuthFormLayout: React.FC<IProps> = ({ children, title, description }) => {
return (
<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} />
<Text className="text-xl md:text-2xl lg:text-5xl font-bold" size="3xl">
{title}
</Text>
<Text className="md:text-lg lg:text-2xl text-center" color="gray.500">
{description}
</Text>
{children}
</SlideFade>
</Flex>
</Container>
);
};
export default AuthFormLayout;

View file

@ -0,0 +1,57 @@
import { Button } from '@chakra-ui/react';
import React from 'react';
import { Field, Form } from 'react-final-form';
import validator from 'validator';
import FormInput from '../../../components/Form/FormInput';
type FormValues = { email: string; password: string };
interface IProps {
onSubmit: (values: FormValues) => void;
loading: boolean;
}
const LoginForm: React.FC<IProps> = ({ onSubmit, loading }) => {
const validateFields = (values: FormValues) => {
const errors: Record<string, string> = {};
if (!validator.isEmail(values.email || '')) {
errors.email = 'Invalid email';
}
if (!values.password) {
errors.password = 'Required';
}
return errors;
};
return (
<Form<FormValues>
onSubmit={onSubmit}
validateOnBlur={true}
validate={(values) => validateFields(values)}
render={({ handleSubmit, validating, submitting }) => (
<form className="flex flex-col" onSubmit={handleSubmit}>
<Field
name="email"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
)}
/>
<Field
name="password"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Password" type="password" {...input} />
)}
/>
<Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
Login
</Button>
</form>
)}
/>
);
};
export default LoginForm;

View file

@ -0,0 +1,75 @@
import { Button } from '@chakra-ui/react';
import React from 'react';
import { Field, Form } from 'react-final-form';
import validator from 'validator';
import FormInput from '../../../components/Form/FormInput';
interface IProps {
onSubmit: (values: FormValues) => void;
loading: boolean;
}
type FormValues = { email: string; password: string; passwordConfirm: string };
const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
const validateFields = (values: FormValues) => {
const errors: Record<string, string> = {};
if (!validator.isEmail(values.email || '')) {
errors.email = 'Invalid email';
}
if (!values.password) {
errors.password = 'Required';
}
if (values.password !== values.passwordConfirm) {
errors.passwordConfirm = 'Passwords do not match';
}
return errors;
};
return (
<Form<FormValues>
onSubmit={onSubmit}
validateOnBlur={true}
validate={(values) => validateFields(values)}
render={({ handleSubmit, validating, submitting }) => (
<form className="flex flex-col" onSubmit={handleSubmit}>
<Field
name="email"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Email" {...input} />
)}
/>
<Field
name="password"
render={({ input, meta }) => (
<FormInput size="lg" className="mt-3 w-full" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} placeholder="Password" type="password" {...input} />
)}
/>
<Field
name="passwordConfirm"
render={({ input, meta }) => (
<FormInput
size="lg"
className="mt-3 w-full"
error={meta.error}
isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
placeholder="Repeat password"
type="password"
{...input}
/>
)}
/>
<Button isLoading={validating || submitting || loading} className="mt-2" colorScheme="green" type="submit">
Enter
</Button>
</form>
)}
/>
);
};
export default RegisterForm;

View file

@ -0,0 +1,36 @@
import React, { useEffect, useState } from 'react';
import LoadingScreen from '../../../components/LoadingScreen';
import { useAuthStore } from '../../../state/authStore';
import Login from './Login';
import Onboarding from './Onboarding';
const AuthWrapper: React.FC = ({ children }) => {
const [initialLoad, setInitialLoad] = useState(true);
const { configured, user, me, fetchConfigured } = useAuthStore();
useEffect(() => {
const fetchUser = async () => {
await me();
await fetchConfigured();
setInitialLoad(false);
};
if (!user) fetchUser();
}, [fetchConfigured, me, user]);
if (initialLoad && !user) {
return <LoadingScreen />;
}
if (user) {
return <>{children}</>;
}
if (!configured) {
return <Onboarding />;
}
return <Login />;
};
export default AuthWrapper;

View file

@ -0,0 +1,41 @@
import { useToast } from '@chakra-ui/react';
import React from 'react';
import { useAuthStore } from '../../../state/authStore';
import AuthFormLayout from '../components/AuthFormLayout';
import LoginForm from '../components/LoginForm';
type FormValues = { email: string; password: string };
const Login: React.FC = () => {
const { me, login, loading } = useAuthStore();
const toast = useToast();
const handleError = (error: unknown) => {
if (error instanceof Error) {
toast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleLogin = async (values: FormValues) => {
try {
await login(values.email, values.password);
await me();
} catch (error) {
handleError(error);
}
};
return (
<AuthFormLayout title="Welcome back" description="Enter your credentials to login to your Tipi">
<LoginForm onSubmit={handleLogin} loading={loading} />
</AuthFormLayout>
);
};
export default Login;

View file

@ -0,0 +1,39 @@
import { useToast } from '@chakra-ui/react';
import React from 'react';
import { useAuthStore } from '../../../state/authStore';
import AuthFormLayout from '../components/AuthFormLayout';
import RegisterForm from '../components/RegisterForm';
const Onboarding: React.FC = () => {
const toast = useToast();
const { me, register, loading } = useAuthStore();
const handleError = (error: unknown) => {
if (error instanceof Error) {
toast({
title: 'Error',
description: error.message,
status: 'error',
position: 'top',
isClosable: true,
});
}
};
const handleRegister = async (values: { email: string; password: string }) => {
try {
await register(values.email, values.password);
await me();
} catch (error) {
handleError(error);
}
};
return (
<AuthFormLayout title="Welcome to your Tipi" description="Register your account to get started">
<RegisterForm onSubmit={handleRegister} loading={loading} />
</AuthFormLayout>
);
};
export default Onboarding;

View file

@ -0,0 +1,25 @@
import { Progress, Stat, StatHelpText, StatLabel, StatNumber } from '@chakra-ui/react';
import React from 'react';
import { IconType } from 'react-icons';
interface IProps {
icon: IconType;
progress: number;
title: string;
subtitle: string;
metric: string;
}
const SystemStat: React.FC<IProps> = ({ icon: Icon, progress, title, subtitle, metric }) => {
return (
<Stat className="border-2 px-5 py-3 rounded-lg">
<StatLabel>{title}</StatLabel>
<StatNumber>{metric}</StatNumber>
<StatHelpText>{subtitle}</StatHelpText>
<Progress value={progress} size="sm" />
<Icon size={30} className="absolute top-3 right-3" />
</Stat>
);
};
export default SystemStat;

View file

@ -0,0 +1,53 @@
import { SimpleGrid, Text } from '@chakra-ui/react';
import React, { useEffect } from 'react';
import { BsCpu } from 'react-icons/bs';
import { FaMemory } from 'react-icons/fa';
import { FiHardDrive } from 'react-icons/fi';
import { useSytemStore } from '../../../state/systemStore';
import SystemStat from '../components/SystemStat';
const Dashboard: React.FC = () => {
const { fetchDiskSpace, fetchCpuLoad, fetchMemoryLoad, disk, cpuLoad, memory } = useSytemStore();
useEffect(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
const interval = setInterval(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
}, 10000);
return () => clearInterval(interval);
}, [fetchCpuLoad, fetchDiskSpace, fetchMemoryLoad]);
// Convert bytes to GB
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
const diskUsed = diskSize - diskFree;
const percentUsed = Math.round((diskUsed / diskSize) * 100);
const memoryTotal = Math.round(memory?.total / 1024 / 1024 / 1024);
const memoryFree = Math.round(memory?.free / 1024 / 1024 / 1024);
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
return (
<>
<Text fontSize="3xl" className="font-bold">
Tipi Dashboard
</Text>
<Text fontSize="xl" color="gray.500">
Welcome home!
</Text>
<SimpleGrid className="mt-5" minChildWidth="180px" spacing="20px">
<SystemStat title="Disk space" metric={`${diskUsed} GB`} subtitle={`Used out of ${diskSize} GB`} icon={FiHardDrive} progress={percentUsed} />
<SystemStat title="CPU Load" metric={`${cpuLoad.toFixed(2)}%`} subtitle="Uninstall apps if there is to much load" icon={BsCpu} progress={cpuLoad} />
<SystemStat title="Memory Used" metric={`${percentUsedMemory}%`} subtitle={`${memoryTotal} GB`} icon={FaMemory} progress={percentUsedMemory} />
</SimpleGrid>
</>
);
};
export default Dashboard;

View file

@ -3,20 +3,15 @@ import '@fontsource/open-sans/400.css';
import '../styles/globals.css';
import { ChakraProvider } from '@chakra-ui/react';
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
import { useNetworkStore } from '../state/networkStore';
import { theme } from '../styles/theme';
import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
function MyApp({ Component, pageProps }: AppProps) {
const { fetchInternalIp } = useNetworkStore();
useEffect(() => {
fetchInternalIp();
}, [fetchInternalIp]);
return (
<ChakraProvider theme={theme}>
<Component {...pageProps} />
<AuthWrapper>
<Component {...pageProps} />
</AuthWrapper>
</ChakraProvider>
);
}

View file

@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import si from 'systeminformation';
type Data = Awaited<ReturnType<typeof si.currentLoad>>;
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
const cpuLoad = await si.currentLoad();
res.status(200).json(cpuLoad);
};
export default handler;

View file

@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import si from 'systeminformation';
type Data = Awaited<ReturnType<typeof si.fsSize>>;
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
const disk = await si.fsSize();
res.status(200).json(disk);
};
export default handler;

View file

@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import si from 'systeminformation';
type Data = Awaited<ReturnType<typeof si.mem>>;
const handler = async (req: NextApiRequest, res: NextApiResponse<Data>) => {
const memory = await si.mem();
res.status(200).json(memory);
};
export default handler;

View file

@ -1,70 +1,11 @@
import { Progress, SimpleGrid, Stat, StatHelpText, StatLabel, StatNumber, Text } from '@chakra-ui/react';
import type { NextPage } from 'next';
import { useEffect } from 'react';
import Layout from '../components/Layout';
import { useSytemStore } from '../state/systemStore';
import { BsCpu } from 'react-icons/bs';
import { FiHardDrive } from 'react-icons/fi';
import { FaMemory } from 'react-icons/fa';
import Dashboard from '../modules/Dashboard/containers/Dashboard';
const Home: NextPage = () => {
const { fetchDiskSpace, fetchCpuLoad, fetchMemoryLoad, disk, cpuLoad, memory } = useSytemStore();
useEffect(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
const interval = setInterval(() => {
fetchDiskSpace();
fetchCpuLoad();
fetchMemoryLoad();
}, 10000);
return () => clearInterval(interval);
}, [fetchCpuLoad, fetchDiskSpace]);
// Convert bytes to GB
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
const diskUsed = diskSize - diskFree;
const percentUsed = Math.round((diskUsed / diskSize) * 100);
const memoryTotal = Math.round(memory?.total / 1024 / 1024 / 1024);
const memoryUsed = Math.round(memory?.used / 1024 / 1024 / 1024);
const percentUsedMemory = Math.round((memoryUsed / memoryTotal) * 100);
return (
<Layout>
<Text fontSize="3xl" className="font-bold">
Tipi Dashboard
</Text>
<Text fontSize="xl" color="gray.500">
Welcome home!
</Text>
<SimpleGrid className="mt-5" minChildWidth="180px" spacing="20px">
<Stat className="border-2 px-5 py-3 rounded-lg">
<StatLabel>Disk space</StatLabel>
<StatNumber>{diskUsed} GB</StatNumber>
<StatHelpText>Used out of {diskSize} GB</StatHelpText>
<Progress value={percentUsed} size="sm" />
<FiHardDrive size={30} className="absolute top-3 right-3" />
</Stat>
<Stat className="border-2 px-5 py-3 rounded-lg">
<StatLabel>CPU Load</StatLabel>
<StatNumber>{cpuLoad.toFixed(2)}%</StatNumber>
<StatHelpText>Uninstall apps if there is to much load</StatHelpText>
<Progress value={cpuLoad} size="sm" />
<BsCpu size={30} className="absolute top-3 right-3" />
</Stat>
<Stat className="border-2 px-5 py-3 rounded-lg">
<StatLabel>Memory Used</StatLabel>
<StatNumber>{percentUsedMemory}%</StatNumber>
<StatHelpText>{memoryTotal} GB</StatHelpText>
<Progress value={percentUsedMemory} size="sm" />
<FaMemory size={30} className="absolute top-3 right-3" />
</Stat>
</SimpleGrid>
<Dashboard />
</Layout>
);
};

View file

@ -0,0 +1,75 @@
import create from 'zustand';
import Cookies from 'js-cookie';
import api from '../core/api';
import { IUser } from '../core/types';
type AppsStore = {
user: IUser | null;
configured: boolean;
me: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
fetchConfigured: () => Promise<void>;
loading: boolean;
};
export const useAuthStore = create<AppsStore>((set) => ({
user: null,
configured: false,
loading: false,
me: async () => {
try {
set({ loading: true });
const response = await api.fetch<{ user: IUser | null }>({ endpoint: '/auth/me' });
set({ user: response.user, loading: false });
} catch (error) {
set({ loading: false, user: null });
}
},
login: async (email: string, password: string) => {
set({ loading: true });
try {
const response = await api.fetch<{ user: IUser }>({
endpoint: '/auth/login',
method: 'post',
data: { email, password },
});
set({ user: response.user, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
logout: async () => {
// Cookies.remove('token2');
Cookies.remove('tipi_token');
set({ user: null, loading: false });
},
register: async (email: string, password: string) => {
set({ loading: true });
try {
const response = await api.fetch<{ user: IUser }>({
endpoint: '/auth/register',
method: 'post',
data: { email, password },
});
set({ user: response.user, loading: false });
} catch (e) {
set({ loading: false });
throw e;
}
},
fetchConfigured: async () => {
try {
const response = await api.fetch<{ configured: boolean }>({ endpoint: '/auth/configured' });
set({ configured: response.configured });
} catch (e) {
set({ configured: false });
}
},
}));

View file

@ -904,6 +904,11 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
integrity sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==
"@types/js-cookie@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.2.tgz#451eaeece64c6bdac8b2dde0caab23b085899e0d"
integrity sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@ -2296,6 +2301,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
js-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414"
integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

0
media/data/tv/.gitkeep Normal file
View file

BIN
screenshots/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

View file

@ -122,7 +122,7 @@ if [[ "$command" = "uninstall" ]]; then
echo "Deleting app data for app ${app}..."
if [[ -d "${app_data_dir}" ]]; then
rm -rf "${app_data_dir}"
sudo rm -rf "${app_data_dir}"
fi
echo "Successfully uninstalled app ${app}"

View file

@ -1,6 +1,14 @@
#!/usr/bin/env bash
set -e # Exit immediately if a command exits with a non-zero status.
# Get field from json file
function get_json_field() {
local json_file="$1"
local field="$2"
echo $(jq -r ".${field}" "${json_file}")
}
# use greadlink instead of readlink on osx
if [[ "$(uname)" == "Darwin" ]]; then
readlink=greadlink
@ -10,8 +18,17 @@ fi
ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
STATE_FOLDER="${ROOT_FOLDER}/state"
INTERNAL_IP="$(hostname -I | awk '{print $1}')"
DNS_IP=9.9.9.9
# Get dns ip if pihole is installed
str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
# if pihole is present in str add it as DNS
if [[ $str = *"pihole"* ]]; then
DNS_IP=10.21.21.201
fi
PUID="$(id -u)"
PGID="$(id -g)"
TZ="$(cat /etc/timezone | sed 's/\//\\\//g' || echo "Europe/Berlin")"
@ -50,6 +67,7 @@ ENV_FILE="$ROOT_FOLDER/templates/.env"
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
for template in "${ENV_FILE}"; do
sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
sed -i "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
sed -i "s/<puid>/${PUID}/g" "${template}"
sed -i "s/<pgid>/${PGID}/g" "${template}"
@ -66,20 +84,14 @@ docker-compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --b
exit 1
}
# Get field from json file
function get_json_field() {
local json_file="$1"
local field="$2"
echo $(jq -r ".${field}" "${json_file}")
}
str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
apps_to_start=($str)
for app in "${apps_to_start[@]}"; do
"${ROOT_FOLDER}/scripts/app.sh" start $app
done
# for app in "${apps_to_start[@]}"; do
# "${ROOT_FOLDER}/scripts/app.sh" start $app
# done
echo "Tipi is now running"
echo ""

File diff suppressed because it is too large Load diff

View file

@ -16,22 +16,33 @@
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"helmet": "^5.0.2",
"internal-ip": "^7.0.0",
"jsonwebtoken": "^8.5.1",
"node-port-scanner": "^3.0.1",
"p-iteration": "^1.1.8",
"passport": "^0.5.2",
"passport-cookie": "^1.0.9",
"passport-http-bearer": "^1.0.1",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.2",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/jsonwebtoken": "^8.5.8",
"@types/passport": "^1.0.7",
"@types/passport-http-bearer": "^1.0.37",
"@types/tcp-port-used": "^1.0.1",
"@types/validator": "^13.7.2",
"concurrently": "^7.1.0",

View file

@ -1 +1,18 @@
export const appNames = ['nextcloud', 'freshrss', 'anonaddy', 'filerun', 'wg-easy', 'radarr', 'transmission', 'jellyfin', 'pihole', 'tailscale'];
export const appNames = [
'nextcloud',
'syncthing',
'freshrss',
'anonaddy',
'filebrowser',
'wg-easy',
'jackett',
'sonarr',
'radarr',
'transmission',
'jellyfin',
'pihole',
'tailscale',
'n8n',
'invidious',
'joplin',
];

View file

@ -3,11 +3,12 @@ import * as dotenv from 'dotenv';
interface IConfig {
NODE_ENV: string;
ROOT_FOLDER: string;
JWT_SECRET: string;
}
dotenv.config();
const { NODE_ENV = 'development', ROOT_FOLDER = '' } = process.env;
const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '' } = process.env;
const missing = [];
@ -20,6 +21,7 @@ if (missing.length > 0) {
const config: IConfig = {
NODE_ENV,
ROOT_FOLDER,
JWT_SECRET,
};
export default config;

View file

@ -15,6 +15,8 @@ interface FormField {
env_variable: string;
}
export type Maybe<T> = T | null | undefined;
export interface AppConfig {
id: string;
port: number;
@ -32,3 +34,9 @@ export interface AppConfig {
installed: boolean;
status: 'running' | 'stopped';
}
export interface IUser {
email: string;
name: string;
password: string;
}

4
system-api/src/declarations.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module 'su-exec' {
export function execFile(path: string, args: string[], options: {}, callback?: any): void;
export function init(): void;
}

View file

@ -147,6 +147,10 @@ const startApp = async (req: Request, res: Response, next: NextFunction) => {
checkAppExists(appName);
checkEnvFile(appName);
// Regenerate env file
const form = getInitalFormValues(appName);
generateEnvFile(appName, form);
// Run script
await runAppScript(['start', appName]);

View file

@ -0,0 +1,88 @@
import { NextFunction, Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { IUser } from '../../config/types';
import { readJsonFile, writeFile } from '../fs/fs.helpers';
import { getJwtToken, getUser } from './auth.helpers';
const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
if (!email || !password) {
throw new Error('Missing id or password');
}
const user = getUser(email);
if (!user) {
throw new Error('User not found');
}
const token = await getJwtToken(user, password);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const users: IUser[] = readJsonFile('/state/users.json');
if (users.length > 0) {
throw new Error('There is already an admin user');
}
const { email, password, name } = req.body;
if (!email || !password) {
throw new Error('Missing email or password');
}
if (users.find((user) => user.email === email)) {
throw new Error('User already exists');
}
const hash = await bcrypt.hash(password, 10);
const newuser: IUser = { email, name, password: hash };
const token = await getJwtToken(newuser, password);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
writeFile('/state/users.json', JSON.stringify([newuser]));
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const me = async (req: Request, res: Response) => {
const { user } = req;
if (user) {
res.status(200).json({ user });
} else {
res.status(200).json({ user: null });
}
};
const isConfigured = async (req: Request, res: Response) => {
const users: IUser[] = readJsonFile('/state/users.json');
res.status(200).json({ configured: users.length > 0 });
};
export default { login, me, register, isConfigured };

View file

@ -0,0 +1,44 @@
import jsonwebtoken from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { IUser, Maybe } from '../../config/types';
import { readJsonFile } from '../fs/fs.helpers';
import config from '../../config';
export const getUser = (email: string): Maybe<IUser> => {
const savedUser: IUser[] = readJsonFile('/state/users.json');
const user = savedUser.find((u) => u.email === email);
return user;
};
const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
return bcrypt.compare(password, hash || '');
};
const getJwtToken = async (user: IUser, password: string) => {
const validPassword = await compareHashPassword(password, user.password || '');
if (validPassword) {
if (config.JWT_SECRET) {
return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
expiresIn: '7d',
});
}
}
throw new Error('Wrong password');
};
const tradeTokenForUser = (token: string): Maybe<IUser> => {
try {
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
const users: IUser[] = readJsonFile('/state/users.json');
return users.find((user) => user.email === email);
} catch (error) {
return null;
}
};
export { tradeTokenForUser, getJwtToken };

View file

@ -0,0 +1,11 @@
import { Router } from 'express';
import AuthController from './auth.controller';
const router = Router();
router.route('/login').post(AuthController.login);
router.route('/me').get(AuthController.me);
router.route('/configured').get(AuthController.isConfigured);
router.route('/register').post(AuthController.register);
export default router;

View file

@ -1,16 +1,23 @@
/* eslint-disable no-unused-vars */
import express, { NextFunction, Request, Response } from 'express';
import compression from 'compression';
// import suExec from 'su-exec';
import helmet from 'helmet';
import cors from 'cors';
import { isProd } from './constants/constants';
import appsRoutes from './modules/apps/apps.routes';
import systemRoutes from './modules/system/system.routes';
import networkRoutes from './modules/network/network.routes';
import authRoutes from './modules/auth/auth.routes';
import { tradeTokenForUser } from './modules/auth/auth.helpers';
import cookieParser from 'cookie-parser';
// suExec.init();
const app = express();
const port = 3001;
app.use(express.json());
app.use(cookieParser());
if (isProd) {
app.use(compression());
@ -19,12 +26,32 @@ if (isProd) {
app.use(cors());
app.use('/system', systemRoutes);
app.use('/apps', appsRoutes);
app.use('/network', networkRoutes);
// Get user from token
app.use((req, res, next) => {
let user = null;
if (req?.cookies?.tipi_token) {
user = tradeTokenForUser(req.cookies.tipi_token);
if (user) req.user = user;
}
next();
});
const restrict = (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
} else {
next();
}
};
app.use('/auth', authRoutes);
app.use('/system', restrict, systemRoutes);
app.use('/apps', restrict, appsRoutes);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
app.use((err: Error, req: Request, res: Response, _: NextFunction) => {
res.status(200).json({ error: err.message });
});

View file

@ -5,5 +5,6 @@ TZ=<tz>
PUID=<puid>
PGID=<pgid>
INTERNAL_IP=<internal_ip>
DNS_IP=<dns_ip>

View file

@ -0,0 +1 @@
[]

View file

@ -1,5 +0,0 @@
{
"main": {
"domain": "mydomain.com"
}
}