🔀 Merge branch 'dev' into overseerr-integration

This commit is contained in:
ajnart
2022-07-24 21:36:55 +02:00
100 changed files with 4558 additions and 11317 deletions

View File

@@ -58,7 +58,7 @@ const useStyles = createStyles((theme) => ({
},
}));
export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
const theme = useMantineTheme();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
@@ -70,7 +70,7 @@ export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
);
}
export default function NothingFoundBackground() {
export default function Custom404() {
const { classes } = useStyles();
return (

View File

@@ -6,6 +6,7 @@ import AppShelf from '../components/AppShelf/AppShelf';
import LoadConfigComponent from '../components/Config/LoadConfig';
import { Config } from '../tools/types';
import { useConfig } from '../tools/state';
import Layout from '../components/layout/Layout';
export async function getServerSideProps(
context: GetServerSidePropsContext
@@ -46,9 +47,9 @@ export default function HomePage(props: any) {
setConfig(initialConfig);
}, [initialConfig]);
return (
<>
<Layout>
<AppShelf />
<LoadConfigComponent />
</>
</Layout>
);
}

View File

@@ -1,51 +1,67 @@
import { GetServerSidePropsContext } from 'next';
import { useState } from 'react';
import { AppProps } from 'next/app';
import { getCookie, setCookies } from 'cookies-next';
import { getCookie, setCookie } from 'cookies-next';
import Head from 'next/head';
import { MantineProvider, ColorScheme, ColorSchemeProvider } from '@mantine/core';
import { MantineProvider, ColorScheme, ColorSchemeProvider, MantineTheme } from '@mantine/core';
import { NotificationsProvider } from '@mantine/notifications';
import { useHotkeys } from '@mantine/hooks';
import Layout from '../components/layout/Layout';
import { ConfigProvider } from '../tools/state';
import { theme } from '../tools/theme';
import { styles } from '../tools/styles';
import { ColorTheme } from '../tools/color';
export default function App(props: AppProps & { colorScheme: ColorScheme }) {
export default function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
const { Component, pageProps } = props;
const [colorScheme, setColorScheme] = useState<ColorScheme>(props.colorScheme);
const [primaryColor, setPrimaryColor] = useState<MantineTheme['primaryColor']>('red');
const [secondaryColor, setSecondaryColor] = useState<MantineTheme['primaryColor']>('orange');
const [primaryShade, setPrimaryShade] = useState<MantineTheme['primaryShade']>(6);
const colorTheme = {
primaryColor,
secondaryColor,
setPrimaryColor,
setSecondaryColor,
primaryShade,
setPrimaryShade,
};
const toggleColorScheme = (value?: ColorScheme) => {
const nextColorScheme = value || (colorScheme === 'dark' ? 'light' : 'dark');
setColorScheme(nextColorScheme);
setCookies('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
setCookie('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 });
};
useHotkeys([['mod+J', () => toggleColorScheme()]]);
return (
<>
<Head>
<title>Homarr 🦞</title>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
<link rel="shortcut icon" href="/favicon.svg" />
</Head>
<ColorSchemeProvider colorScheme={colorScheme} toggleColorScheme={toggleColorScheme}>
<MantineProvider
theme={{
...theme,
colorScheme,
}}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider>
<Layout>
<ColorTheme.Provider value={colorTheme}>
<MantineProvider
theme={{
...theme,
primaryColor,
primaryShade,
colorScheme,
}}
styles={{
...styles,
}}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider>
<Component {...pageProps} />
</Layout>
</ConfigProvider>
</NotificationsProvider>
</MantineProvider>
</ConfigProvider>
</NotificationsProvider>
</MantineProvider>
</ColorTheme.Provider>
</ColorSchemeProvider>
</>
);

16
src/pages/_middleware.ts Normal file
View File

@@ -0,0 +1,16 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
// eslint-disable-next-line consistent-return
export function middleware(req: NextRequest, ev: NextFetchEvent) {
const isCorrectPassword = req.cookies.password === process.env.PASSWORD;
const url = req.nextUrl.clone();
if (
!isCorrectPassword &&
url.pathname !== '/login' &&
process.env.PASSWORD &&
url.pathname !== '/api/configs/tryPassword'
) {
url.pathname = '/login';
return NextResponse.rewrite(url);
}
}

View File

@@ -0,0 +1,25 @@
import { NextApiRequest, NextApiResponse } from 'next';
function Post(req: NextApiRequest, res: NextApiResponse) {
const { tried } = req.body;
// Try to match the password with the PASSWORD env variable
if (tried === process.env.PASSWORD) {
return res.status(200).json({
success: true,
});
}
return res.status(200).json({
success: false,
});
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'POST') {
return Post(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,58 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Docker from 'dockerode';
const docker = new Docker();
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request
const { id } = req.query as { id: string };
const { action } = req.query;
// Get the action on the request (start, stop, restart)
if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
return res.status(400).json({
statusCode: 400,
message: 'Invalid action',
});
}
if (!id) {
return res.status(400).json({
message: 'Missing ID',
});
}
// Get the container with the ID
const container = docker.getContainer(id);
const startAction = async () => {
switch (action) {
case 'remove':
return container.remove();
case 'start':
return container.start();
case 'stop':
return container.stop();
case 'restart':
return container.restart();
default:
return Promise;
}
};
try {
await startAction();
return res.status(200).json({
statusCode: 200,
message: `Container ${id} ${action}ed`,
});
} catch (err) {
return res.status(500).json(err);
}
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a Put or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Docker from 'dockerode';
async function Get(req: NextApiRequest, res: NextApiResponse) {
try {
const docker = new Docker();
const containers = await docker.listContainers({ all: true });
res.status(200).json(containers);
} catch (err) {
res.status(500).json({ err });
}
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -0,0 +1,83 @@
import axios from 'axios';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
const { id } = req.body;
const { type } = req.query;
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
// Find service with serviceId in config
const service = config.services.find((service) => service.id === id);
if (!service) {
return res.status(500).json({
statusCode: 500,
message: 'Missing service',
});
}
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
const lastMonth = new Date(new Date().setMonth(new Date().getMonth() - 2)).toISOString();
const TypeToUrl: { service: string; url: string }[] = [
{
service: 'sonarr',
url: '/api/calendar',
},
{
service: 'radarr',
url: '/api/v3/calendar',
},
{
service: 'lidarr',
url: '/api/v1/calendar',
},
{
service: 'readarr',
url: '/api/v1/calendar',
},
];
if (!type) {
return res.status(400).json({
message: 'Missing required parameter in url: type',
});
}
if (!service) {
return res.status(400).json({
message: 'Missing required parameter in body: service',
});
}
// Match the type to the correct url
const url = TypeToUrl.find((x) => x.service === type);
if (!url) {
return res.status(400).json({
message: 'Invalid type',
});
}
// Get the origin URL
let { href: origin } = new URL(service.url);
if (origin.endsWith('/')) {
origin = origin.slice(0, -1);
}
const pined = `${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`;
return axios
.get(`${origin}${url?.url}?apiKey=${service.apiKey}&end=${nextMonth}&start=${lastMonth}`)
.then((response) => res.status(200).json(response.data))
.catch((e) => res.status(500).json(e));
// // Make a request to the URL
// const response = await axios.get(url);
// // Return the response
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'POST') {
return Post(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -1,42 +1,66 @@
import { Deluge } from '@ctrl/deluge';
import { QBittorrent } from '@ctrl/qbittorrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { Transmission } from '@ctrl/transmission';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the type of service from the request url
const { dlclient } = req.query;
const { body } = req;
// Get login, password and url from the body
const { username, password, url } = body;
if (!dlclient || (!username && !password) || !url) {
return res.status(400).json({
error: 'Wrong request',
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const qBittorrentServices = config.services.filter((service) => service.type === 'qBittorrent');
const delugeServices = config.services.filter((service) => service.type === 'Deluge');
const transmissionServices = config.services.filter((service) => service.type === 'Transmission');
const torrents: NormalizedTorrent[] = [];
if (!qBittorrentServices && !delugeServices && !transmissionServices) {
return res.status(500).json({
statusCode: 500,
message: 'Missing services',
});
}
let client: Deluge | QBittorrent;
switch (dlclient) {
case 'qbit':
client = new QBittorrent({
baseUrl: new URL(url).origin,
username,
password,
});
break;
case 'deluge':
client = new Deluge({
baseUrl: new URL(url).origin,
password,
});
break;
default:
return res.status(400).json({
error: 'Wrong request',
});
try {
await Promise.all(
qBittorrentServices.map((service) =>
new QBittorrent({
baseUrl: service.url,
username: service.username,
password: service.password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
await Promise.all(
delugeServices.map((service) =>
new Deluge({
baseUrl: service.url,
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
// Map transmissionServices
await Promise.all(
transmissionServices.map((service) =>
new Transmission({
baseUrl: service.url,
username: 'username' in service ? service.username : '',
password: 'password' in service ? service.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
} catch (e: any) {
return res.status(401).json(e);
}
const data = await client.getAllData();
res.status(200).json({
torrents: data.torrents,
});
return res.status(200).json(torrents);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -7,10 +7,14 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
await axios
.get(url as string)
.then((response) => {
res.status(200).json(response.data);
res.status(response.status).json(response.statusText);
})
.catch((error) => {
res.status(500).json(error);
if (error.response) {
res.status(error.response.status).json(error.response.statusText);
} else {
res.status(500).json('Server Error');
}
});
// // Make a request to the URL
// const response = await axios.get(url);

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
async function Get(req: NextApiRequest, res: NextApiResponse) {
const { q } = req.query;
const response = await axios.get(`https://duckduckgo.com/ac/?q=${q}`);
res.status(200).json(response.data);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'GET') {
return Get(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookies } from 'cookies-next';
import { getCookie, setCookie } from 'cookies-next';
import { GetServerSidePropsContext } from 'next';
import { useEffect } from 'react';
import AppShelf from '../components/AppShelf/AppShelf';
@@ -7,6 +7,8 @@ import { Config } from '../tools/types';
import { useConfig } from '../tools/state';
import { migrateToIdConfig } from '../tools/migrate';
import { getConfig } from '../tools/getConfig';
import { useColorTheme } from '../tools/color';
import Layout from '../components/layout/Layout';
export async function getServerSideProps({
req,
@@ -14,7 +16,7 @@ export async function getServerSideProps({
}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> {
let cookie = getCookie('config-name', { req, res });
if (!cookie) {
setCookies('config-name', 'default', {
setCookie('config-name', 'default', {
req,
res,
maxAge: 60 * 60 * 24 * 30,
@@ -28,14 +30,17 @@ export async function getServerSideProps({
export default function HomePage(props: any) {
const { config: initialConfig }: { config: Config } = props;
const { setConfig } = useConfig();
const { setPrimaryColor, setSecondaryColor } = useColorTheme();
useEffect(() => {
const migratedConfig = migrateToIdConfig(initialConfig);
setPrimaryColor(migratedConfig.settings.primaryColor || 'red');
setSecondaryColor(migratedConfig.settings.secondaryColor || 'orange');
setConfig(migratedConfig);
}, [initialConfig]);
return (
<>
<Layout>
<AppShelf />
<LoadConfigComponent />
</>
</Layout>
);
}

115
src/pages/login.tsx Normal file
View File

@@ -0,0 +1,115 @@
import React from 'react';
import { PasswordInput, Anchor, Paper, Title, Text, Container, Group, Button } from '@mantine/core';
import { setCookie } from 'cookies-next';
import { useForm } from '@mantine/hooks';
import { showNotification, updateNotification } from '@mantine/notifications';
import axios from 'axios';
import { IconCheck, IconX } from '@tabler/icons';
import { useRouter } from 'next/router';
// TODO: Add links to the wiki articles about the login process.
export default function AuthenticationTitle() {
const router = useRouter();
const form = useForm({
initialValues: {
password: '',
},
});
return (
<Container
size="lg"
style={{
height: '100vh',
display: 'flex',
width: '100%',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Group>
<Title
align="center"
sx={(theme) => ({ fontFamily: `Greycliff CF, ${theme.fontFamily}`, fontWeight: 900 })}
>
Welcome back!
</Title>
</Group>
<Text color="dimmed" size="sm" align="center" mt={5}>
Please enter the{' '}
<Anchor<'a'> href="#" size="sm" onClick={(event) => event.preventDefault()}>
password
</Anchor>
</Text>
<Paper
withBorder
shadow="md"
p={30}
mt={30}
radius="md"
style={{ width: '100%', maxWidth: 420 }}
>
<form
onSubmit={form.onSubmit((values) => {
setCookie('password', values.password, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
});
showNotification({
id: 'load-data',
loading: true,
title: 'Checking your password',
message: 'Your password is being checked...',
autoClose: false,
disallowClose: true,
});
axios
.post('/api/configs/tryPassword', {
tried: values.password,
})
.then((res) => {
setTimeout(() => {
if (res.data.success === true) {
router.push('/');
updateNotification({
id: 'load-data',
color: 'teal',
title: 'Password correct, redirecting you...',
message: undefined,
icon: <IconCheck />,
autoClose: 1000,
});
}
if (res.data.success === false) {
updateNotification({
id: 'load-data',
color: 'red',
title: 'Password is wrong, please try again.',
message: undefined,
icon: <IconX />,
autoClose: 2000,
});
}
}, 500);
});
})}
>
<PasswordInput
id="password"
label="Password"
placeholder="Your password"
required
autoFocus
mt="md"
{...form.getInputProps('password')}
/>
<Button fullWidth type="submit" mt="xl">
Sign in
</Button>
</form>
</Paper>
</Container>
);
}