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