From dac68650f5a0051357c5ced62413f4829e6f4cab Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 10:43:42 +0000 Subject: [PATCH 1/9] Remove "stale removal" code --- express/backend/src/cron.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/express/backend/src/cron.ts b/express/backend/src/cron.ts index db8442b..b14f93b 100644 --- a/express/backend/src/cron.ts +++ b/express/backend/src/cron.ts @@ -64,12 +64,6 @@ async function collectRepos(): Promise { ]); const collection = db.repoAdapters(); - // remove "stale" entries - const { deletedCount } = await collection.deleteMany({ - source: { $exists: false }, - }); - console.log(`Deleted ${deletedCount || 0} stale entries`); - await Promise.all([ addRepoAdapters(collection, latest, "latest"), addRepoAdapters(collection, stable, "stable"), From 0207e919d851eb8db80f72e3de5d5c122b2a6626 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 12:36:38 +0000 Subject: [PATCH 2/9] Add current version statistics as a pie chart Closes #60 --- express/backend/src/api/adapter.ts | 38 ++++++++++++- express/frontend/src/lib/ioBroker.ts | 16 +++++- express/frontend/src/router.tsx | 4 +- express/frontend/src/tools/AdapterCheck.tsx | 2 +- .../src/tools/adapter/AdapterDashboard.tsx | 19 +++---- .../adapter/statistics/CurrentVersions.tsx | 55 +++++++++++++++++++ .../tools/adapter/statistics/Statistics.tsx | 16 ++++++ .../VersionHistory.tsx} | 45 +++++++-------- 8 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx create mode 100644 express/frontend/src/tools/adapter/statistics/Statistics.tsx rename express/frontend/src/tools/adapter/{AdapterStatistics.tsx => statistics/VersionHistory.tsx} (79%) diff --git a/express/backend/src/api/adapter.ts b/express/backend/src/api/adapter.ts index 2ab3406..107a6ed 100644 --- a/express/backend/src/api/adapter.ts +++ b/express/backend/src/api/adapter.ts @@ -1,11 +1,45 @@ import { Router } from "express"; import { dbConnect, unescapeObjectKeys } from "../db/utils"; -import { AdapterStats } from "../global/adapter-stats"; +import { AdapterStats, AdapterVersions } from "../global/adapter-stats"; import { Statistics } from "../global/iobroker"; const router = Router(); -router.get("/api/adapter/:name/stats", async function (req, res) { +router.get("/api/adapter/:name/stats/now", async function (req, res) { + try { + const { name } = req.params; + const db = await dbConnect(); + const rawStatistics = db.rawStatistics(); + + const stats = await rawStatistics + .find() + .project({ + adapters: { [name]: 1 }, + versions: { [name]: 1 }, + date: 1, + _id: 0, + }) + .sort({ date: -1 }) + .limit(1) + .toArray(); + if (stats.length === 0) { + res.status(404).send(`Adapter ${name} not found`); + return; + } + + const stat = unescapeObjectKeys(stats[0]); + const versions: AdapterVersions = { + total: stat.adapters[name] ?? 0, + versions: stat.versions[name] ?? {}, + }; + res.send(versions); + } catch (error: any) { + console.error(error); + res.status(500).send(error.message || error); + } +}); + +router.get("/api/adapter/:name/stats/history", async function (req, res) { try { const { name } = req.params; const db = await dbConnect(); diff --git a/express/frontend/src/lib/ioBroker.ts b/express/frontend/src/lib/ioBroker.ts index e75a701..05d6de2 100644 --- a/express/frontend/src/lib/ioBroker.ts +++ b/express/frontend/src/lib/ioBroker.ts @@ -1,5 +1,8 @@ import axios from "axios"; -import { AdapterStats } from "../../../backend/src/global/adapter-stats"; +import { + AdapterStats, + AdapterVersions, +} from "../../../backend/src/global/adapter-stats"; import { AdapterRatings, AllRatings, @@ -175,9 +178,16 @@ export const getWeblateAdapterComponents = AsyncCache.of(async () => { return result.data; }); -export async function getStatistics(adapterName: string) { +export async function getCurrentVersions(adapterName: string) { + const result = await axios.get( + getApiUrl(`adapter/${uc(adapterName)}/stats/now`), + ); + return result.data; +} + +export async function getStatisticsHistory(adapterName: string) { const result = await axios.get( - getApiUrl(`adapter/${uc(adapterName)}/stats`), + getApiUrl(`adapter/${uc(adapterName)}/stats/history`), ); return result.data; } diff --git a/express/frontend/src/router.tsx b/express/frontend/src/router.tsx index af326d4..147c019 100644 --- a/express/frontend/src/router.tsx +++ b/express/frontend/src/router.tsx @@ -5,7 +5,7 @@ import { UserProvider } from "./contexts/UserContext"; import { AdapterDashboard } from "./tools/adapter/AdapterDashboard"; import { AdapterDetails } from "./tools/adapter/AdapterDetails"; import { AdapterRatings } from "./tools/adapter/AdapterRatings"; -import { AdapterStatistics } from "./tools/adapter/AdapterStatistics"; +import { Statistics } from "./tools/adapter/statistics/Statistics"; import { CreateReleaseDialog } from "./tools/adapter/releases/CreateReleaseDialog"; import { Releases } from "./tools/adapter/releases/Releases"; import { UpdateRepositoriesDialog } from "./tools/adapter/releases/UpdateRepositoriesDialog"; @@ -72,7 +72,7 @@ export const router = createBrowserRouter([ }, { path: "statistics", - element: , + element: , }, { path: "ratings", diff --git a/express/frontend/src/tools/AdapterCheck.tsx b/express/frontend/src/tools/AdapterCheck.tsx index d5a9676..a2464f5 100644 --- a/express/frontend/src/tools/AdapterCheck.tsx +++ b/express/frontend/src/tools/AdapterCheck.tsx @@ -26,7 +26,7 @@ import { useLocation } from "react-router-dom"; import { useUserToken } from "../contexts/UserContext"; import { checkAdapter, CheckResult, getMyAdapterRepos } from "../lib/ioBroker"; -const iconStyles = { +export const iconStyles = { check: { color: "#00b200", }, diff --git a/express/frontend/src/tools/adapter/AdapterDashboard.tsx b/express/frontend/src/tools/adapter/AdapterDashboard.tsx index 3d2603f..a1e678f 100644 --- a/express/frontend/src/tools/adapter/AdapterDashboard.tsx +++ b/express/frontend/src/tools/adapter/AdapterDashboard.tsx @@ -10,7 +10,11 @@ import { CardButton } from "../../components/CardButton"; import { CardGrid, CardGridProps } from "../../components/dashboard/CardGrid"; import { DashboardCardProps } from "../../components/dashboard/DashboardCard"; import { useAdapter } from "../../contexts/AdapterContext"; -import { getAllRatings, getLatest, getStatistics } from "../../lib/ioBroker"; +import { + getAllRatings, + getCurrentVersions, + getLatest, +} from "../../lib/ioBroker"; const CATEGORY_GENERAL = "General"; const CATEGORY_FEATURES = "Features"; @@ -28,18 +32,11 @@ export function AdapterDashboard() { useEffect(() => { setCategories(EMPTY_CARDS); const loadCards = async () => { - const [latest, ratings] = await Promise.all([ + const [latest, ratings, versions] = await Promise.all([ getLatest(), getAllRatings(), + getCurrentVersions(name).catch(() => null), ]); - let hasStatistics = !!latest[name]; - if (!hasStatistics) { - // check if statistics are available - try { - await getStatistics(name); - hasStatistics = true; - } catch {} - } const generalCards: DashboardCardProps[] = []; generalCards.push({ title: "Releases", @@ -51,7 +48,7 @@ export function AdapterDashboard() { to: "releases", buttons: [], }); - if (hasStatistics) { + if (latest[name] || versions?.total) { generalCards.push({ title: "Statistics", text: "Learn more about the usage and distribution of your adapter.", diff --git a/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx b/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx new file mode 100644 index 0000000..192dc74 --- /dev/null +++ b/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx @@ -0,0 +1,55 @@ +import Chart from "react-google-charts"; +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useAdapter } from "../../../contexts/AdapterContext"; +import { useEffect, useState } from "react"; +import { getCurrentVersions } from "../../../lib/ioBroker"; + +type GraphData = [string, string | number][]; + +export function CurrentVersions() { + const { name } = useAdapter(); + const [graphData, setGraphData] = useState(); + + useEffect(() => { + setGraphData(undefined); + const loadHistory = async () => { + const stats = await getCurrentVersions(name); + const data: GraphData = [["Version", "Count"]]; + for (const [version, count] of Object.entries( + stats.versions, + ).reverse()) { + data.push([version, count]); + } + setGraphData(data); + }; + loadHistory().catch((e) => { + console.error(e); + setGraphData(undefined); + }); + }, [name]); + + return ( + + + Currently installed versions + + } + data={graphData} + options={{ + is3D: true, + backgroundColor: "transparent", + sliceVisibilityThreshold: 0.05, + /*colors: [ + iconStyles.error.color, + iconStyles.warning.color, + iconStyles.check.color, + ],*/ + }} + /> + + ); +} diff --git a/express/frontend/src/tools/adapter/statistics/Statistics.tsx b/express/frontend/src/tools/adapter/statistics/Statistics.tsx new file mode 100644 index 0000000..86bb1b7 --- /dev/null +++ b/express/frontend/src/tools/adapter/statistics/Statistics.tsx @@ -0,0 +1,16 @@ +import { Paper } from "@mui/material"; +import { VersionHistory } from "./VersionHistory"; +import { CurrentVersions } from "./CurrentVersions"; + +export function Statistics() { + return ( + <> + + + + + + + + ); +} diff --git a/express/frontend/src/tools/adapter/AdapterStatistics.tsx b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx similarity index 79% rename from express/frontend/src/tools/adapter/AdapterStatistics.tsx rename to express/frontend/src/tools/adapter/statistics/VersionHistory.tsx index d640702..cc201c9 100644 --- a/express/frontend/src/tools/adapter/AdapterStatistics.tsx +++ b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx @@ -1,14 +1,14 @@ -import { Box, Paper } from "@mui/material"; +import { Box } from "@mui/material"; import ReactECharts from "echarts-for-react"; import { useEffect, useState } from "react"; import { coerce } from "semver"; import sort from "semver/functions/sort"; -import { useAdapter } from "../../contexts/AdapterContext"; -import { getStatistics } from "../../lib/ioBroker"; +import { useAdapter } from "../../../contexts/AdapterContext"; +import { getStatisticsHistory } from "../../../lib/ioBroker"; const chartDefaults = { title: { - text: "Installed versions", + text: "Installed version history", }, tooltip: { trigger: "axis", @@ -56,9 +56,7 @@ const chartDefaults = { series: [], }; -export interface AdapterStatisticsProps {} - -export function AdapterStatistics(props: AdapterStatisticsProps) { +export function VersionHistory() { const { name } = useAdapter(); const [option, setOption] = useState(); const [showLoading, setShowLoading] = useState(true); @@ -66,8 +64,8 @@ export function AdapterStatistics(props: AdapterStatisticsProps) { useEffect(() => { setOption(undefined); setShowLoading(true); - const loadStatistics = async () => { - const stats = await getStatistics(name); + const loadHistory = async () => { + const stats = await getStatisticsHistory(name); const versions = new Set(); for (const date of Object.keys(stats.counts)) { Object.keys(stats.counts[date].versions) @@ -147,26 +145,25 @@ export function AdapterStatistics(props: AdapterStatisticsProps) { series, }); }; - loadStatistics().catch((e) => { + loadHistory().catch((e) => { console.error(e); setShowLoading(false); setOption(undefined); }); }, [name]); + if (!option || showLoading) { + return null; + } return ( - - {(option || showLoading) && ( - - - - )} - + + + ); } From b0ed5845520a0d219f3ebf15239d4c50165b0b47 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 15:47:00 +0000 Subject: [PATCH 3/9] Improve security by only allowing valid adapter names --- express/backend/src/api/adapter.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/express/backend/src/api/adapter.ts b/express/backend/src/api/adapter.ts index 107a6ed..c844301 100644 --- a/express/backend/src/api/adapter.ts +++ b/express/backend/src/api/adapter.ts @@ -8,6 +8,11 @@ const router = Router(); router.get("/api/adapter/:name/stats/now", async function (req, res) { try { const { name } = req.params; + if (!isValidAdapterName(name)) { + res.status(404).send("Adapter not found"); + return; + } + const db = await dbConnect(); const rawStatistics = db.rawStatistics(); @@ -23,7 +28,7 @@ router.get("/api/adapter/:name/stats/now", async function (req, res) { .limit(1) .toArray(); if (stats.length === 0) { - res.status(404).send(`Adapter ${name} not found`); + res.status(404).send("Adapter not found"); return; } @@ -42,6 +47,10 @@ router.get("/api/adapter/:name/stats/now", async function (req, res) { router.get("/api/adapter/:name/stats/history", async function (req, res) { try { const { name } = req.params; + if (!isValidAdapterName(name)) { + res.status(404).send("Adapter not found"); + return; + } const db = await dbConnect(); const rawStatistics = db.rawStatistics(); const repoAdapters = db.repoAdapters(); @@ -96,7 +105,7 @@ router.get("/api/adapter/:name/stats/history", async function (req, res) { console.log(result); if (Object.keys(result.counts).length === 0) { - res.status(404).send(`Adapter ${name} not found`); + res.status(404).send("Adapter not found"); return; } @@ -107,4 +116,14 @@ router.get("/api/adapter/:name/stats/history", async function (req, res) { } }); +function isValidAdapterName(name: string) { + const forbiddenChars = /[^a-z0-9\-_]/g; + if (forbiddenChars.test(name)) { + return false; + } + + // the name must start with a letter + return /^[a-z]/.test(name); +} + export default router; From 7dd098aab95d1d9175ca33daa50544efa4c3ab51 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 16:07:40 +0000 Subject: [PATCH 4/9] Add login page when trying to access a restricted page Fixes #88 --- .../src/components/dashboard/LoginButton.tsx | 4 +- express/frontend/src/contexts/UserContext.tsx | 8 +- express/frontend/src/router.tsx | 113 +++++++++++------- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/express/frontend/src/components/dashboard/LoginButton.tsx b/express/frontend/src/components/dashboard/LoginButton.tsx index ddf5703..9acc1e5 100644 --- a/express/frontend/src/components/dashboard/LoginButton.tsx +++ b/express/frontend/src/components/dashboard/LoginButton.tsx @@ -1,7 +1,7 @@ import { useUserContext } from "../../contexts/UserContext"; import { CardButton } from "../CardButton"; -export function LoginButton() { +export function LoginButton({ variant }: { variant?: string }) { const { login } = useUserContext(); - return ; + return ; } diff --git a/express/frontend/src/contexts/UserContext.tsx b/express/frontend/src/contexts/UserContext.tsx index fd2689a..49da6cc 100644 --- a/express/frontend/src/contexts/UserContext.tsx +++ b/express/frontend/src/contexts/UserContext.tsx @@ -27,11 +27,17 @@ export function useUserContext() { return context; } +export class UserTokenMissingError extends Error { + constructor() { + super("User token missing"); + } +} + export function useUserToken() { const { user } = useUserContext(); const token = user?.token; if (!token) { - throw new Error("User token missing"); + throw new UserTokenMissingError(); } return token; } diff --git a/express/frontend/src/router.tsx b/express/frontend/src/router.tsx index 147c019..be53bf8 100644 --- a/express/frontend/src/router.tsx +++ b/express/frontend/src/router.tsx @@ -1,14 +1,16 @@ -import { createBrowserRouter } from "react-router-dom"; +import { Paper, Typography } from "@mui/material"; +import { createBrowserRouter, useRouteError } from "react-router-dom"; import { App } from "./App"; import { Dashboard } from "./components/dashboard/Dashboard"; -import { UserProvider } from "./contexts/UserContext"; +import { LoginButton } from "./components/dashboard/LoginButton"; +import { UserProvider, UserTokenMissingError } from "./contexts/UserContext"; import { AdapterDashboard } from "./tools/adapter/AdapterDashboard"; import { AdapterDetails } from "./tools/adapter/AdapterDetails"; import { AdapterRatings } from "./tools/adapter/AdapterRatings"; -import { Statistics } from "./tools/adapter/statistics/Statistics"; import { CreateReleaseDialog } from "./tools/adapter/releases/CreateReleaseDialog"; import { Releases } from "./tools/adapter/releases/Releases"; import { UpdateRepositoriesDialog } from "./tools/adapter/releases/UpdateRepositoriesDialog"; +import { Statistics } from "./tools/adapter/statistics/Statistics"; import { AdapterCheck } from "./tools/AdapterCheck"; import { StartCreateAdapter } from "./tools/create-adapter/StartCreateAdapter"; import { Wizard } from "./tools/create-adapter/Wizard"; @@ -24,66 +26,89 @@ export const router = createBrowserRouter([ children: [ { - path: "/create-adapter", + path: "/", + errorElement: , children: [ { - index: true, - element: , + path: "/create-adapter", + children: [ + { + index: true, + element: , + }, + { + path: "wizard", + element: , + }, + ], }, { - path: "wizard", - element: , - }, - ], - }, - { - path: "/adapter-check", - element: , - }, - { - path: "/adapter/:name", - element: , - children: [ - { - index: true, - element: , + path: "/adapter-check", + element: , }, { - path: "releases", - element: , + path: "/adapter/:name", + element: , children: [ { - path: "~release", - element: , + index: true, + element: , }, { - path: "~to-latest", - element: ( - - ), + path: "releases", + element: , + children: [ + { + path: "~release", + element: , + }, + { + path: "~to-latest", + element: ( + + ), + }, + { + path: "~to-stable/:version", + element: ( + + ), + }, + ], }, { - path: "~to-stable/:version", - element: ( - - ), + path: "statistics", + element: , + }, + { + path: "ratings", + element: , }, ], }, { - path: "statistics", - element: , - }, - { - path: "ratings", - element: , + index: true, + element: , }, ], }, - { - index: true, - element: , - }, ], }, ]); + +function ErrorBoundary() { + let error = useRouteError(); + if (error instanceof UserTokenMissingError) { + return ( + + Not logged in +

You need to be logged in to access this page.

+

+ +

+
+ ); + } + + throw error; +} From f2bd855ae7ef6dea1ea50456a37d241c015a7ff0 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 17:11:51 +0100 Subject: [PATCH 5/9] Potential fix for code scanning alert no. 37: Information exposure through a stack trace Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- express/backend/src/api/adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express/backend/src/api/adapter.ts b/express/backend/src/api/adapter.ts index c844301..b7b21aa 100644 --- a/express/backend/src/api/adapter.ts +++ b/express/backend/src/api/adapter.ts @@ -40,7 +40,7 @@ router.get("/api/adapter/:name/stats/now", async function (req, res) { res.send(versions); } catch (error: any) { console.error(error); - res.status(500).send(error.message || error); + res.status(500).send("An unexpected error occurred"); } }); @@ -112,7 +112,7 @@ router.get("/api/adapter/:name/stats/history", async function (req, res) { res.send(result); } catch (error: any) { console.error(error); - res.status(500).send(error.message || error); + res.status(500).send("An unexpected error occurred"); } }); From 6900516bd5554d816b9ad80ac964554b984babc5 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 16:10:23 +0000 Subject: [PATCH 6/9] Fix wrong condition --- .../frontend/src/tools/adapter/statistics/VersionHistory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx index cc201c9..2f9fa0b 100644 --- a/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx +++ b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx @@ -151,7 +151,7 @@ export function VersionHistory() { setOption(undefined); }); }, [name]); - if (!option || showLoading) { + if (!option && !showLoading) { return null; } return ( From f1deec276d1a51f17fd7e8ddd32b92a3c2708d10 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 16:22:55 +0000 Subject: [PATCH 7/9] Fix navigating from adapter card to repo checker Fixes #86 --- express/frontend/src/tools/AdapterCheck.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/express/frontend/src/tools/AdapterCheck.tsx b/express/frontend/src/tools/AdapterCheck.tsx index a2464f5..1f5fd75 100644 --- a/express/frontend/src/tools/AdapterCheck.tsx +++ b/express/frontend/src/tools/AdapterCheck.tsx @@ -22,7 +22,7 @@ import { import { OverridableComponent } from "@mui/material/OverridableComponent"; import { useEffect, useState } from "react"; import Chart from "react-google-charts"; -import { useLocation } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { useUserToken } from "../contexts/UserContext"; import { checkAdapter, CheckResult, getMyAdapterRepos } from "../lib/ioBroker"; @@ -92,7 +92,7 @@ export interface AdapterCheckLocationState { export function AdapterCheck() { const token = useUserToken(); - let location = useLocation(); + const [searchParams] = useSearchParams(); const [repoNames, setRepoNames] = useState([]); const [repoName, setRepoName] = useState(""); const [busy, setBusy] = useState(false); @@ -105,14 +105,12 @@ export function AdapterCheck() { loadData().catch(console.error); }, [token]); - const incomingState = location.state as - | AdapterCheckLocationState - | undefined; + const repo = searchParams.get("repo"); useEffect(() => { - if (incomingState?.repoFullName) { - setRepoName(incomingState.repoFullName); + if (repo) { + setRepoName(repo); } - }, [incomingState]); + }, [repo]); const handleStartClick = async () => { setMessages([]); From 8bf56b12495e2954c3457fc2789c9b153cadac96 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 16:34:50 +0000 Subject: [PATCH 8/9] Add link to report an issue Closes #85 --- express/frontend/src/Root.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/express/frontend/src/Root.tsx b/express/frontend/src/Root.tsx index 021fb4b..009ab6b 100644 --- a/express/frontend/src/Root.tsx +++ b/express/frontend/src/Root.tsx @@ -1,3 +1,4 @@ +import { Flag } from "@mui/icons-material"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import MenuIcon from "@mui/icons-material/Menu"; import { @@ -138,6 +139,17 @@ export function Root() { ioBroker Developer Portal + {!user && (