diff --git a/client/public/assets/locales/en.json b/client/public/assets/locales/en.json index 99c29b1d..b2dc5bb0 100644 --- a/client/public/assets/locales/en.json +++ b/client/public/assets/locales/en.json @@ -44,6 +44,8 @@ "dropdown": { "settings": "Settings", "changes_applied": "Your changes have been saved.", + "language_changed": "The language has been changed.", + "provider_changed": "The provider has been changed.", "view_changed": "The view has been changed.", "changes_unsaved": "Your changes were not applied. Check your input.", "invalid": "Input invalid", @@ -55,7 +57,7 @@ "password": "Change password", "cron": "Set frequency", "time": "Set period", - "export": "Export tests", + "storage": "Manage storage", "pause_tests": "Pause tests", "resume_tests": "Resume tests", "language": "Change language", @@ -78,10 +80,6 @@ "rare": "Rarely (every 3 hours)", "really_rare": "Very rarely (every 6 hours)" }, - "export": { - "json": "JSON file", - "csv": "CSV file" - }, "level": { "no_access": "No Access", "read_access": "Read-only Access" @@ -107,7 +105,6 @@ "cron_rules": "Cron rule", "cron_next_test": "Next Test:", "time_title": "Show tests of the last ...", - "export_title": "Export speedtests", "download": "Download", "pause_title": "Pause speedtests for...", "hours": "Hours", @@ -130,6 +127,33 @@ "description": "This feature is still in beta. If you find any bugs, please report them here." } }, + "storage": { + "speedtests": "Speedtests", + "configuration": "Configuration", + "stored_tests": "Stored tests", + "tests": "Tests", + "export_tests": "Export tests", + "tests_exported": "The tests have been exported", + "csv": "CSV", + "json": "JSON", + "import_tests": "Import tests", + "tests_imported": "The tests have been imported", + "import_error": "An error occurred while importing the tests", + "export": "Export", + "import": "Import", + "clear_history": "Clear history", + "history_cleared": "The history has been cleared", + "delete": "Delete", + "confirm_delete": "Yes, delete", + "export_settings": "Export settings", + "import_settings": "Import settings", + "factory_reset": "Factory reset", + "factory_reset_completed": "The factory reset has been completed", + "reset": "Reset", + "confirm_reset": "Yes, reset", + "settings_exported": "The settings have been exported", + "settings_imported": "The settings have been imported" + }, "latest": { "ping": "Ping", "ping_unit": "ms", @@ -140,7 +164,7 @@ "before": "before" }, "info": { - "credits": "MySpeed is provided by GNMYT and uses the Speedtest CLI from Ookla.", + "credits": "MySpeed is a open source project provided by GNMYT. Leave a star on GitHub or donate to support the project.", "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}} 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.", diff --git a/client/src/common/components/Dropdown/DropdownComponent.jsx b/client/src/common/components/Dropdown/DropdownComponent.jsx index 15c947ff..b176f82a 100644 --- a/client/src/common/components/Dropdown/DropdownComponent.jsx +++ b/client/src/common/components/Dropdown/DropdownComponent.jsx @@ -7,7 +7,6 @@ import { faCircleNodes, faClock, faClose, - faFileExport, faChartSimple, faGear, faGlobeEurope, @@ -18,15 +17,15 @@ import { faPlay, faWandMagicSparkles, faCheck, - faExclamationTriangle, faSliders + faExclamationTriangle, faSliders, faHardDrive } from "@fortawesome/free-solid-svg-icons"; import {ConfigContext} from "@/common/contexts/Config"; import {StatusContext} from "@/common/contexts/Status"; import {InputDialogContext} from "@/common/contexts/InputDialog"; import {SpeedtestContext} from "@/common/contexts/Speedtests"; -import {baseRequest, downloadRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil"; +import {baseRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil"; import {creditsInfo, recommendationsInfo} from "@/common/components/Dropdown/utils/infos"; -import {exportOptions, levelOptions, selectOptions, timeOptions} from "@/common/components/Dropdown/utils/options"; +import {levelOptions, selectOptions, timeOptions} from "@/common/components/Dropdown/utils/options"; import {parseCron, stringifyCron} from "@/common/components/Dropdown/utils/utils"; import {t} from "i18next"; import ViewDialog from "@/common/components/ViewDialog"; @@ -36,6 +35,7 @@ import {NodeContext} from "@/common/contexts/Node"; import {IntegrationDialog} from "@/common/components/IntegrationDialog"; import LanguageDialog from "@/common/components/LanguageDialog"; import ProviderDialog from "@/common/components/ProviderDialog"; +import StorageDialog from "@/common/components/StorageDialog"; let icon; @@ -66,6 +66,7 @@ function DropdownComponent() { const [showIntegrationDialog, setShowIntegrationDialog] = useState(false); const [showLanguageDialog, setShowLanguageDialog] = useState(false); const [showProviderDialog, setShowProviderDialog] = useState(false); + const [showStorageDialog, setShowStorageDialog] = useState(false); const ref = useRef(); useEffect(() => { @@ -192,17 +193,6 @@ function DropdownComponent() { }); } - function exportDialog() { - setDialog({ - select: true, - title: t("update.export_title"), - buttonText: t("update.download"), - value: "json", - selectOptions: exportOptions(), - onSuccess: value => downloadRequest("/export/" + value) - }); - } - const togglePause = () => { if (!status.paused) { setDialog({ @@ -228,9 +218,9 @@ function DropdownComponent() { {run: recommendedSettings, icon: faWandMagicSparkles, text: t("dropdown.recommendations")}, {hr: true, key: 1}, {run: () => setShowProviderDialog(true), icon: faSliders, text: t("dropdown.change_provider")}, + {run: () => setShowStorageDialog(true), icon: faHardDrive, text: t("dropdown.storage")}, {run: updatePassword, icon: faKey, text: t("dropdown.password"), previewHidden: true}, {run: updateCron, icon: faClock, text: t("dropdown.cron")}, - {run: exportDialog, icon: faFileExport, text: t("dropdown.export")}, {run: togglePause, icon: status.paused ? faPlay : faPause, text: t("dropdown." + (status.paused ? "resume_tests" : "pause_tests"))}, {run: () => setShowIntegrationDialog(true), icon: faCircleNodes, text: t("dropdown.integrations")}, {hr: true, key: 2}, @@ -247,6 +237,7 @@ function DropdownComponent() { {showIntegrationDialog && setShowIntegrationDialog(false)}/>} {showLanguageDialog && setShowLanguageDialog(false)}/>} {showProviderDialog && setShowProviderDialog(false)}/>} + {showStorageDialog && setShowStorageDialog(false)}/>} {t("dropdown.settings")} diff --git a/client/src/common/components/Dropdown/utils/infos.jsx b/client/src/common/components/Dropdown/utils/infos.jsx index 775bfa7c..96d17079 100644 --- a/client/src/common/components/Dropdown/utils/infos.jsx +++ b/client/src/common/components/Dropdown/utils/infos.jsx @@ -1,9 +1,9 @@ -import {WEB_URL} from "@/index"; +import {DONATION_URL, PROJECT_URL, WEB_URL} from "@/index"; import {Trans} from "react-i18next"; -const CLI_URL = "https://www.speedtest.net/apps/cli"; -export const creditsInfo = () => , - CLILink: }}>info.credits + +export const creditsInfo = () => , + Github: , Donate: }}>info.credits export const recommendationsInfo = (ping, down, up) => }} values={{ping, down, up}}>info.recommendations_info \ No newline at end of file diff --git a/client/src/common/components/Dropdown/utils/options.js b/client/src/common/components/Dropdown/utils/options.js index 8d4360c2..3cb8074f 100644 --- a/client/src/common/components/Dropdown/utils/options.js +++ b/client/src/common/components/Dropdown/utils/options.js @@ -18,9 +18,4 @@ export const selectOptions = () => ({ "0 * * * *": t("options.cron.default"), "0 0,3,6,9,12,15,18,21 * * *": t("options.cron.rare"), "0 0,6,12,18 * * *": t("options.cron.really_rare") -}); - -export const exportOptions = () => ({ - json: t("options.export.json"), - csv: t("options.export.csv") }); \ No newline at end of file diff --git a/client/src/common/components/LanguageDialog/LanguageDialog.jsx b/client/src/common/components/LanguageDialog/LanguageDialog.jsx index 56e3f7f0..d8d4ae48 100644 --- a/client/src/common/components/LanguageDialog/LanguageDialog.jsx +++ b/client/src/common/components/LanguageDialog/LanguageDialog.jsx @@ -1,17 +1,20 @@ import {DialogContext, DialogProvider} from "@/common/contexts/Dialog"; import {t, changeLanguage} from "i18next"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faClose} from "@fortawesome/free-solid-svg-icons"; +import {faClose, faGlobe} from "@fortawesome/free-solid-svg-icons"; import "./styles.sass"; import {languages} from "@/i18n"; import {useContext, useState} from "react"; +import {ToastNotificationContext} from "@/common/contexts/ToastNotification"; export const Dialog = () => { const [selectedLanguage, setSelectedLanguage] = useState(localStorage.getItem("language") || "en"); + const updateToast = useContext(ToastNotificationContext); const close = useContext(DialogContext); const updateLanguage = () => { changeLanguage(selectedLanguage); + updateToast(t('dropdown.language_changed'), "green", faGlobe); close(); } diff --git a/client/src/common/components/LanguageDialog/styles.sass b/client/src/common/components/LanguageDialog/styles.sass index 462e858b..30013139 100644 --- a/client/src/common/components/LanguageDialog/styles.sass +++ b/client/src/common/components/LanguageDialog/styles.sass @@ -20,6 +20,7 @@ cursor: pointer transition: background-color 0.3s border-radius: 0.5rem + border: 2px solid $darker-gray &:hover background-color: $darker-gray @@ -37,6 +38,7 @@ .language-selected background-color: $light-gray color: $white + border: 2px solid $light-gray &:hover background-color: $light-gray diff --git a/client/src/common/components/ProviderDialog/ProviderDialog.jsx b/client/src/common/components/ProviderDialog/ProviderDialog.jsx index c5030a5d..a2b874e7 100644 --- a/client/src/common/components/ProviderDialog/ProviderDialog.jsx +++ b/client/src/common/components/ProviderDialog/ProviderDialog.jsx @@ -1,7 +1,7 @@ import {DialogContext, DialogProvider} from "@/common/contexts/Dialog"; import {t} from "i18next"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faClose} from "@fortawesome/free-solid-svg-icons"; +import {faCheck, faClose} from "@fortawesome/free-solid-svg-icons"; import "./styles.sass"; import React, {useContext, useEffect, useState} from "react"; import OoklaImage from "./assets/img/ookla.webp"; @@ -10,6 +10,7 @@ import CloudflareImage from "./assets/img/cloudflare.webp"; import {jsonRequest, patchRequest} from "@/common/utils/RequestUtil"; import {Trans} from "react-i18next"; import {ConfigContext} from "@/common/contexts/Config"; +import {ToastNotificationContext} from "@/common/contexts/ToastNotification"; export const providers = [ {id: "ookla", name: "Ookla", image: OoklaImage}, @@ -21,6 +22,7 @@ export const providers = [ export const Dialog = () => { const close = useContext(DialogContext); const [config, reloadConfig] = useContext(ConfigContext); + const updateToast = useContext(ToastNotificationContext); const [provider, setProvider] = useState(config.provider || "ookla"); const [licenseAccepted, setLicenseAccepted] = useState(false); @@ -61,6 +63,7 @@ export const Dialog = () => { } reloadConfig(); + updateToast(t('dropdown.provider_changed'), "green", faCheck); close(); } diff --git a/client/src/common/components/StorageDialog/StorageDialog.jsx b/client/src/common/components/StorageDialog/StorageDialog.jsx new file mode 100644 index 00000000..a20da43b --- /dev/null +++ b/client/src/common/components/StorageDialog/StorageDialog.jsx @@ -0,0 +1,69 @@ +import "./styles.sass"; +import React, {useContext, useEffect, useState} from "react"; +import {DialogContext, DialogProvider} from "@/common/contexts/Dialog"; +import {t} from "i18next"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faClose, faDatabase, faGauge, faScrewdriverWrench} from "@fortawesome/free-solid-svg-icons"; +import Speedtests from "./tabs/Speedtests"; +import Configuration from "./tabs/Configuration"; +import {jsonRequest} from "@/common/utils/RequestUtil"; + +const Dialog = () => { + const close = useContext(DialogContext); + const [storageSize, setStorageSize] = useState({size: 0, testCount: 0}); + + const [currentTab, setCurrentTab] = useState(1); + + useEffect(() => { + jsonRequest("/storage").then((res) => { + setStorageSize(res); + }); + }, []); + + return ( + <> + + {t("dropdown.storage")} + close()}/> + + + + + setCurrentTab(1)}> + + {t("storage.speedtests")} + + setCurrentTab(2)}> + + {t("storage.configuration")} + + + + + + + {Math.round(storageSize.size / 1024)} KB + + + + + + {currentTab === 1 && } + {currentTab === 2 && } + + + + + > + ); +} + +export const StorageDialog = ({onClose}) => { + return ( + + + + ) +} \ No newline at end of file diff --git a/client/src/common/components/StorageDialog/index.js b/client/src/common/components/StorageDialog/index.js new file mode 100644 index 00000000..93681d77 --- /dev/null +++ b/client/src/common/components/StorageDialog/index.js @@ -0,0 +1 @@ +export {StorageDialog as default} from "./StorageDialog"; \ No newline at end of file diff --git a/client/src/common/components/StorageDialog/styles.sass b/client/src/common/components/StorageDialog/styles.sass new file mode 100644 index 00000000..1e7c49cc --- /dev/null +++ b/client/src/common/components/StorageDialog/styles.sass @@ -0,0 +1,91 @@ +@import "@/common/styles/colors" + +.storage-dialog + display: flex + margin-left: 0.5rem + margin-right: 0.5rem + gap: 1rem + width: 45rem + margin-top: 1rem + height: 14rem + user-select: none + +.storage-options + display: flex + flex-direction: column + justify-content: space-between + +.storage-top + display: flex + flex-direction: column + gap: 0.5rem + user-select: none + overflow-x: hidden + overflow-y: scroll + +.storage-tab + display: flex + gap: 0.5rem + align-items: center + padding: 0.6rem 0.7rem + color: $darker-white + border: 2px solid transparent + border-radius: 1rem + cursor: pointer + + svg + width: 1.3rem + height: 1.3rem + p + margin: 0 + font-size: 14pt + + +.reset-cursor + cursor: default + +.storage-item-active + background-color: $light-gray + color: $white + +.storage-manager + width: 70% + display: flex + flex-direction: column + gap: 1rem + +.storage-manager .storage-row + display: flex + justify-content: space-between + align-items: center + + h3 + margin: 0 + color: $darker-white + + .dialog-btn + padding: 0.4rem 1rem + + +@media (max-width: 781px) + .storage-dialog + width: 90vw + margin-left: 0 + height: 100% + margin-top: 0.5rem + margin-right: 0 + flex-direction: column + + .storage-bottom + display: none + + .storage-manager + max-height: 15rem + width: 100% + + .storage-top + flex-direction: row + justify-content: center + overflow-x: scroll + overflow-y: hidden + gap: 0.5rem \ No newline at end of file diff --git a/client/src/common/components/StorageDialog/tabs/Configuration.jsx b/client/src/common/components/StorageDialog/tabs/Configuration.jsx new file mode 100644 index 00000000..0adad1ab --- /dev/null +++ b/client/src/common/components/StorageDialog/tabs/Configuration.jsx @@ -0,0 +1,83 @@ +import React, {useContext, useState} from "react"; +import {deleteRequest, downloadRequest, putRequest} from "@/common/utils/RequestUtil"; +import {DialogContext} from "@/common/contexts/Dialog"; +import {ToastNotificationContext} from "@/common/contexts/ToastNotification"; +import {ConfigContext} from "@/common/contexts/Config"; +import {t} from "i18next"; +import {faClockRotateLeft, faFileExport, faFileImport} from "@fortawesome/free-solid-svg-icons"; + +export default () => { + const close = useContext(DialogContext); + const [deleteWarning, setDeleteWarning] = useState(false); + const updateConfig = useContext(ConfigContext)[1]; + const updateToast = useContext(ToastNotificationContext); + + const exportConfig = () => { + downloadRequest("/storage/config").then(() => { + updateToast(t("storage.settings_exported"), "green", faFileExport); + close(); + }); + } + + const importConfig = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.onchange = () => { + const file = input.files[0]; + const reader = new FileReader(); + reader.readAsText(file); + + reader.onload = () => { + const data = JSON.parse(reader.result); + putRequest("/storage/config", data).then((res) => { + if (res.ok) { + updateToast(t("storage.settings_imported"), "green", faFileImport); + updateConfig(); + close(); + } else { + updateToast(t("storage.import_error"), "red"); + } + }); + } + input.remove(); + } + + input.click(); + } + + const factoryReset = () => { + if (!deleteWarning) { + setDeleteWarning(true); + return; + } + + deleteRequest("/storage/config").then(() => { + setDeleteWarning(false); + updateToast(t("storage.factory_reset_completed"), "green", faClockRotateLeft); + updateConfig(); + close(); + }); + } + + return ( + <> + + {t("storage.export_settings")} + {t("storage.export")} + + + + {t("storage.import_settings")} + {t("storage.import")} + + + + {t("storage.factory_reset")} + + {deleteWarning ? t("storage.confirm_reset") : t("storage.reset")} + + > + ) +} \ No newline at end of file diff --git a/client/src/common/components/StorageDialog/tabs/Speedtests.jsx b/client/src/common/components/StorageDialog/tabs/Speedtests.jsx new file mode 100644 index 00000000..2a8f72c3 --- /dev/null +++ b/client/src/common/components/StorageDialog/tabs/Speedtests.jsx @@ -0,0 +1,95 @@ +import React, {useContext, useState} from "react"; +import {DialogContext} from "@/common/contexts/Dialog"; +import {deleteRequest, downloadRequest, putRequest} from "@/common/utils/RequestUtil"; +import {SpeedtestContext} from "@/common/contexts/Speedtests"; +import {ToastNotificationContext} from "@/common/contexts/ToastNotification"; +import {t} from "i18next"; +import {faFileExport, faFileImport, faTrashCan} from "@fortawesome/free-solid-svg-icons"; + +export default ({tests}) => { + const close = useContext(DialogContext); + const [deleteWarning, setDeleteWarning] = useState(false); + const updateTests = useContext(SpeedtestContext)[1]; + const updateToast = useContext(ToastNotificationContext); + + const deleteHistory = () => { + if (!deleteWarning) { + setDeleteWarning(true); + return; + } + + deleteRequest("/storage/tests/history").then(() => { + setDeleteWarning(false); + updateTests(); + updateToast(t("storage.history_cleared"), "green", faTrashCan); + close(); + }); + } + + const downloadHistory = (type) => { + downloadRequest(`/storage/tests/history/${type}`).then(() => { + updateToast(t("storage.tests_exported"), "green", faFileExport); + close(); + }); + } + + const importHistory = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.onchange = () => { + const file = input.files[0]; + const reader = new FileReader(); + reader.readAsText(file); + + reader.onload = () => { + const data = JSON.parse(reader.result); + putRequest("/storage/tests/history", data).then((res) => { + if (res.ok) { + updateToast(t("storage.tests_imported"), "green", faFileImport); + updateTests(); + } else { + updateToast(t("storage.import_error"), "red"); + } + close(); + }); + } + input.remove(); + } + + input.click(); + } + + + return ( + <> + + {t("storage.stored_tests")} + {tests} {t("storage.tests")} + + + + {t("storage.export_tests")} + + downloadHistory("csv")}> + {t("storage.csv")} + downloadHistory("json")}> + {t("storage.json")} + + + + + {t("storage.import_tests")} + {t("storage.import")} + + + + {t("storage.clear_history")} + + {deleteWarning ? t("storage.confirm_delete") : t("storage.delete")} + + + > + ) +} \ No newline at end of file diff --git a/client/src/common/components/WelcomeDialog/WelcomeDialog.jsx b/client/src/common/components/WelcomeDialog/WelcomeDialog.jsx index d7f5451c..7e9524ff 100644 --- a/client/src/common/components/WelcomeDialog/WelcomeDialog.jsx +++ b/client/src/common/components/WelcomeDialog/WelcomeDialog.jsx @@ -17,9 +17,9 @@ export const Dialog = () => { const [step, setStep] = useState(1); const [provider, setProvider] = useState("ookla"); - const [ping, setPing] = useState(25); - const [download, setDownload] = useState(100); - const [upload, setUpload] = useState(50); + const [ping, setPing] = useState(parseInt(config.ping) || 0); + const [download, setDownload] = useState(parseInt(config.download) || 0); + const [upload, setUpload] = useState(parseInt(config.upload) || 0); const [animating, setAnimating] = useState(false); const finish = async () => { diff --git a/client/src/common/components/WelcomeDialog/steps/DataHelper/styles.sass b/client/src/common/components/WelcomeDialog/steps/DataHelper/styles.sass index cdf9aed2..02a64e93 100644 --- a/client/src/common/components/WelcomeDialog/steps/DataHelper/styles.sass +++ b/client/src/common/components/WelcomeDialog/steps/DataHelper/styles.sass @@ -42,4 +42,19 @@ .speed-text p margin: 0 - color: $subtext \ No newline at end of file + color: $subtext + + +@media screen and (max-width: 600px) + .data-helper .speeds + flex-direction: column + gap: 1rem + margin-top: 0.5rem + + .speed + width: 100% + flex-direction: row + justify-content: space-between + + input + width: 50% \ No newline at end of file diff --git a/client/src/common/components/WelcomeDialog/styles.sass b/client/src/common/components/WelcomeDialog/styles.sass index c0fc2ebf..ab36eb3d 100644 --- a/client/src/common/components/WelcomeDialog/styles.sass +++ b/client/src/common/components/WelcomeDialog/styles.sass @@ -35,3 +35,11 @@ to opacity: 1 transform: translateX(0) + + +@media screen and (max-width: 600px) + .welcome-banner + width: 100% + + .slide-in + animation: slide-in 0.5s forwards \ No newline at end of file diff --git a/client/src/common/styles/default.sass b/client/src/common/styles/default.sass index 20a9f023..d4da0e02 100644 --- a/client/src/common/styles/default.sass +++ b/client/src/common/styles/default.sass @@ -2,6 +2,7 @@ body, html margin: 0 + overflow-x: hidden background-color: $background font-family: "Inter", sans-serif font-weight: 700 diff --git a/client/src/index.jsx b/client/src/index.jsx index 06ef0812..8dbeae89 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -5,6 +5,7 @@ import App from './App'; export const PROJECT_URL = "https://github.com/gnmyt/myspeed"; export const WEB_URL = "https://myspeed.dev"; export const PROJECT_WIKI = "https://docs.myspeed.dev"; +export const DONATION_URL = "https://ko-fi.com/gnmyt"; const root = ReactDOM.createRoot(document.getElementById('root')); diff --git a/client/src/pages/Home/components/LatestTest/styles.sass b/client/src/pages/Home/components/LatestTest/styles.sass index 87009817..b40d2ce8 100644 --- a/client/src/pages/Home/components/LatestTest/styles.sass +++ b/client/src/pages/Home/components/LatestTest/styles.sass @@ -41,6 +41,7 @@ color: $white font-weight: 700 font-size: 24pt + white-space: nowrap .container-subtext color: $subtext diff --git a/server/controller/config.js b/server/controller/config.js index d0a00600..e9ee3fb3 100644 --- a/server/controller/config.js +++ b/server/controller/config.js @@ -1,5 +1,15 @@ const config = require("../models/Config"); +const node = require("../models/Node"); +const test = require("../models/Speedtests"); +const recommendations = require("../models/Recommendations"); +const integration = require("../models/IntegrationData"); const {triggerEvent} = require("./integrations"); +const bcrypt = require('bcrypt'); +const timer = require('../tasks/timer'); +const cron = require('cron-validator'); +const db = require("../config/database"); +const fs = require('fs'); +const path = require('path'); const configDefaults = { ping: "25", @@ -38,4 +48,131 @@ module.exports.updateValue = async (key, newValue) => { .then(undefined); return await config.update({value: newValue}, {where: {key: key}}); +} + +module.exports.getUsedStorage = async () => { + let size = 0; + + if (process.env.DB_TYPE === "mysql") { + const sizes = await db.query("SELECT table_name AS `Table`, ROUND((data_length + index_length), 2) AS `size` FROM information_schema.TABLES WHERE table_schema = ?;", { + replacements: [process.env.DB_NAME], + type: db.QueryTypes.SELECT + }); + for (let i = 0; i < sizes.length; i++) { + size += parseFloat(sizes[i].size); + } + } else { + const STORAGE_PATH = `../../data/storage${process.env.PREVIEW_MODE === "true" ? "_preview" : ""}.db`; + + size = fs.statSync(path.join(__dirname, STORAGE_PATH)).size; + } + + return {size, testCount: await test.count()}; +} + +module.exports.validateInput = async (key, value) => { + if (!value?.toString()) return "You need to provide the new value"; + + if ((key === "ping" || key === "download" || key === "upload") && isNaN(value)) + return "You need to provide a number in order to change this"; + + if ((key === "ooklaId" || key === "libreId") && (isNaN(value) && value !== "none")) + return "You need to provide a number in order to change this"; + + if (key === "passwordLevel" && !["none", "read"].includes(value)) + return "You need to provide either none or read-access"; + + if (key === "provider" && !["ookla", "libre", "cloudflare"].includes(value)) + return "You need to provide a valid provider"; + + if (key === "ping") + value = value.toString().split(".")[0]; + + if (key === "password" && value !== "none") + value = await bcrypt.hash(value, 10); + + if (key === "cron" && !cron.isValidCron(value.toString())) + return "Not a valid cron expression"; + + if (configDefaults[key] === undefined) + return "The provided key does not exist"; + + if (process.env.PREVIEW_MODE === "true" && (key === "password" || key === "passwordLevel")) + return "You can't change the password in preview mode"; + + return {value: value}; +} + +module.exports.exportConfig = async () => { + let obj = {}; + obj.config = {}; + + let configValues = await config.findAll(); + for (let i = 0; i < configValues.length; i++) { + if (configValues[i].key === "password") continue; + obj.config[configValues[i].key] = configValues[i].value; + } + + obj.nodes = await node.findAll(); + obj.recommendations = await recommendations.findAll(); + + obj.integrations = await integration.findAll(); + + return obj; +} + +module.exports.importConfig = async (obj) => { + let configValues = obj.config; + for (let key in configValues) { + if (configDefaults[key] === undefined) return false; + if (key === "password") continue; + + const validate = await this.validateInput(key, configValues[key]); + if (Object.keys(validate).length !== 1) return false; + + if (key === "cron") { + timer.stopTimer(); + timer.startTimer(configValues[key].toString()); + } + + await config.update({value: validate.value}, {where: {key: key}}); + } + + if (recommendations.length > 1) return false; + + await node.destroy({where: {}}); + await recommendations.destroy({where: {}}); + await integration.destroy({where: {}}); + + try { + await node.bulkCreate(obj.nodes); + + for (let i = 0; i < obj.integrations.length; i++) { + obj.integrations[i].data = JSON.parse(obj.integrations[i].data); + } + + await integration.bulkCreate(obj.integrations); + + await recommendations.bulkCreate(obj.recommendations); + } catch (e) { + return false; + } + + return true; +} + +module.exports.factoryReset = async () => { + let configValues = await config.findAll(); + for (let i = 0; i < configValues.length; i++) { + await config.update({value: configDefaults[configValues[i].key]}, {where: {key: configValues[i].key}}); + } + + await node.destroy({where: {}}); + await recommendations.destroy({where: {}}); + await integration.destroy({where: {}}); + + timer.stopTimer(); + timer.startTimer(configDefaults.cron); + + return true; } \ No newline at end of file diff --git a/server/controller/speedtests.js b/server/controller/speedtests.js index 78588f56..cf1df856 100644 --- a/server/controller/speedtests.js +++ b/server/controller/speedtests.js @@ -66,6 +66,25 @@ module.exports.listAverage = async (days) => { return result; } +module.exports.deleteTests = async () => { + await tests.destroy({where: {}}); + return true; +} + +module.exports.importTests = async (data) => { + if (!Array.isArray(data)) return false; + + for (let entry of data) { + if (entry.error === null) delete entry.error; + try { + await tests.create(entry); + } catch (e) { + } + } + + return true; +} + module.exports.listStatistics = async (days) => { let dbEntries = (await tests.findAll({order: [["created", "DESC"]]})) .filter((entry) => new Date(entry.created) > new Date().getTime() - (days <= 30 ? days : 30 ) * 24 * 3600000); diff --git a/server/index.js b/server/index.js index d5f38334..4b73199d 100755 --- a/server/index.js +++ b/server/index.js @@ -20,7 +20,7 @@ app.use(require('./middlewares/error')); app.use("/api/config", require('./routes/config')); app.use("/api/speedtests", require('./routes/speedtests')); app.use("/api/info", require('./routes/system')); -app.use("/api/export", require('./routes/export')); +app.use("/api/storage", require('./routes/storage')); app.use("/api/recommendations", require('./routes/recommendations')); app.use("/api/nodes", require('./routes/nodes')); app.use("/api/integrations", require('./routes/integrations')); diff --git a/server/routes/config.js b/server/routes/config.js index 89d13a14..7294330f 100644 --- a/server/routes/config.js +++ b/server/routes/config.js @@ -1,7 +1,6 @@ const app = require('express').Router(); const config = require('../controller/config'); const timer = require('../tasks/timer'); -const cron = require('cron-validator'); const password = require('../middlewares/password'); app.get("/", password(true), async (req, res) => { @@ -21,34 +20,11 @@ app.get("/", password(true), async (req, res) => { }); app.patch("/:key", password(false), async (req, res) => { - if (!req.body.value?.toString()) return res.status(400).json({message: "You need to provide the new value"}); + const value = await config.validateInput(req.params.key, req.body?.value); + if (Object.keys(value).length !== 1) return res.status(400).json({message: value}); - if ((req.params.key === "ping" || req.params.key === "download" || req.params.key === "upload") && isNaN(req.body.value)) - return res.status(400).json({message: "You need to provide a number in order to change this"}); - - if ((req.params.key === "ooklaId" || req.params.key === "libreId") && (isNaN(req.body.value) && req.body.value !== "none")) - return res.status(400).json({message: "You need to provide a number in order to change this"}); - - - if (req.params.key === "passwordLevel" && !["none", "read"].includes(req.body.value)) - return res.status(400).json({message: "You need to provide either none or read-access"}); - - if (req.params.key === "provider" && !["ookla", "libre", "cloudflare"].includes(req.body.value)) - return res.status(400).json({message: "You need to provide a valid provider"}); - - if (req.params.key === "ping") - req.body.value = req.body.value.toString().split(".")[0]; - - if (req.params.key === "password" && req.body.value !== "none") req.body.value = await require('bcrypt').hash(req.body.value, 10); - - if (req.params.key === "cron" && !cron.isValidCron(req.body.value.toString())) - return res.status(500).json({message: "Not a valid cron expression"}); - - if (!await config.updateValue(req.params.key, req.body.value.toString())) - return res.status(404).json({message: "The provided key does not exist"}); - - if (process.env.PREVIEW_MODE === "true" && (req.params.key === "password" || req.params.key === "passwordLevel")) - return res.status(403).json({message: "You can't change the password in preview mode"}); + if (!await config.updateValue(req.params.key, value.value)) + return res.status(500).json({message: `Error updating the key '${req.params.key}'`}); if (req.params.key === "cron") { timer.stopTimer(); diff --git a/server/routes/export.js b/server/routes/export.js deleted file mode 100644 index 687642d6..00000000 --- a/server/routes/export.js +++ /dev/null @@ -1,26 +0,0 @@ -const app = require('express').Router(); -const tests = require('../controller/speedtests'); -const password = require('../middlewares/password'); - -app.get("/json", password(false), async (req, res) => { - res.set({"Content-Disposition": "attachment; filename=\"speedtests.json\""}); - res.send(JSON.stringify(await tests.listTests(), null, 4)); -}); - -app.get("/csv", password(false), async (req, res) => { - res.set({"Content-Disposition": "attachment; filename=\"speedtests.csv\""}); - let list = await tests.listTests(); - - if (list.length === 0) return res.send(""); - let fields = Object.keys(list[0]); - - let replacer = (key, value) => value === null ? '' : value; - - let csv = list.map(row => fields.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(',')); - csv.unshift('"' + fields.join('","') + '"'); - csv = csv.join('\r\n'); - - res.send(csv); -}); - -module.exports = app; \ No newline at end of file diff --git a/server/routes/storage.js b/server/routes/storage.js new file mode 100644 index 00000000..9bbc04fe --- /dev/null +++ b/server/routes/storage.js @@ -0,0 +1,56 @@ +const app = require('express').Router(); +const tests = require('../controller/speedtests'); +const config = require('../controller/config'); +const password = require('../middlewares/password'); + +app.get("/", password(false), async (req, res) => { + res.json(await config.getUsedStorage()); +}); + +app.get("/tests/history/json", password(false), async (req, res) => { + res.set({"Content-Disposition": "attachment; filename=\"speedtests.json\""}); + res.send(JSON.stringify(await tests.listTests(), null, 4)); +}); + +app.get("/tests/history/csv", password(false), async (req, res) => { + res.set({"Content-Disposition": "attachment; filename=\"speedtests.csv\""}); + let list = await tests.listTests(); + + if (list.length === 0) return res.send(""); + let fields = Object.keys(list[0]); + + let replacer = (key, value) => value === null ? '' : value; + + let csv = list.map(row => fields.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(',')); + csv.unshift('"' + fields.join('","') + '"'); + csv = csv.join('\r\n'); + + res.send(csv); +}); + +app.delete("/tests/history", password(false), async (req, res) => { + let result = await tests.deleteTests(); + res.status(result ? 200 : 500).json({message: result ? "Tests deleted" : "Error deleting tests"}); +}); + +app.put("/tests/history", password(false), async (req, res) => { + let result = await tests.importTests(req.body); + res.status(result ? 200 : 500).json({message: result ? "Tests imported" : "Error importing tests"}); +}); + +app.get("/config", password(false), async (req, res) => { + res.set({"Content-Disposition": "attachment; filename=\"config.json\""}); + config.exportConfig().then(obj => res.json(obj)); +}); + +app.put("/config", password(false), async (req, res) => { + let result = await config.importConfig(req.body); + res.status(result ? 200 : 500).json({message: result ? "Config imported" : "Error importing config"}); +}); + +app.delete("/config", password(false), async (req, res) => { + let result = await config.factoryReset(); + res.status(result ? 200 : 500).json({message: result ? "Config reset" : "Error resetting config"}); +}); + +module.exports = app; \ No newline at end of file
{t("storage.speedtests")}
{t("storage.configuration")}
{Math.round(storageSize.size / 1024)} KB