diff --git a/client/package-lock.json b/client/package-lock.json index 1769b551..dab74278 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "client", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", diff --git a/client/package.json b/client/package.json index 795e8693..c995175c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "client", - "version": "1.0.6", + "version": "1.0.7", "scripts": { "dev": "vite", "build": "vite build", diff --git a/client/public/locales/de.json b/client/public/locales/de.json index eb68ba0b..2de3d59f 100644 --- a/client/public/locales/de.json +++ b/client/public/locales/de.json @@ -12,7 +12,8 @@ "password": { "title": "Passwort erforderlich", "placeholder": "Dein Passwort", - "wrong": "Das von dir eingegebene Passwort ist falsch" + "wrong": "Das von dir eingegebene Passwort ist falsch", + "unlock": "Sperre aufheben" }, "accept": { "title": "Wir brauchen deine Genehmigung", @@ -160,6 +161,7 @@ }, "test": { "not_available": "Es liegen aktuell keine Tests vor", + "no_latest": "Es liegt kein aktueller Test vor. Bitte führe einen Test durch oder warte, bis der nächste Test durchgeführt wurde.", "unknown_error": "Unbekannter Fehler:", "failed": "Test fehlgeschlagen", "recheck": "Bitte überprüfe weitestgehend, ob das öfter passiert.", diff --git a/client/public/locales/en.json b/client/public/locales/en.json index c246b682..9ac99d83 100644 --- a/client/public/locales/en.json +++ b/client/public/locales/en.json @@ -12,7 +12,8 @@ "password": { "title": "Password required", "placeholder": "Your password", - "wrong": "The password you entered is incorrect" + "wrong": "The password you entered is incorrect", + "unlock": "Unlock" }, "accept": { "title": "We need your permission", @@ -129,7 +130,7 @@ "healthchecks": "MySpeed uses HealthChecks to notify you when your internet is down. To enable this, put your ping URL in the text box. Read more here", "credits": "MySpeed is provided by GNMYT and uses the Speedtest CLI from Ookla.", "recommendations_error": "You have to do at least 10 tests to get an average. It doesn't matter if the tests were done manually or automatically.", - "recommendations_info": "Based on the last 10 tests, it was found that the optimal ping was {{ping}} ms, the download at {{down}} Mbit/s and the upload at {{up}} Mbit/s. It is best to orientate yourself on your internet contract and only adopt it if it matches that.", + "recommendations_info": "Based on the last 10 tests, it was found that the optimal ping was {{ping}} ms, the download at {{down}} Mbps and the upload at {{up}} Mbps. It is best to orientate yourself on your internet contract and only adopt it if it matches that.", "update": "An update to version {{version}} is available. See the changes and download the update.", "down": { "title": "Download speed", @@ -160,6 +161,7 @@ }, "test": { "not_available": "There are currently no tests available", + "no_latest": "There is no current test available. Please perform a test or wait until the next test is performed.", "unknown_error": "Unknown error:", "failed": "Test failed", "recheck": "Please check as far as possible if this happens often.", diff --git a/client/src/common/components/Dropdown/DropdownComponent.jsx b/client/src/common/components/Dropdown/DropdownComponent.jsx index 8cc7621c..630ce55e 100644 --- a/client/src/common/components/Dropdown/DropdownComponent.jsx +++ b/client/src/common/components/Dropdown/DropdownComponent.jsx @@ -57,6 +57,8 @@ export const toggleDropdown = (setIcon) => { function DropdownComponent() { const [config, reloadConfig] = useContext(ConfigContext); const [status, updateStatus] = useContext(StatusContext); + const findNode = useContext(NodeContext)[4]; + const updateNodes = useContext(NodeContext)[1]; const currentNode = useContext(NodeContext)[2]; const updateTests = useContext(SpeedtestContext)[1]; const updateToast = useContext(ToastNotificationContext); @@ -145,19 +147,24 @@ function DropdownComponent() { const updatePassword = async () => { toggleDropdown(); + const passwordSet = currentNode !== 0 ? findNode(currentNode).password : localStorage.getItem("password") != null; + setDialog({ title: <>{t("update.new_password")} » {t("update.level")}, placeholder: t("update.password_placeholder"), type: "password", - unsetButton: localStorage.getItem("password") != null ? "Sperre aufheben" : undefined, + unsetButton: passwordSet ? t("dialog.password.unlock") : undefined, onClear: () => patchRequest("/config/password", {value: "none"}) .then(() => showFeedback("update.password_removed", false)) - .then(() => localStorage.removeItem("password")), + .then(() => { + currentNode !== 0 ? baseRequest("/nodes/" + currentNode + "/password", "PATCH", + {password: "none"}).then(() => updateNodes()) : localStorage.removeItem("password"); + }), onSuccess: (value) => patchRequest("/config/password", {value}) .then(() => showFeedback(undefined, false)) .then(() => { currentNode !== 0 ? baseRequest("/nodes/" + currentNode + "/password", "PATCH", - {password: value}) : localStorage.setItem("password", value); + {password: value}).then(() => updateNodes()) : localStorage.setItem("password", value); }) }) } diff --git a/client/src/common/components/Header/HeaderComponent.jsx b/client/src/common/components/Header/HeaderComponent.jsx index d99d168b..cd04a64a 100644 --- a/client/src/common/components/Header/HeaderComponent.jsx +++ b/client/src/common/components/Header/HeaderComponent.jsx @@ -20,7 +20,7 @@ import {SpeedtestDialog} from "@/common/components/SpeedtestDialog"; import {NodeContext} from "@/common/contexts/Node"; function HeaderComponent(props) { - const nodes = useContext(NodeContext)[0]; + const findNode = useContext(NodeContext)[4]; const currentNode = useContext(NodeContext)[2]; const [setDialog] = useContext(InputDialogContext); @@ -82,8 +82,7 @@ function HeaderComponent(props) { if (!config.viewMode) updateVersion(); }, [config]); - const getNodeName = () => - currentNode === "0" ? t("header.title") : nodes?.find(node => node.id === currentNode)?.name || t("header.title"); + const getNodeName = () => currentNode === "0" ? t("header.title") : findNode(currentNode)?.name || t("header.title"); if (Object.keys(config).length === 0) return <>; diff --git a/client/src/common/contexts/Node/NodeContext.jsx b/client/src/common/contexts/Node/NodeContext.jsx index 4fe9c483..3d56906e 100644 --- a/client/src/common/contexts/Node/NodeContext.jsx +++ b/client/src/common/contexts/Node/NodeContext.jsx @@ -24,8 +24,10 @@ export const NodeProvider = (props) => { setCurrentNode(parseInt(node)); } + const findNode = (nodeId) => nodes?.find(node => node.id === nodeId); + return ( - + {props.children} ) diff --git a/client/src/common/utils/RequestUtil.js b/client/src/common/utils/RequestUtil.js index cc54973f..1df651ff 100644 --- a/client/src/common/utils/RequestUtil.js +++ b/client/src/common/utils/RequestUtil.js @@ -14,9 +14,12 @@ const getHeaders = () => { // Run a plain request with all default values using the base path export const baseRequest = async (path, method = "GET", body = {}, headers = {}) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10000); return await fetch("/api" + path, { headers: {...getHeaders(), ...headers}, method, - body: method !== "GET" ? JSON.stringify(body) : undefined + body: method !== "GET" ? JSON.stringify(body) : undefined, + signal: controller.signal }); } diff --git a/client/src/pages/Home/components/LatestTest/LatestTestComponent.jsx b/client/src/pages/Home/components/LatestTest/LatestTestComponent.jsx index edfa9867..ce3cd8fb 100644 --- a/client/src/pages/Home/components/LatestTest/LatestTestComponent.jsx +++ b/client/src/pages/Home/components/LatestTest/LatestTestComponent.jsx @@ -13,7 +13,7 @@ import {t} from "i18next"; function LatestTestComponent() { const status = useContext(StatusContext)[0]; - const [latest, setLatest] = useState({}); + const [latest, setLatest] = useState(null); const [latestTestTime, setLatestTestTime] = useState("N/A"); const [setDialog] = useContext(InputDialogContext); const [speedtests] = useContext(SpeedtestContext); @@ -30,6 +30,7 @@ function LatestTestComponent() { }, [latest]); if (Object.entries(config).length === 0) return (<>); + if (latest === null) return (<>); return (
@@ -38,7 +39,8 @@ function LatestTestComponent() {
setDialog(pingInfo())} icon={faPingPongPaddleBall} className={"container-icon help-icon icon-" + getIconBySpeed(latest.ping, config.ping, false)}/> -

{t("latest.ping")}{t("latest.ping_unit")}

+

{t("latest.ping")}{t("latest.ping_unit")}

{latest.ping === -1 ? "N/A" : latest.ping}

@@ -50,7 +52,8 @@ function LatestTestComponent() {
setDialog(downloadInfo())} icon={faArrowDown} className={"container-icon help-icon icon-" + getIconBySpeed(latest.download, config.download, true)}/> -

{t("latest.down")}{t("latest.speed_unit")}

+

{t("latest.down")}{t("latest.speed_unit")}

{latest.download === -1 ? "N/A" : latest.download}

@@ -64,7 +67,8 @@ function LatestTestComponent() {
setDialog(uploadInfo())} icon={faArrowUp} className={"container-icon help-icon icon-" + getIconBySpeed(latest.upload, config.upload, true)}/> -

{t("latest.up")}{t("latest.speed_unit")}

+

{t("latest.up")}{t("latest.speed_unit")}

{latest.upload === -1 ? "N/A" : latest.upload}

@@ -76,7 +80,8 @@ function LatestTestComponent() {
setDialog(latestTestInfo(latest))} icon={faClockRotateLeft} className="container-icon icon-blue help-icon"/> -

{t("latest.latest")}{t("latest.before")}

+

{t("latest.latest")}{t("latest.before")}

{latestTestTime}

diff --git a/client/src/pages/Home/components/LatestTest/utils/dialogs.jsx b/client/src/pages/Home/components/LatestTest/utils/dialogs.jsx index 30208561..589dd3cd 100644 --- a/client/src/pages/Home/components/LatestTest/utils/dialogs.jsx +++ b/client/src/pages/Home/components/LatestTest/utils/dialogs.jsx @@ -9,8 +9,8 @@ export const uploadInfo = () => ({title: t("info.up.title"), description: t("inf export const latestTestInfo = (latest) => ({ title: t("info.latest.title"), - description: }} values={{date: new Date(latest.created).toLocaleDateString(), + description: latest.created ? }} values={{date: new Date(latest.created).toLocaleDateString(), time: new Date(latest.created).toLocaleTimeString(undefined, {hour: "2-digit", minute: "2-digit"})}}> - info.latest.description, + info.latest.description : t("test.no_latest"), buttonText: t("dialog.okay") }); \ No newline at end of file diff --git a/client/src/pages/Nodes/Nodes.jsx b/client/src/pages/Nodes/Nodes.jsx index 1c0a4ac2..86467a43 100644 --- a/client/src/pages/Nodes/Nodes.jsx +++ b/client/src/pages/Nodes/Nodes.jsx @@ -19,7 +19,7 @@ export const Nodes = (props) => { {createDialogOpen && setCreateDialogOpen(false)}/>}
- {nodes.map(node => )} diff --git a/client/src/pages/Nodes/styles.sass b/client/src/pages/Nodes/styles.sass index 5481532d..6e74d17e 100644 --- a/client/src/pages/Nodes/styles.sass +++ b/client/src/pages/Nodes/styles.sass @@ -25,6 +25,7 @@ border: 2px dashed #696C73 border-radius: 15px cursor: pointer + user-select: none .node-add h1 font-size: 24pt diff --git a/package-lock.json b/package-lock.json index d48bec63..62b345d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "myspeed", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "myspeed", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "axios": "^1.3.5", "bcrypt": "^5.1.0", diff --git a/package.json b/package.json index 467bb159..0d98a175 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "myspeed", - "version": "1.0.6", + "version": "1.0.7", "scripts": { "client": "cd client && npm run dev", "server": "nodemon server", diff --git a/server/controller/node.js b/server/controller/node.js index 56a880fb..b5726194 100644 --- a/server/controller/node.js +++ b/server/controller/node.js @@ -1,8 +1,9 @@ +const axios = require('axios'); const nodes = require('../models/Node'); // Gets all node entries -module.exports.list = async (excludePassword) => { - return await nodes.findAll({attributes: {exclude: excludePassword ? ['password'] : []}}); +module.exports.list = async () => { + return await nodes.findAll().then((result) => result.map((node) => ({...node, password: node.password !== null}))); } // Create a new node entry @@ -28,4 +29,37 @@ module.exports.updateName = async (nodeId, name) => { // Update the password of the node entry module.exports.updatePassword = async (nodeId, password) => { return await nodes.update({password: password}, {where: {id: nodeId}}); +} + +module.exports.checkNode = async (url, password) => { + if (password === "none") password = undefined; + const api = await axios.get(url + "/api/config", {headers: {password: password}}).catch(() => { + return "INVALID_URL"; + }); + + if (api === "INVALID_URL" || api.status !== 200) return "INVALID_URL"; + + if (!api.data.ping) return "INVALID_URL"; + + if (api.data.viewMode) return "PASSWORD_REQUIRED"; + + return "NODE_VALID"; +} + +module.exports.proxyRequest = async (url, req, res) => { + const response = await axios(url, { + method: req.method, + headers: req.headers, + data: req.method === "GET" ? undefined : JSON.stringify(req.body), + signal: req.signal, + validateStatus: (status) => status >= 200 && status < 400 + }).catch(() => "INVALID_URL"); + + if (response === "INVALID_URL") + return res.status(500).json({message: "Internal server error"}); + + if (response.headers["content-disposition"]) + res.setHeader("content-disposition", response.headers["content-disposition"]); + + res.status(response.status).json(response.data); } \ No newline at end of file diff --git a/server/index.js b/server/index.js index f005e99f..5dfb8d45 100755 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,9 @@ const timerTask = require('./tasks/timer'); const healthCheckTask = require('./tasks/healthchecks'); const app = express(); + +app.disable('x-powered-by'); + const port = process.env.port || 5216; // Create the data folder and the servers file diff --git a/server/routes/export.js b/server/routes/export.js index beacbea3..32268e6b 100644 --- a/server/routes/export.js +++ b/server/routes/export.js @@ -10,6 +10,8 @@ app.get("/json", password(false), async (req, res) => { app.get("/csv", password(false), async (req, res) => { res.set({"Content-Disposition": "attachment; filename=\"speedtests.csv\""}); let list = await tests.list(); + + if (list.length === 0) return res.send(""); let fields = Object.keys(list[0]); let replacer = (key, value) => value === null ? '' : value; diff --git a/server/routes/nodes.js b/server/routes/nodes.js index 42487df1..b3b89172 100644 --- a/server/routes/nodes.js +++ b/server/routes/nodes.js @@ -1,10 +1,11 @@ const app = require('express').Router(); const nodes = require('../controller/node'); const password = require("../middlewares/password"); +const {checkNode, proxyRequest} = require("../controller/node"); // List all nodes app.get("/", password(false), async (req, res) => { - return res.json(await nodes.list(true)); + return res.json(await nodes.list()); }); // Create a node @@ -13,18 +14,14 @@ app.put("/", password(false), async (req, res) => { const url = req.body.url.replace(/\/+$/, ""); - const headers = req.body.password ? {password: req.body.password} : {}; - - fetch(url + "/api/config", {headers}).then(async api => { - if (api.status !== 200) + checkNode(url, req.body.password).then(async (result) => { + if (result === "INVALID_URL") return res.status(400).json({message: "Invalid URL", type: "INVALID_URL"}); - if ((await api.json()).viewMode) + if (result === "PASSWORD_REQUIRED") return res.status(400).json({message: "Invalid password", type: "PASSWORD_REQUIRED"}); res.json({id: (await nodes.create(req.body.name, url, req.body.password)).id, type: "NODE_CREATED"}); - }).catch(async () => { - res.status(400).json({message: "Invalid URL", type: "INVALID_URL"}); }); }); @@ -55,17 +52,15 @@ app.patch("/:nodeId/password", password(false), async (req, res) => { const node = await nodes.get(req.params.nodeId); if (node === null) return res.status(404).json({message: "Node not found"}); - fetch(node.url + "/api/config", {headers: {password: req.body.password}}).then(async api => { - if (api.status !== 200) + checkNode(node.url, req.body.password).then(async (result) => { + if (result === "INVALID_URL") return res.status(400).json({message: "Invalid URL", type: "INVALID_URL"}); - if ((await api.json()).viewMode) + if (result === "PASSWORD_REQUIRED") return res.status(400).json({message: "Invalid password", type: "PASSWORD_REQUIRED"}); - await nodes.updatePassword(req.params.nodeId, req.body.password); + await nodes.updatePassword(req.params.nodeId, req.body.password === "none" ? null : req.body.password); res.json({message: "Node password successfully updated", type: "PASSWORD_UPDATED"}); - }).catch(async () => { - res.status(400).json({message: "Invalid URL", type: "INVALID_URL"}); }); }); @@ -79,19 +74,7 @@ app.all("/:nodeId/*", password(false), async (req, res) => { req.headers['password'] = node.password; delete req.headers['host']; - fetch(url, { - method: req.method, - headers: req.headers, - body: req.method === "GET" ? undefined : JSON.stringify(req.body), - signal: req.signal - }).then(async api => { - if (api.headers.get("content-disposition")) - res.setHeader("content-disposition", api.headers.get("content-disposition")); - - res.status(api.status).json(await api.json()); - }).catch(() => { - res.status(500).json({message: "Internal server error"}); - }); + await proxyRequest(url, req, res); }); module.exports = app; \ No newline at end of file