From f1d6a990ac0eeb538a853e5965e646ce124522b9 Mon Sep 17 00:00:00 2001 From: Karl Hudgell Date: Tue, 13 Jun 2023 20:30:09 +0100 Subject: [PATCH] Working Jdownloader --- public/locales/en/common.json | 5 + src/widgets/components.js | 3 +- src/widgets/jdownloader/component.jsx | 37 +++++ src/widgets/jdownloader/proxy.js | 191 ++++++++++++++++++++++++++ src/widgets/jdownloader/tools.js | 55 ++++++++ src/widgets/jdownloader/widget.js | 15 ++ src/widgets/widgets.js | 2 + 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/widgets/jdownloader/component.jsx create mode 100644 src/widgets/jdownloader/proxy.js create mode 100644 src/widgets/jdownloader/tools.js create mode 100644 src/widgets/jdownloader/widget.js diff --git a/public/locales/en/common.json b/public/locales/en/common.json index c90287da6..437088bd3 100755 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -653,5 +653,10 @@ "whatsupdocker": { "monitoring": "Monitoring", "updates": "Updates" + }, + "jdownloader": { + "downloadCount": "Queue Count", + "downloadQueueSize": "Queue Size", + "downloadSpeed": "Download Speed" } } diff --git a/src/widgets/components.js b/src/widgets/components.js index 589a93ad2..97760d368 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -31,6 +31,7 @@ const components = { healthchecks: dynamic(() => import("./healthchecks/component")), immich: dynamic(() => import("./immich/component")), jackett: dynamic(() => import("./jackett/component")), + jdownloader: dynamic(() => import("./jdownloader/component")), jellyfin: dynamic(() => import("./emby/component")), jellyseerr: dynamic(() => import("./jellyseerr/component")), komga: dynamic(() => import("./komga/component")), @@ -91,4 +92,4 @@ const components = { xteve: dynamic(() => import("./xteve/component")), }; -export default components; +export default components; \ No newline at end of file diff --git a/src/widgets/jdownloader/component.jsx b/src/widgets/jdownloader/component.jsx new file mode 100644 index 000000000..d8fea9caf --- /dev/null +++ b/src/widgets/jdownloader/component.jsx @@ -0,0 +1,37 @@ +import { useTranslation } from "next-i18next"; + +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: jdownloaderData, error: jdownloaderAPIError } = useWidgetAPI(widget, "unified", { + refreshInterval: 30000, + }); + + if (jdownloaderAPIError) { + return ; + } + + if (!jdownloaderData) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +} \ No newline at end of file diff --git a/src/widgets/jdownloader/proxy.js b/src/widgets/jdownloader/proxy.js new file mode 100644 index 000000000..353192ac7 --- /dev/null +++ b/src/widgets/jdownloader/proxy.js @@ -0,0 +1,191 @@ +/* eslint-disable no-underscore-dangle */ +import crypto from 'crypto'; +import querystring from 'querystring'; + +import { sha256, uniqueRid, validateRid, createEncryptionToken, decrypt, encrypt } from "./tools" + +import getServiceWidget from "utils/config/service-helpers"; +import { httpProxy } from "utils/proxy/http"; +import createLogger from "utils/logger"; + +const proxyName = "jdownloaderProxyHandler"; +const logger = createLogger(proxyName); + +async function getWidget(req) { + const { group, service } = req.query; + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return null; + } + const widget = await getServiceWidget(group, service); + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return null; + } + + return widget; +} + +async function login(loginSecret, deviceSecret, params) { + const rid = uniqueRid(); + const path = `/my/connect?${querystring.stringify({...params, rid})}`; + + const signature = crypto + .createHmac('sha256', loginSecret) + .update(path) + .digest('hex'); + const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}` + + const [status, contentType, data] = await httpProxy(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (status !== 200) { + logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString()); + return [status, data]; + } + + try { + const decryptedData = JSON.parse(decrypt(data.toString(), loginSecret)) + const sessionToken = decryptedData.sessiontoken; + validateRid(decryptedData, rid); + const serverEncryptionToken = createEncryptionToken(loginSecret, sessionToken); + const deviceEncryptionToken = createEncryptionToken(deviceSecret, sessionToken); + return [status, decryptedData, contentType, serverEncryptionToken, deviceEncryptionToken, sessionToken]; + } catch (e) { + logger.error("Error decoding jdownloader API data. Data: %s", data.toString()); + return [status, null]; + } +} + + +async function getDevice(serverEncryptionToken, deviceName, params) { + const rid = uniqueRid(); + const path = `/my/listdevices?${querystring.stringify({...params, rid})}`; + const signature = crypto + .createHmac('sha256', serverEncryptionToken) + .update(path) + .digest('hex'); + const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}` + + const [status, , data] = await httpProxy(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (status !== 200) { + logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString()); + return [status, data]; + } + + try { + const decryptedData = JSON.parse(decrypt(data.toString(), serverEncryptionToken)) + const filteredDevice = decryptedData.list.filter(device => device.name === deviceName); + return [status, filteredDevice[0].id]; + } catch (e) { + logger.error("Error decoding jdownloader API data. Data: %s", data.toString()); + return [status, null]; + } + +} + +function createBody(rid, query, params) { + const baseBody = { + apiVer: 1, + rid, + url: query + }; + return params ? {...baseBody, params: [JSON.stringify(params)] } : baseBody; +} + +async function queryPackages(deviceEncryptionToken, deviceId, sessionToken, params) { + const rid = uniqueRid(); + const body = encrypt(JSON.stringify(createBody(rid, '/downloadsV2/queryPackages', params)), deviceEncryptionToken); + const url = `${new URL(`https://api.jdownloader.org/t_${encodeURI(sessionToken)}_${encodeURI(deviceId)}/downloadsV2/queryPackages`)}` + const [status, , data] = await httpProxy(url, { + method: 'POST', + body, + }); + + if (status !== 200) { + logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString()); + return [status, data]; + } + + try { + const decryptedData = JSON.parse(decrypt(data.toString(), deviceEncryptionToken)) + return decryptedData.data; + } catch (e) { + logger.error("Error decoding JDRss jdownloader data. Data: %s", data.toString()); + return [status, null]; + } + +} + + +export default async function jdownloaderProxyHandler(req, res) { + const widget = await getWidget(req); + + if (!widget) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + logger.debug("Getting data from JDRss API"); + const {username} = widget + const {password} = widget + + const appKey = "homepage" + const loginSecret = sha256(`${username}${password}server`) + const deviceSecret = sha256(`${username}${password}device`) + const email = username; + + const loginData = await login(loginSecret, deviceSecret, { + appKey, + email + }) + + const deviceData = await getDevice(loginData[3], widget.client, { + sessiontoken: loginData[5] + }) + + const packageStatus = await queryPackages(loginData[4], deviceData[1], loginData[5], { + "bytesLoaded": false, + "bytesTotal": true, + "comment": false, + "enabled": true, + "eta": false, + "priority": false, + "finished": true, + "running": true, + "speed": true, + "status": true, + "childCount": false, + "hosts": false, + "saveTo": false, + "maxResults": -1, + "startAt": 0, + } + ) + + let totalBytes = 0; + let totalSpeed = 0; + packageStatus.forEach(file => { + totalBytes += file.bytesTotal; + if (file.speed) { + totalSpeed += file.speed; + } + }); + + const data = { + downloadCount: packageStatus.length, + totalBytes, + totalSpeed + }; + + return res.send(data); + +} \ No newline at end of file diff --git a/src/widgets/jdownloader/tools.js b/src/widgets/jdownloader/tools.js new file mode 100644 index 000000000..d678b0725 --- /dev/null +++ b/src/widgets/jdownloader/tools.js @@ -0,0 +1,55 @@ +import crypto from 'crypto'; + +export function sha256(data) { + return crypto + .createHash('sha256') + .update(data) + .digest(); +} + +export function uniqueRid() { + return Math.floor(Math.random() * 10e12); +} + +export function validateRid(decryptedData, rid) { + if (decryptedData.rid !== rid) { + throw new Error('RequestID mismatch'); + } + return decryptedData; + +} + +export function decrypt(data, ivKey) { + const iv = ivKey.slice(0, ivKey.length / 2); + const key = ivKey.slice(ivKey.length / 2, ivKey.length); + const cipher = crypto.createDecipheriv('aes-128-cbc', key, iv); + return Buffer.concat([ + cipher.update(data, 'base64'), + cipher.final() + ]).toString(); +} + +export function createEncryptionToken(oldTokenBuff, updateToken) { + const updateTokenBuff = Buffer.from(updateToken, 'hex'); + const mergedBuffer = Buffer.concat([oldTokenBuff, updateTokenBuff], oldTokenBuff.length + updateTokenBuff.length); + return sha256(mergedBuffer); +} + +export function encrypt(data, ivKey) { + if (typeof data !== 'string') { + throw new Error('data no es un string'); + } + if (!(ivKey instanceof Buffer)) { + throw new Error('ivKey no es un buffer'); + } + if (ivKey.length !== 32) { + throw new Error('ivKey tiene que tener tamaƱo 32'); + } + const stringIVKey = ivKey.toString('hex'); + const stringIV = stringIVKey.substring(0, stringIVKey.length / 2); + const stringKey = stringIVKey.substring(stringIVKey.length / 2, stringIVKey.length); + const iv = Buffer.from(stringIV, 'hex'); + const key = Buffer.from(stringKey, 'hex'); + const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); + return cipher.update(data, 'utf8', 'base64') + cipher.final('base64'); +} \ No newline at end of file diff --git a/src/widgets/jdownloader/widget.js b/src/widgets/jdownloader/widget.js new file mode 100644 index 000000000..acd25d744 --- /dev/null +++ b/src/widgets/jdownloader/widget.js @@ -0,0 +1,15 @@ +import jdownloaderProxyHandler from "./proxy"; + +const widget = { + api: "https://api.jdownloader.org/{endpoint}/&signature={signature}", + proxyHandler: jdownloaderProxyHandler, + + mappings: { + unified: { + endpoint: "/", + signature: "", + }, + }, +}; + +export default widget; \ No newline at end of file diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index f843a1686..86c4266fc 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -27,6 +27,7 @@ import healthchecks from "./healthchecks/widget"; import immich from "./immich/widget"; import jackett from "./jackett/widget"; import jellyseerr from "./jellyseerr/widget"; +import jdownloader from "./jdownloader/widget"; import komga from "./komga/widget"; import kopia from "./kopia/widget"; import lidarr from "./lidarr/widget"; @@ -113,6 +114,7 @@ const widgets = { healthchecks, immich, jackett, + jdownloader, jellyfin: emby, jellyseerr, komga,