Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements and bug fixes #87

Merged
merged 10 commits into from
Jan 21, 2025
59 changes: 56 additions & 3 deletions express/backend/src/api/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,56 @@
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;
if (!isValidAdapterName(name)) {
res.status(404).send("Adapter not found");
return;
}

const db = await dbConnect();
const rawStatistics = db.rawStatistics();

const stats = await rawStatistics
.find()
.project<Statistics>({
adapters: { [name]: 1 },

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
versions: { [name]: 1 },

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name is now checked against some regexes that ensure it can only be a valid adapter name. These names can't be used for prototype pollution or similar attacks.

date: 1,

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name is now checked against some regexes that ensure it can only be a valid adapter name. These names can't be used for prototype pollution or similar attacks.

_id: 0,
})
.sort({ date: -1 })
.limit(1)
.toArray();
if (stats.length === 0) {
res.status(404).send("Adapter 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);
Fixed Show fixed Hide fixed
}
});

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();
Expand Down Expand Up @@ -62,7 +105,7 @@

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;
}

Expand All @@ -73,4 +116,14 @@
}
});

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;
6 changes: 0 additions & 6 deletions express/backend/src/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,6 @@ async function collectRepos(): Promise<void> {
]);
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"),
Expand Down
4 changes: 2 additions & 2 deletions express/frontend/src/components/dashboard/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -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 <CardButton text="Login" onClick={login} />;
return <CardButton text="Login" onClick={login} variant={variant} />;
}
8 changes: 7 additions & 1 deletion express/frontend/src/contexts/UserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 13 additions & 3 deletions express/frontend/src/lib/ioBroker.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -180,9 +183,16 @@ export const getWeblateAdapterComponents = AsyncCache.of(async () => {
return components;
});

export async function getStatistics(adapterName: string) {
export async function getCurrentVersions(adapterName: string) {
const result = await axios.get<AdapterVersions>(
getApiUrl(`adapter/${uc(adapterName)}/stats/now`),
);
return result.data;
}

export async function getStatisticsHistory(adapterName: string) {
const result = await axios.get<AdapterStats>(
getApiUrl(`adapter/${uc(adapterName)}/stats`),
getApiUrl(`adapter/${uc(adapterName)}/stats/history`),
);
return result.data;
}
Expand Down
113 changes: 69 additions & 44 deletions express/frontend/src/router.tsx
Original file line number Diff line number Diff line change
@@ -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 { AdapterStatistics } from "./tools/adapter/AdapterStatistics";
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";
Expand All @@ -24,66 +26,89 @@ export const router = createBrowserRouter([

children: [
{
path: "/create-adapter",
path: "/",
errorElement: <ErrorBoundary />,
children: [
{
index: true,
element: <StartCreateAdapter />,
path: "/create-adapter",
children: [
{
index: true,
element: <StartCreateAdapter />,
},
{
path: "wizard",
element: <Wizard />,
},
],
},
{
path: "wizard",
element: <Wizard />,
path: "/adapter-check",
element: <AdapterCheck />,
},
],
},
{
path: "/adapter-check",
element: <AdapterCheck />,
},
{
path: "/adapter/:name",
element: <AdapterDetails />,
children: [
{
index: true,
element: <AdapterDashboard />,
},
{
path: "releases",
element: <Releases />,
path: "/adapter/:name",
element: <AdapterDetails />,
children: [
{
path: "~release",
element: <CreateReleaseDialog />,
index: true,
element: <AdapterDashboard />,
},
{
path: "~to-latest",
element: (
<UpdateRepositoriesDialog action="to-latest" />
),
path: "releases",
element: <Releases />,
children: [
{
path: "~release",
element: <CreateReleaseDialog />,
},
{
path: "~to-latest",
element: (
<UpdateRepositoriesDialog action="to-latest" />
),
},
{
path: "~to-stable/:version",
element: (
<UpdateRepositoriesDialog action="to-stable" />
),
},
],
},
{
path: "~to-stable/:version",
element: (
<UpdateRepositoriesDialog action="to-stable" />
),
path: "statistics",
element: <Statistics />,
},
{
path: "ratings",
element: <AdapterRatings />,
},
],
},
{
path: "statistics",
element: <AdapterStatistics />,
},
{
path: "ratings",
element: <AdapterRatings />,
index: true,
element: <Dashboard />,
},
],
},
{
index: true,
element: <Dashboard />,
},
],
},
]);

function ErrorBoundary() {
let error = useRouteError();
if (error instanceof UserTokenMissingError) {
return (
<Paper sx={{ padding: 2 }}>
<Typography variant="h4">Not logged in</Typography>
<p>You need to be logged in to access this page.</p>
<p>
<LoginButton variant="contained" />
</p>
</Paper>
);
}

throw error;
}
2 changes: 1 addition & 1 deletion express/frontend/src/tools/AdapterCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
19 changes: 8 additions & 11 deletions express/frontend/src/tools/adapter/AdapterDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -51,7 +48,7 @@ export function AdapterDashboard() {
to: "releases",
buttons: [<CardButton text="Manage" to="releases" />],
});
if (hasStatistics) {
if (latest[name] || versions?.total) {
generalCards.push({
title: "Statistics",
text: "Learn more about the usage and distribution of your adapter.",
Expand Down
Loading
Loading