From 7266390491f9946d2aa415e4905514653ce2cb57 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Wed, 23 Nov 2022 11:51:53 -0800 Subject: [PATCH 1/6] Add Deluge widget - Create semi-generic jsonrpc proxy handler - Refactor NZBGet to use jsonrpc proxy handler closes #190 --- public/locales/en/common.json | 6 +++ src/utils/proxy/handlers/jsonrpc.js | 82 +++++++++++++++++++++++++++++ src/widgets/components.js | 1 + src/widgets/deluge/component.jsx | 52 ++++++++++++++++++ src/widgets/deluge/proxy.js | 63 ++++++++++++++++++++++ src/widgets/deluge/widget.js | 8 +++ src/widgets/nzbget/proxy.js | 40 -------------- src/widgets/nzbget/widget.js | 5 +- src/widgets/widgets.js | 2 + 9 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 src/utils/proxy/handlers/jsonrpc.js create mode 100644 src/widgets/deluge/component.jsx create mode 100644 src/widgets/deluge/proxy.js create mode 100644 src/widgets/deluge/widget.js delete mode 100644 src/widgets/nzbget/proxy.js diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4d1b5774..8098273e 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -112,6 +112,12 @@ "leech": "Leech", "seed": "Seed" }, + "deluge": { + "download": "Download", + "upload": "Upload", + "leech": "Leech", + "seed": "Seed" + }, "sonarr": { "wanted": "Wanted", "queued": "Queued", diff --git a/src/utils/proxy/handlers/jsonrpc.js b/src/utils/proxy/handlers/jsonrpc.js new file mode 100644 index 00000000..9677fa50 --- /dev/null +++ b/src/utils/proxy/handlers/jsonrpc.js @@ -0,0 +1,82 @@ +import { JSONRPCClient, JSONRPCErrorException } from "json-rpc-2.0"; + +import { formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const logger = createLogger("jsonrpcProxyHandler"); + +export async function sendJsonRpcRequest(url, method, params, username, password) { + const headers = { + "content-type": "application/json", + "accept": "application/json" + } + + if (username && password) { + const authorization = Buffer.from(`${username}:${password}`).toString("base64"); + headers.authorization = `Basic ${authorization}`; + } + + const client = new JSONRPCClient(async (rpcRequest) => { + const httpRequestParams = { + method: "POST", + headers, + body: JSON.stringify(rpcRequest) + }; + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await httpProxy(url, httpRequestParams); + const dataString = data.toString(); + if (status === 200) { + const json = JSON.parse(dataString); + + // in order to get access to the underlying error object in the JSON response + // you must set `result` equal to undefined + if (json.error && (json.result === null)) { + json.result = undefined; + } + return client.receive(json); + } + + return Promise.reject(new Error(dataString)); + }); + + try { + const response = await client.request(method, params); + return [200, "application/json", JSON.stringify(response)]; + } + catch (e) { + if (e instanceof JSONRPCErrorException) { + return [200, "application/json", JSON.stringify({result: null, error: {code: e.code, message: e.message}})]; + } + + logger.warn("Error calling JSONPRC endpoint: %s. %s", url, e); + return [500, "application/json", JSON.stringify({result: null, error: {code: 2, message: e.toString()}})]; + } +} + +export default async function jsonrpcProxyHandler(req, res) { + const { group, service, endpoint: method } = req.query; + + if (group && service) { + const widget = await getServiceWidget(group, service); + const api = widgets?.[widget.type]?.api; + + if (!api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + if (widget) { + const url = formatApiCall(api, { ...widget }); + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await sendJsonRpcRequest(url, method, null, widget.username, widget.password); + res.status(status).end(data); + } + } + + logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); +} diff --git a/src/widgets/components.js b/src/widgets/components.js index b781172b..e15ed4d8 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -7,6 +7,7 @@ const components = { bazarr: dynamic(() => import("./bazarr/component")), changedetectionio: dynamic(() => import("./changedetectionio/component")), coinmarketcap: dynamic(() => import("./coinmarketcap/component")), + deluge: dynamic(() => import("./deluge/component")), docker: dynamic(() => import("./docker/component")), emby: dynamic(() => import("./emby/component")), gluetun: dynamic(() => import("./gluetun/component")), diff --git a/src/widgets/deluge/component.jsx b/src/widgets/deluge/component.jsx new file mode 100644 index 00000000..40f8c672 --- /dev/null +++ b/src/widgets/deluge/component.jsx @@ -0,0 +1,52 @@ +import { useTranslation } from "next-i18next"; + +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: torrentData, error: torrentError } = useWidgetAPI(widget); + + if (torrentError) { + return ; + } + + if (!torrentData) { + return ( + + + + + + + ); + } + + const { torrents } = torrentData; + let count = 0; + let rateDl = 0; + let rateUl = 0; + let completed = 0; + for (const key of Object.keys(torrents)) { + const torrent = torrents[key]; + count += 1; + rateDl += torrent.download_payload_rate; + rateUl += torrent.upload_payload_rate; + completed += torrent.total_remaining === 0 ? 1 : 0; + } + + const leech = count - completed || 0; + + return ( + + + + + + + ); +} diff --git a/src/widgets/deluge/proxy.js b/src/widgets/deluge/proxy.js new file mode 100644 index 00000000..e9dac0d9 --- /dev/null +++ b/src/widgets/deluge/proxy.js @@ -0,0 +1,63 @@ +import { formatApiCall } from "utils/proxy/api-helpers"; +import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const logger = createLogger("delugeProxyHandler"); + +const dataMethod = "web.update_ui"; +const dataParams = [ + ["queue", "name", "total_wanted", "state", "progress", "download_payload_rate", "upload_payload_rate", "total_remaining"], + {} +]; +const loginMethod = "auth.login"; + +async function sendRpc(url, method, params, username, password) { + const [status, contentType, data] = await sendJsonRpcRequest(url, method, params, username, password); + const json = JSON.parse(data.toString()); + if (json?.error) { + if (json.error.code === 1) { + return [403, contentType, data]; + } + return [500, contentType, data]; + } + + return [status, contentType, data]; +} + +function login(url, username, password) { + return sendRpc(url, loginMethod, [password], username, password); +} + +export default async function delugeProxyHandler(req, res) { + const { group, service } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const api = widgets?.[widget.type]?.api + const url = new URL(formatApiCall(api, { ...widget })); + + let [status, contentType, data] = await sendRpc(url, dataMethod, dataParams, widget.username, widget.password); + if (status === 403) { + [status, contentType, data] = await login(url, widget.username, widget.password); + if (status !== 200) { + return res.status(status).end(data); + } + + // eslint-disable-next-line no-unused-vars + [status, contentType, data] = await sendRpc(url, dataMethod, dataParams, widget.username, widget.password); + } + + return res.status(status).end(data); +} diff --git a/src/widgets/deluge/widget.js b/src/widgets/deluge/widget.js new file mode 100644 index 00000000..b5518b66 --- /dev/null +++ b/src/widgets/deluge/widget.js @@ -0,0 +1,8 @@ +import delugeProxyHandler from "./proxy"; + +const widget = { + api: "{url}/json", + proxyHandler: delugeProxyHandler, +}; + +export default widget; diff --git a/src/widgets/nzbget/proxy.js b/src/widgets/nzbget/proxy.js deleted file mode 100644 index 4feac781..00000000 --- a/src/widgets/nzbget/proxy.js +++ /dev/null @@ -1,40 +0,0 @@ -import { JSONRPCClient } from "json-rpc-2.0"; - -import getServiceWidget from "utils/config/service-helpers"; - -export default async function nzbgetProxyHandler(req, res) { - const { group, service, endpoint } = req.query; - - if (group && service) { - const widget = await getServiceWidget(group, service); - - if (widget) { - const constructedUrl = new URL(widget.url); - constructedUrl.pathname = "jsonrpc"; - - const authorization = Buffer.from(`${widget.username}:${widget.password}`).toString("base64"); - - const client = new JSONRPCClient((jsonRPCRequest) => - fetch(constructedUrl.toString(), { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${authorization}`, - }, - body: JSON.stringify(jsonRPCRequest), - }).then(async (response) => { - if (response.status === 200) { - const jsonRPCResponse = await response.json(); - return client.receive(jsonRPCResponse); - } - - return Promise.reject(new Error(response.statusText)); - }) - ); - - return res.send(await client.request(endpoint)); - } - } - - return res.status(400).json({ error: "Invalid proxy service type" }); -} diff --git a/src/widgets/nzbget/widget.js b/src/widgets/nzbget/widget.js index 975c8dea..841fb66c 100644 --- a/src/widgets/nzbget/widget.js +++ b/src/widgets/nzbget/widget.js @@ -1,7 +1,8 @@ -import nzbgetProxyHandler from "./proxy"; +import jsonrpcProxyHandler from "utils/proxy/handlers/jsonrpc"; const widget = { - proxyHandler: nzbgetProxyHandler, + api: "{url}/jsonrpc", + proxyHandler: jsonrpcProxyHandler, }; export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index fe432832..6d5c4088 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -4,6 +4,7 @@ import autobrr from "./autobrr/widget"; import bazarr from "./bazarr/widget"; import changedetectionio from "./changedetectionio/widget"; import coinmarketcap from "./coinmarketcap/widget"; +import deluge from "./deluge/widget"; import emby from "./emby/widget"; import gluetun from "./gluetun/widget"; import gotify from "./gotify/widget"; @@ -47,6 +48,7 @@ const widgets = { bazarr, changedetectionio, coinmarketcap, + deluge, emby, gluetun, gotify, From bec62a09494916d81251c419e1d3916c5a820ff2 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Wed, 23 Nov 2022 12:01:31 -0800 Subject: [PATCH 2/6] Fix linting errors --- src/widgets/deluge/component.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/widgets/deluge/component.jsx b/src/widgets/deluge/component.jsx index 40f8c672..2e5296f1 100644 --- a/src/widgets/deluge/component.jsx +++ b/src/widgets/deluge/component.jsx @@ -27,19 +27,19 @@ export default function Component({ service }) { } const { torrents } = torrentData; - let count = 0; + const keys = Object.keys(torrents); + let rateDl = 0; let rateUl = 0; let completed = 0; - for (const key of Object.keys(torrents)) { - const torrent = torrents[key]; - count += 1; + for (let i = 0; i < keys.length; i += 1) { + const torrent = torrents[keys[i]]; rateDl += torrent.download_payload_rate; rateUl += torrent.upload_payload_rate; completed += torrent.total_remaining === 0 ? 1 : 0; } - const leech = count - completed || 0; + const leech = keys.length - completed || 0; return ( From 165add7f53c0fbc1aebe02890456a4e1d25f7db2 Mon Sep 17 00:00:00 2001 From: Michael Shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 23 Nov 2022 15:08:06 -0800 Subject: [PATCH 3/6] Handle deluge with 0 torrents --- src/widgets/deluge/component.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/deluge/component.jsx b/src/widgets/deluge/component.jsx index 2e5296f1..6615cac0 100644 --- a/src/widgets/deluge/component.jsx +++ b/src/widgets/deluge/component.jsx @@ -27,7 +27,7 @@ export default function Component({ service }) { } const { torrents } = torrentData; - const keys = Object.keys(torrents); + const keys = torrents ? Object.keys(torrents) : []; let rateDl = 0; let rateUl = 0; From ccfafe1b31d84b45082e14a8542fa4cff65b6de5 Mon Sep 17 00:00:00 2001 From: Michael Shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 23 Nov 2022 20:07:34 -0800 Subject: [PATCH 4/6] fix fatal jsonrpc error, error handling, add content-length --- src/utils/proxy/handlers/jsonrpc.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/utils/proxy/handlers/jsonrpc.js b/src/utils/proxy/handlers/jsonrpc.js index 9677fa50..5618d011 100644 --- a/src/utils/proxy/handlers/jsonrpc.js +++ b/src/utils/proxy/handlers/jsonrpc.js @@ -15,22 +15,22 @@ export async function sendJsonRpcRequest(url, method, params, username, password } if (username && password) { - const authorization = Buffer.from(`${username}:${password}`).toString("base64"); - headers.authorization = `Basic ${authorization}`; + headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; } const client = new JSONRPCClient(async (rpcRequest) => { + const body = JSON.stringify(rpcRequest); + headers['content-length'] = Buffer.byteLength(body); const httpRequestParams = { method: "POST", headers, - body: JSON.stringify(rpcRequest) + body }; // eslint-disable-next-line no-unused-vars const [status, contentType, data] = await httpProxy(url, httpRequestParams); - const dataString = data.toString(); if (status === 200) { - const json = JSON.parse(dataString); + const json = JSON.parse(data.toString()); // in order to get access to the underlying error object in the JSON response // you must set `result` equal to undefined @@ -40,7 +40,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password return client.receive(json); } - return Promise.reject(new Error(dataString)); + return Promise.reject(data?.error ? data : new Error(data.toString())); }); try { @@ -49,6 +49,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password } catch (e) { if (e instanceof JSONRPCErrorException) { + logger.warn("Error calling JSONPRC endpoint: %s. %s", url, e.message); return [200, "application/json", JSON.stringify({result: null, error: {code: e.code, message: e.message}})]; } @@ -73,7 +74,7 @@ export default async function jsonrpcProxyHandler(req, res) { // eslint-disable-next-line no-unused-vars const [status, contentType, data] = await sendJsonRpcRequest(url, method, null, widget.username, widget.password); - res.status(status).end(data); + return res.status(status).end(data); } } From 9f03d18e49f6dd6a3a8df8c29b6c6dba2626c5e4 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Thu, 24 Nov 2022 12:26:22 -0800 Subject: [PATCH 5/6] Move content-length calculation to http module - consolidate http / https functionality to single function --- src/utils/proxy/handlers/jsonrpc.js | 3 +-- src/utils/proxy/http.js | 41 +++++++++-------------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/utils/proxy/handlers/jsonrpc.js b/src/utils/proxy/handlers/jsonrpc.js index 5618d011..27427612 100644 --- a/src/utils/proxy/handlers/jsonrpc.js +++ b/src/utils/proxy/handlers/jsonrpc.js @@ -20,7 +20,6 @@ export async function sendJsonRpcRequest(url, method, params, username, password const client = new JSONRPCClient(async (rpcRequest) => { const body = JSON.stringify(rpcRequest); - headers['content-length'] = Buffer.byteLength(body); const httpRequestParams = { method: "POST", headers, @@ -49,7 +48,7 @@ export async function sendJsonRpcRequest(url, method, params, username, password } catch (e) { if (e instanceof JSONRPCErrorException) { - logger.warn("Error calling JSONPRC endpoint: %s. %s", url, e.message); + logger.debug("Error calling JSONPRC endpoint: %s. %s", url, e.message); return [200, "application/json", JSON.stringify({result: null, error: {code: e.code, message: e.message}})]; } diff --git a/src/utils/proxy/http.js b/src/utils/proxy/http.js index 16b58bf7..e07f06ff 100644 --- a/src/utils/proxy/http.js +++ b/src/utils/proxy/http.js @@ -18,10 +18,15 @@ function addCookieHandler(url, params) { }; } -export function httpsRequest(url, params) { +function handleRequest(requestor, url, params) { return new Promise((resolve, reject) => { addCookieHandler(url, params); - const request = https.request(url, params, (response) => { + if (params?.body) { + params.headers = params.headers ?? {}; + params.headers['content-length'] = Buffer.byteLength(params.body); + } + + const request = requestor.request(url, params, (response) => { const data = []; response.on("data", (chunk) => { @@ -38,7 +43,7 @@ export function httpsRequest(url, params) { reject([500, error]); }); - if (params.body) { + if (params?.body) { request.write(params.body); } @@ -46,32 +51,12 @@ export function httpsRequest(url, params) { }); } +export function httpsRequest(url, params) { + return handleRequest(https, url, params); +} + export function httpRequest(url, params) { - return new Promise((resolve, reject) => { - addCookieHandler(url, params); - const request = http.request(url, params, (response) => { - const data = []; - - response.on("data", (chunk) => { - data.push(chunk); - }); - - response.on("end", () => { - addCookieToJar(url, response.headers); - resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]); - }); - }); - - request.on("error", (error) => { - reject([500, error]); - }); - - if (params.body) { - request.write(params.body); - } - - request.end(); - }); + return handleRequest(http, url, params); } export async function httpProxy(url, params = {}) { From 034dbb956a7ee07583cc325859a715265213bb15 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Fri, 25 Nov 2022 10:55:56 -0800 Subject: [PATCH 6/6] Change qBittorrent to no longer use fetch --- src/widgets/qbittorrent/proxy.js | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/widgets/qbittorrent/proxy.js b/src/widgets/qbittorrent/proxy.js index 14271b65..e1ea7f90 100644 --- a/src/widgets/qbittorrent/proxy.js +++ b/src/widgets/qbittorrent/proxy.js @@ -1,30 +1,23 @@ import { formatApiCall } from "utils/proxy/api-helpers"; -import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar"; import { httpProxy } from "utils/proxy/http"; import getServiceWidget from "utils/config/service-helpers"; import createLogger from "utils/logger"; const logger = createLogger("qbittorrentProxyHandler"); -async function login(widget, params) { +async function login(widget) { logger.debug("qBittorrent is rejecting the request, logging in."); const loginUrl = new URL(`${widget.url}/api/v2/auth/login`).toString(); const loginBody = `username=${encodeURI(widget.username)}&password=${encodeURI(widget.password)}`; - - // using fetch intentionally, for login only, as the httpProxy method causes qBittorrent to - // complain about header encoding - return fetch(loginUrl, { + const loginParams = { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: loginBody, - }) - .then(async (response) => { - addCookieToJar(loginUrl, response.headers); - setCookieHeader(loginUrl, params); - const data = await response.text(); - return [response.status, data]; - }) - .catch((err) => [500, err]); + } + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await httpProxy(loginUrl, loginParams); + return [status, data]; } export default async function qbittorrentProxyHandler(req, res) { @@ -44,11 +37,10 @@ export default async function qbittorrentProxyHandler(req, res) { const url = new URL(formatApiCall("{url}/api/v2/{endpoint}", { endpoint, ...widget })); const params = { method: "GET", headers: {} }; - setCookieHeader(url, params); let [status, contentType, data] = await httpProxy(url, params); if (status === 403) { - [status, data] = await login(widget, params); + [status, data] = await login(widget); if (status !== 200) { logger.error("HTTP %d logging in to qBittorrent. Data: %s", status, data); @@ -59,9 +51,9 @@ export default async function qbittorrentProxyHandler(req, res) { logger.error("Error logging in to qBittorrent: Data: %s", data); return res.status(401).end(data); } - } - [status, contentType, data] = await httpProxy(url, params); + [status, contentType, data] = await httpProxy(url, params); + } if (status !== 200) { logger.error("HTTP %d getting data from qBittorrent. Data: %s", status, data);