Add AdGuard, Bazarr, and Coin Market Cap widgets

- Allow setting HTTP method in widget.js
- Allow sending allow listed query params to proxy
This commit is contained in:
Jason Fischer 2022-09-25 14:31:41 -07:00
parent f999f4a467
commit 03fa2f86d7
No known key found for this signature in database
10 changed files with 251 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 overseerr from "./overseerr/widget";
import radarr from "./radarr/widget"; import radarr from "./radarr/widget";
import sonarr from "./sonarr/widget" import sonarr from "./sonarr/widget"
const widgets = { const widgets = {
adguard,
bazarr,
coinmarketcap,
overseerr, overseerr,
radarr, radarr,
sonarr, sonarr,