浏览代码

Add AdGuard, Bazarr, and Coin Market Cap widgets
- Allow setting HTTP method in widget.js
- Allow sending allow listed query params to proxy

Jason Fischer 2 年之前
父节点
当前提交
03fa2f86d7

+ 14 - 2
src/pages/api/services/proxy.js

@@ -1,3 +1,5 @@
+import { URLSearchParams } from "next/dist/compiled/@edge-runtime/primitives/url";
+
 import createLogger from "utils/logger";
 import genericProxyHandler from "utils/proxies/generic";
 import widgets from "widgets/widgets";
@@ -15,20 +17,30 @@ export default async function handler(req, res) {
     }
 
     const serviceProxyHandler = widget.proxyHandler || genericProxyHandler;
+    req.method = "GET";
 
     if (serviceProxyHandler instanceof Function) {
       // map opaque endpoints to their actual endpoint
       const mapping = widget?.mappings?.[req.query.endpoint];
+      const mappingParams = mapping.params;
       const map = mapping?.map;
       const endpoint = mapping?.endpoint;
-      const endpointProxy = mapping?.proxyHandler;
+      const endpointProxy = mapping?.proxyHandler || serviceProxyHandler;
+      req.method = mapping?.method || "GET";
 
       if (!endpoint) {
         logger.debug("Unsupported service endpoint: %s", type);
         return res.status(403).json({ error: "Unsupported service endpoint" });
       }
 
-      req.query.endpoint = endpoint;
+      if (req.query.params) {
+        const queryParams = JSON.parse(req.query.params);
+        const query = new URLSearchParams(mappingParams.map(p => [p, queryParams[p]]));
+        req.query.endpoint = `${endpoint}?${query}`;
+      }
+      else {
+        req.query.endpoint = endpoint;
+      }
 
       if (endpointProxy instanceof Function) {
         return endpointProxy(req, res, map);

+ 4 - 7
src/utils/api-helpers.js

@@ -2,8 +2,6 @@
 //   emby: `{url}/emby/{endpoint}?api_key={key}`,
 //   jellyfin: `{url}/emby/{endpoint}?api_key={key}`,
 //   pihole: `{url}/admin/{endpoint}`,
-//   radarr: `{url}/api/v3/{endpoint}?apikey={key}`,
-//   sonarr: `{url}/api/v3/{endpoint}?apikey={key}`,
 //   speedtest: `{url}/api/{endpoint}`,
 //   tautulli: `{url}/api/v2?apikey={key}&cmd={endpoint}`,
 //   traefik: `{url}/api/{endpoint}`,
@@ -12,18 +10,14 @@
 //   transmission: `{url}/transmission/rpc`,
 //   qbittorrent: `{url}/api/v2/{endpoint}`,
 //   jellyseerr: `{url}/api/v1/{endpoint}`,
-//   overseerr: `{url}/api/v1/{endpoint}`,
 //   ombi: `{url}/api/v1/{endpoint}`,
 //   npm: `{url}/api/{endpoint}`,
 //   lidarr: `{url}/api/v1/{endpoint}?apikey={key}`,
 //   readarr: `{url}/api/v1/{endpoint}?apikey={key}`,
-//   bazarr: `{url}/api/{endpoint}/wanted?apikey={key}`,
 //   sabnzbd: `{url}/api/?apikey={key}&output=json&mode={endpoint}`,
-//   coinmarketcap: `https://pro-api.coinmarketcap.com/{endpoint}`,
 //   gotify: `{url}/{endpoint}`,
 //   prowlarr: `{url}/api/v1/{endpoint}`,
 //   jackett: `{url}/api/v2.0/{endpoint}?apikey={key}&configured=true`,
-//   adguard: `{url}/control/{endpoint}`,
 //   strelaysrv: `{url}/{endpoint}`,
 //   mastodon: `{url}/api/v1/{endpoint}`,
 // };
@@ -38,13 +32,16 @@ export function formatApiCall(url, args) {
   return url.replace(find, replace);
 }
 
-export function formatProxyUrl(widget, endpoint) {
+export function formatProxyUrl(widget, endpoint, endpointParams) {
   const params = new URLSearchParams({
     type: widget.type,
     group: widget.service_group,
     service: widget.service_name,
     endpoint,
   });
+  if (endpointParams) {
+    params.append("params", JSON.stringify(endpointParams));
+  }
   return `/api/services/proxy?${params.toString()}`;
 }
 

+ 44 - 0
src/widgets/adguard/component.jsx

@@ -0,0 +1,44 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: adguardData, error: adguardError } = useSWR(formatProxyUrl(config, "stats"));
+
+  if (adguardError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!adguardData) {
+    return (
+      <Widget>
+        <Block label={t("adguard.queries")} />
+        <Block label={t("adguard.blocked")} />
+        <Block label={t("adguard.filtered")} />
+        <Block label={t("adguard.latency")} />
+      </Widget>
+    );
+  }
+
+  const filtered =
+    adguardData.num_replaced_safebrowsing + adguardData.num_replaced_safesearch + adguardData.num_replaced_parental;
+
+  return (
+    <Widget>
+      <Block label={t("adguard.queries")} value={t("common.number", { value: adguardData.num_dns_queries })} />
+      <Block label={t("adguard.blocked")} value={t("common.number", { value: adguardData.num_blocked_filtering })} />
+      <Block label={t("adguard.filtered")} value={t("common.number", { value: filtered })} />
+      <Block
+        label={t("adguard.latency")}
+        value={t("common.ms", { value: adguardData.avg_processing_time * 1000, style: "unit", unit: "millisecond" })}
+      />
+    </Widget>
+  );
+}

+ 14 - 0
src/widgets/adguard/widget.js

@@ -0,0 +1,14 @@
+import genericProxyHandler from "utils/proxies/generic";
+
+const widget = {
+  api: "{url}/control/{endpoint}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    "stats": {
+      endpoint: "stats",
+    },
+  },
+};
+
+export default widget;

+ 35 - 0
src/widgets/bazarr/component.jsx

@@ -0,0 +1,35 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: episodesData, error: episodesError } = useSWR(formatProxyUrl(config, "episodes"));
+  const { data: moviesData, error: moviesError } = useSWR(formatProxyUrl(config, "movies"));
+
+  if (episodesError || moviesError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!episodesData || !moviesData) {
+    return (
+      <Widget>
+        <Block label={t("bazarr.missingEpisodes")} />
+        <Block label={t("bazarr.missingMovies")} />
+      </Widget>
+    );
+  }
+
+  return (
+    <Widget>
+      <Block label={t("bazarr.missingEpisodes")} value={t("common.number", { value: episodesData.total })} />
+      <Block label={t("bazarr.missingMovies")} value={t("common.number", { value: moviesData.total })} />
+    </Widget>
+  );
+}

+ 24 - 0
src/widgets/bazarr/widget.js

@@ -0,0 +1,24 @@
+import genericProxyHandler from "utils/proxies/generic";
+import { asJson } from "utils/api-helpers";
+
+const widget = {
+  api: "{url}/api/{endpoint}/wanted?apikey={key}",
+  proxyHandler: genericProxyHandler,
+
+  mappings: {
+    "movies": {
+      endpoint: "movies",
+      map: (data) => ({
+        total: asJson(data).total,
+      }),
+    },
+    "episodes": {
+      endpoint: "episodes",
+      map: (data) => ({
+        total: asJson(data).total,
+      }),
+    },
+  },
+};
+
+export default widget;

+ 92 - 0
src/widgets/coinmarketcap/component.jsx

@@ -0,0 +1,92 @@
+import useSWR from "swr";
+import { useState } from "react";
+import { useTranslation } from "next-i18next";
+import classNames from "classnames";
+
+import Widget from "components/services/widgets/widget";
+import Block from "components/services/widgets/block";
+import Dropdown from "components/services/dropdown";
+import { formatProxyUrl } from "utils/api-helpers";
+
+export default function Component({ service }) {
+  const { t } = useTranslation();
+
+  const dateRangeOptions = [
+    { label: t("coinmarketcap.1hour"), value: "1h" },
+    { label: t("coinmarketcap.1day"), value: "24h" },
+    { label: t("coinmarketcap.7days"), value: "7d" },
+    { label: t("coinmarketcap.30days"), value: "30d" },
+  ];
+
+  const [dateRange, setDateRange] = useState(dateRangeOptions[0].value);
+
+  const config = service.widget;
+  const currencyCode = config.currency ?? "USD";
+  const { symbols } = config;
+
+  const { data: statsData, error: statsError } = useSWR(
+    formatProxyUrl(config, "v1/cryptocurrency/quotes/latest", {
+      symbol: `${symbols.join(",")}`,
+      convert: `${currencyCode}`
+    })
+  );
+
+  if (!symbols || symbols.length === 0) {
+    return (
+      <Widget>
+        <Block value={t("coinmarketcap.configure")} />
+      </Widget>
+    );
+  }
+
+  if (statsError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!statsData || !dateRange) {
+    return (
+      <Widget>
+        <Block value={t("coinmarketcap.configure")} />
+      </Widget>
+    );
+  }
+
+  const { data } = statsData;
+
+  return (
+    <Widget>
+      <div className={classNames(service.description ? "-top-10" : "-top-8", "absolute right-1")}>
+        <Dropdown options={dateRangeOptions} value={dateRange} setValue={setDateRange} />
+      </div>
+
+      <div className="flex flex-col w-full">
+        {symbols.map((symbol) => (
+          <div
+            key={data[symbol].symbol}
+            className="bg-theme-200/50 dark:bg-theme-900/20 rounded m-1 flex-1 flex flex-row items-center justify-between p-1 text-xs"
+          >
+            <div className="font-thin pl-2">{data[symbol].name}</div>
+            <div className="flex flex-row text-right">
+              <div className="font-bold mr-2">
+                {t("common.number", {
+                  value: data[symbol].quote[currencyCode].price,
+                  style: "currency",
+                  currency: currencyCode,
+                })}
+              </div>
+              <div
+                className={`font-bold w-10 mr-2 ${
+                  data[symbol].quote[currencyCode][`percent_change_${dateRange}`] > 0
+                    ? "text-emerald-300"
+                    : "text-rose-300"
+                }`}
+              >
+                {data[symbol].quote[currencyCode][`percent_change_${dateRange}`].toFixed(2)}%
+              </div>
+            </div>
+          </div>
+        ))}
+      </div>
+    </Widget>
+  );
+}

+ 15 - 0
src/widgets/coinmarketcap/widget.js

@@ -0,0 +1,15 @@
+import credentialedProxyHandler from "utils/proxies/credentialed";
+
+const widget = {
+  api: "https://pro-api.coinmarketcap.com/{endpoint}",
+  proxyHandler: credentialedProxyHandler,
+
+  mappings: {
+    "v1/cryptocurrency/quotes/latest": {
+      endpoint: "v1/cryptocurrency/quotes/latest",
+      params: ["symbol", "convert"],
+    },
+  },
+};
+
+export default widget;

+ 3 - 0
src/widgets/components.js

@@ -1,6 +1,9 @@
 import dynamic from "next/dynamic";
 
 const components = {
+  adguard: dynamic(() => import("./adguard/component")),
+  bazarr: dynamic(() => import("./bazarr/component")),
+  coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
   overseerr: dynamic(() => import("./overseerr/component")),
   radarr: dynamic(() => import("./radarr/component")),
   sonarr: dynamic(() => import("./sonarr/component")),

+ 6 - 0
src/widgets/widgets.js

@@ -1,8 +1,14 @@
+import adguard from "./adguard/widget";
+import bazarr from "./bazarr/widget";
+import coinmarketcap from "./coinmarketcap/widget";
 import overseerr from "./overseerr/widget";
 import radarr from "./radarr/widget";
 import sonarr from "./sonarr/widget"
 
 const widgets = {
+  adguard,
+  bazarr,
+  coinmarketcap,
   overseerr,
   radarr,
   sonarr,