diff --git a/backend/src/index.ts b/backend/src/index.ts index a02fa71..a6f253a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,23 +1,89 @@ import axios, { AxiosRequestConfig } from 'axios'; import express from 'express'; import https from 'https'; +import { PerPlexed } from './types'; +import { randomBytes } from 'crypto'; /* * ENVIRONMENT VARIABLES * - * PLEX_SERVER: The URL of the Plex server to proxy requests to - * DISABLE_PROXY: If set to true, the proxy will be disabled and all requests go directly to the Plex server from the frontend (NOT RECOMMENDED) - * DISABLE_TLS_VERIFY: If set to true, the proxy will not check any https ssl certificates + * PLEX_SERVERS: A comma separated list of Plex servers the frontend can connect to + * PROXY_PLEX_SERVER: The URL of the Plex server to proxy requests to + * FRONTEND_SERVER_CHECK_TIMEOUT?: The timeout in milliseconds for the proxy to check if the frontend server is reachable (default: 2000) + * DISABLE_PROXY?: If set to true, the proxy will be disabled and all requests go directly to the Plex server from the frontend (NOT RECOMMENDED) + * DISABLE_TLS_VERIFY?: If set to true, the proxy will not check any https ssl certificates **/ +const deploymentID = randomBytes(8).toString('hex'); + +const status: PerPlexed.Status = { + ready: false, + error: false, + message: 'Server is starting up...', +} const app = express(); app.use(express.json()); -if (!process.env.PLEX_SERVER) { - console.error('PLEX_SERVER environment variable not set'); - process.exit(1); -} +(async () => { + if (process.env.PLEX_SERVER) { + status.error = true; + status.message = 'PLEX_SERVER has changed to PLEX_SERVERS. Please view the upgrade guide in the 1.0.0 release notes on github. https://github.com/Ipmake/PerPlexed/releases/tag/v1.0.0'; + return; + } + + if (!process.env.PLEX_SERVERS) { + status.error = true; + status.message = 'PLEX_SERVERS environment variable not set'; + console.error('PLEX_SERVER environment variable not set'); + return; + } + + if (!process.env.PROXY_PLEX_SERVER && process.env.DISABLE_PROXY !== 'true') { + status.error = true; + status.message = 'PROXY_PLEX_SERVER environment variable not set. Please view the upgrade guide in the 1.0.0 release notes on github. https://github.com/Ipmake/PerPlexed/releases/tag/v1.0.0'; + console.error('PROXY_PLEX_SERVER environment variable not set'); + return; + } + + if (process.env.PLEX_SERVERS) { + // check if the PLEX_SERVERS environment variable is a comma separated list and whether each server is a valid URL, the URL must not end with a / + const servers = process.env.PLEX_SERVERS.split(','); + const invalidServers = servers.filter((server) => !server.trim().match(/^https?:\/\/[^\/]+$/)); + + if (invalidServers.length > 0) { + status.error = true; + status.message = 'Invalid PLEX_SERVERS environment variable. The URL must start with http:// or https:// and must not end with a /'; + console.error('Invalid PLEX_SERVERS environment variable. The URL must start with http:// or https:// and must not end with a /'); + return; + } + } + + if (process.env.PROXY_PLEX_SERVER && process.env.DISABLE_PROXY !== 'true') { + // check if the PROXY_PLEX_SERVER environment variable is a valid URL, the URL must not end with a / + if (!process.env.PROXY_PLEX_SERVER.match(/^https?:\/\/[^\/]+$/)) { + status.error = true; + status.message = 'Invalid PROXY_PLEX_SERVER environment variable. The URL must start with http:// or https:// and must not end with a /'; + console.error('Invalid PROXY_PLEX_SERVER environment variable. The URL must start with http:// or https:// and must not end with a /'); + return; + } + + // check whether the PROXY_PLEX_SERVER is reachable + try { + await axios.get(`${process.env.PROXY_PLEX_SERVER}/identity`, { + timeout: 5000, + }); + } catch (error) { + status.error = true; + status.message = 'Proxy cannot reach PROXY_PLEX_SERVER'; + console.error('Proxy cannot reach PROXY_PLEX_SERVER'); + return; + } + } + + status.ready = true; + status.message = 'OK'; +})(); app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] [${req.method}] ${req.url}`); @@ -27,11 +93,17 @@ app.use((req, res, next) => { next(); }); +app.get('/status', (req, res) => { + res.send(status); +}); + app.get('/config', (req, res) => { res.send({ - PLEX_SERVER: process.env.PLEX_SERVER, + PLEX_SERVERS: (process.env.PLEX_SERVERS as string).split(",").map((server) => server.trim()), + DEPLOYMENTID: deploymentID, CONFIG: { DISABLE_PROXY: process.env.DISABLE_PROXY === 'true', + FRONTEND_SERVER_CHECK_TIMEOUT: parseInt(process.env.FRONTEND_SERVER_CHECK_TIMEOUT || '2000'), } }); }); @@ -39,17 +111,22 @@ app.get('/config', (req, res) => { app.use(express.static('www')); app.post('/proxy', (req, res) => { + if(process.env.DISABLE_PROXY === 'true') return res.status(400).send('Proxy is disabled'); + const { url, method, headers, data } = req.body; const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; // the url must start with a / to prevent the server from making requests to external servers if (!url || !url.startsWith('/')) return res.status(400).send('Invalid URL'); + // check that the url doesn't include any harmful characters that could be used for directory traversal + if (url.match(/\.\./)) return res.status(400).send('Invalid URL'); + // the method must be one of the allowed methods [GET, POST, PUT] if (!method || !['GET', 'POST', 'PUT'].includes(method)) return res.status(400).send('Invalid method'); const config: AxiosRequestConfig = { - url: `${process.env.PLEX_SERVER}${url}`, + url: `${process.env.PROXY_PLEX_SERVER}${url}`, method, headers: { ...headers, diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..731783e --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,7 @@ +export namespace PerPlexed { + export interface Status { + ready: boolean; + error: boolean; + message: string; + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1e39b6..a51b933 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,30 @@ import Home from "./pages/Home"; import Library from "./pages/Library"; import BigReader from "./components/BigReader"; import { useWatchListCache } from "./states/WatchListCache"; +import Startup, { useStartupState } from "./pages/Startup"; + +function AppManager() { + const { loading } = useStartupState(); + const [showApp, setShowApp] = React.useState(false); + const [fadeOut, setFadeOut] = React.useState(false); + + useEffect(() => { + if (loading) return; + + setFadeOut(true); + setTimeout(() => setShowApp(true), 500); + }, [loading]); + + if (!showApp) { + return ( +
+ +
+ ); + } + + return ; +} function App() { const location = useLocation(); @@ -63,4 +87,4 @@ function App() { ); } -export default App; +export default AppManager; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index af92b6a..2d78bda 100755 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -7,8 +7,6 @@ import { CssBaseline, createTheme } from "@mui/material"; import { QueryClient, QueryClientProvider } from "react-query"; import { BrowserRouter } from "react-router-dom"; import { makeid, uuidv4 } from "./plex/QuickFunctions"; -import axios from "axios"; -import { getBackendURL } from "./backendURL"; import "@fontsource-variable/quicksand"; import '@fontsource-variable/rubik'; @@ -19,23 +17,6 @@ if(!localStorage.getItem("clientID")) localStorage.setItem("clientID", makeid(24 sessionStorage.setItem("sessionID", uuidv4()); -(async () => { - let reload = false; - - const config = await axios.get(`${getBackendURL()}/config`); - if(config.data.PLEX_SERVER !== localStorage.getItem("server")) { - localStorage.setItem("server", config.data.PLEX_SERVER); - reload = true; - } - - if(JSON.stringify(config.data.CONFIG) !== localStorage.getItem("config")) { - localStorage.setItem("config", JSON.stringify(config.data.CONFIG)); - reload = true; - } - - if(reload) return window.location.reload(); -})(); - interface ConfigInterface { [key: string]: any; } diff --git a/frontend/src/pages/Startup.tsx b/frontend/src/pages/Startup.tsx new file mode 100644 index 0000000..a8b2d22 --- /dev/null +++ b/frontend/src/pages/Startup.tsx @@ -0,0 +1,228 @@ +import { Box } from "@mui/material"; +import axios from "axios"; +import React, { useEffect, useRef } from "react"; +import { create } from "zustand"; +import { getBackendURL } from "../backendURL"; +import Utility from "./Utility"; + +interface StartupState { + loading: boolean; + setLoading: (loading: boolean) => void; + + showDiagnostic: boolean; + setShowDiagnostic: (showDiagnostic: boolean) => void; + + lastStatus?: PerPlexed.Status; + setLastStatus: (status: PerPlexed.Status) => void; + + frontEndStatus?: PerPlexed.Status; + setFrontEndStatus: (frontEndStatus: PerPlexed.Status) => void; +} + +export const useStartupState = create((set) => ({ + loading: true, + setLoading: (loading) => set({ loading }), + + showDiagnostic: false, + setShowDiagnostic: (showDiagnostic) => set({ showDiagnostic }), + + lastStatus: undefined, + setLastStatus: (lastStatus) => set({ lastStatus }), + + frontEndStatus: undefined, + setFrontEndStatus: (frontEndStatus) => set({ frontEndStatus }), +})); + +function Startup() { + const { + loading, + showDiagnostic, + setShowDiagnostic, + lastStatus, + setLastStatus, + setLoading, + frontEndStatus, + setFrontEndStatus, + } = useStartupState(); + + const skipUpdates = useRef(false); + + useEffect(() => { + if (!loading || skipUpdates.current) return; + + const fetchStatus = async () => { + const res = await axios + .get(`${getBackendURL()}/status`, { + timeout: 5000, + }) + .then((res) => res.data as PerPlexed.Status) + .catch(() => null); + if (!res) { + setFrontEndStatus({ + ready: false, + error: true, + message: + "Frontend could not connect to the backend. Please check the backend logs for more information.", + }); + return; + } + + setLastStatus(res); + if (res.error) setShowDiagnostic(true); + }; + + const interval = setInterval(fetchStatus, 2500); + + return () => { + clearInterval(interval); + }; + }, [loading, setFrontEndStatus, setLastStatus, setShowDiagnostic]); + + useEffect(() => { + if (!lastStatus || skipUpdates.current) return; + + if (lastStatus.ready) { + (async () => { + skipUpdates.current = true; + let reload = false; + + const config = await axios + .get(`${getBackendURL()}/config`) + .then((res) => res.data as PerPlexed.Config) + .catch(() => null); + + if (!config) { + setFrontEndStatus({ + ready: false, + error: true, + message: + "Frontend could not retrieve the configuration from the backend. Please check the backend logs for more information.", + }); + skipUpdates.current = false; + return; + } + + if (JSON.stringify(config.CONFIG) !== localStorage.getItem("config")) { + localStorage.setItem("config", JSON.stringify(config.CONFIG)); + reload = true; + } + + const getBestServer: () => Promise = async () => { + // if the current page is http then filter all https servers + const servers = config.PLEX_SERVERS.filter((server) => { + if (window.location.protocol === "http:") { + if (server.startsWith("https:")) return false; + } + if (window.location.protocol === "https:") { + if (server.startsWith("http:")) return false; + } + return true; + }); + + if (servers.length === 0) { + setFrontEndStatus({ + ready: false, + error: true, + message: + "No servers available for the current protocol. Due to the same-origin policy, the Plex server must be accessible over the same protocol as the PerPlexed frontend.", + }); + return null; + } + + const reachableServers = await Promise.all( + servers.map((server) => + axios + .get(`${server}/identity`, { + timeout: config.CONFIG.FRONTEND_SERVER_CHECK_TIMEOUT, + }) + .then((res) => { + if(res.data?.MediaContainer?.machineIdentifier) return server; + return null; + }) + .catch(() => null) + ) + ); + + // server priority goes from left to right from highest to lowest + const bestServer = reachableServers.find((server) => server !== null); + if (!bestServer) { + setFrontEndStatus({ + ready: false, + error: true, + message: + "No servers reachable. Please check the backend logs for more information.", + }); + return null; + } + + return bestServer; + }; + + const currServer = localStorage.getItem("server"); + const lastDeployId = localStorage.getItem("deploymentId"); + + let bestServer: string | null = null; + + // if a server is defined in localStorage and the deploymentId is the same as the last one, only check the current server for reachability, otherwise get a new best server + if (currServer && lastDeployId === config.DEPLOYMENTID) { + // check if the current server is reachable, if not get a new best server + const res = await axios + .get(`${currServer}/identity`, { + timeout: config.CONFIG.FRONTEND_SERVER_CHECK_TIMEOUT, + }) + .then(() => currServer) + .catch(() => null); + + if (res) bestServer = currServer; + else bestServer = await getBestServer(); + } else { + // get a new best server + bestServer = await getBestServer(); + } + + if(bestServer !== currServer) reload = true; + skipUpdates.current = false; + + if(bestServer) localStorage.setItem("server", bestServer); + else return localStorage.removeItem("server"); + localStorage.setItem("deploymentId", config.DEPLOYMENTID); + + + if (reload) return window.location.reload(); + else { + setShowDiagnostic(false); + setLoading(false); + } + })(); + } + }, [ + lastStatus, + setFrontEndStatus, + setLastStatus, + setLoading, + setShowDiagnostic, + ]); + + if (showDiagnostic || frontEndStatus) return ; + + return ( + + Plex Logo + + ); +} + +export default Startup; diff --git a/frontend/src/pages/Utility.tsx b/frontend/src/pages/Utility.tsx new file mode 100644 index 0000000..1b0f888 --- /dev/null +++ b/frontend/src/pages/Utility.tsx @@ -0,0 +1,116 @@ +import { Box, Typography } from "@mui/material"; +import React from "react"; +import { useStartupState } from "./Startup"; + +function Utility() { + const { lastStatus, frontEndStatus } = useStartupState(); + + return ( + + + + PerPlexed has encountered an error + + + {lastStatus?.error && ( + + {lastStatus.message.split(" ").map((word, index) => ( + + {word.match(/https?:\/\/[^\s]+/) ? ( + <> +
+
+ + {word} + +
+ + ) : ( + word + " " + )} +
+ ))} +
+ )} + + {frontEndStatus?.error && ( + + {frontEndStatus.message.split(" ").map((word, index) => ( + + {word.match(/https?:\/\/[^\s]+/) ? ( + <> +
+
+ + {word} + +
+ + ) : ( + word + " " + )} +
+ ))} +
+ )} +
+
+ ); +} + +export default Utility; diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 2f1db77..a8deb95 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -5,4 +5,19 @@ declare namespace PerPlexed { dir: string; link: string; } + + interface Status { + ready: boolean; + error: boolean; + message: string; + } + + interface Config { + PLEX_SERVERS: string[]; + DEPLOYMENTID: string; + CONFIG: { + DISABLE_PROXY: boolean; + FRONTEND_SERVER_CHECK_TIMEOUT: number; + } + } } \ No newline at end of file