From f7508764256b8dff97a15513f820598e1edf1fda Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Wed, 14 Sep 2022 10:46:52 -0700 Subject: [PATCH] Add the Jackett widget - add the follow-redirect package - add the tough-cookie package Jackett API uses a redirect mechanism to set a CSRF token. This CSRF token is stored in a cookie that is required to be present or the API won't work. --- package.json | 4 +- pnpm-lock.yaml | 49 ++++++++++++++++++- public/locales/en/common.json | 4 ++ src/components/services/widget.jsx | 4 +- .../services/widgets/service/jackett.jsx | 37 ++++++++++++++ src/pages/api/services/proxy.js | 1 + src/utils/api-helpers.js | 1 + src/utils/http.js | 40 ++++++++++++++- 8 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 src/components/services/widgets/service/jackett.jsx diff --git a/package.json b/package.json index ae8ce0e38..b7bbd3845 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "classnames": "^2.3.1", "currency-symbol-map": "^5.1.0", "dockerode": "^3.3.4", + "follow-redirects": "^1.15.2", "i18next": "^21.9.1", "i18next-browser-languagedetector": "^6.1.5", "i18next-http-backend": "^1.4.1", @@ -30,7 +31,8 @@ "react-icons": "^4.4.0", "rutorrent-promise": "^2.0.0", "shvl": "^3.0.0", - "swr": "^1.3.0" + "swr": "^1.3.0", + "tough-cookie": "^4.1.2" }, "devDependencies": { "autoprefixer": "^10.4.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed4b537c..2d19e8a5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ specifiers: eslint-plugin-prettier: ^4.2.1 eslint-plugin-react: ^7.31.8 eslint-plugin-react-hooks: ^4.6.0 + follow-redirects: ^1.15.2 i18next: ^21.9.1 i18next-browser-languagedetector: ^6.1.5 i18next-http-backend: ^1.4.1 @@ -36,6 +37,7 @@ specifiers: shvl: ^3.0.0 swr: ^1.3.0 tailwindcss: ^3.1.8 + tough-cookie: ^4.1.2 typescript: ^4.8.3 dependencies: @@ -44,6 +46,7 @@ dependencies: classnames: 2.3.1 currency-symbol-map: 5.1.0 dockerode: 3.3.4 + follow-redirects: 1.15.2 i18next: 21.9.1 i18next-browser-languagedetector: 6.1.5 i18next-http-backend: 1.4.1 @@ -61,6 +64,7 @@ dependencies: rutorrent-promise: 2.0.0 shvl: 3.0.0 swr: 1.3.0_react@18.2.0 + tough-cookie: 4.1.2 devDependencies: autoprefixer: 10.4.9_postcss@8.4.16 @@ -1358,6 +1362,16 @@ packages: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true + /follow-redirects/1.15.2: + resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /form-data/3.0.1: resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} engines: {node: '>= 6'} @@ -2162,6 +2176,10 @@ packages: react-is: 16.13.1 dev: true + /psl/1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: false + /pump/3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -2172,7 +2190,10 @@ packages: /punycode/2.1.1: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} - dev: true + + /querystringify/2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: false /queue-microtask/1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2277,6 +2298,10 @@ packages: engines: {node: '>=8'} dev: true + /requires-port/1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: false + /resolve-from/4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2563,6 +2588,16 @@ packages: engines: {node: '>=0.6'} dev: false + /tough-cookie/4.1.2: + resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.1.1 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: false + /tr46/0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false @@ -2625,6 +2660,11 @@ packages: which-boxed-primitive: 1.0.2 dev: true + /universalify/0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: false + /unpipe/1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2647,6 +2687,13 @@ packages: punycode: 2.1.1 dev: true + /url-parse/1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: false + /use-sync-external-store/1.2.0_react@18.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5fbaaf216..9d92e66e9 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -139,5 +139,9 @@ "numberOfQueries": "Queries", "numberOfFailGrabs": "Fail Grabs", "numberOfFailQueries": "Fail Queries" + }, + "jackett": { + "configured": "Configured", + "errored": "Errored" } } diff --git a/src/components/services/widget.jsx b/src/components/services/widget.jsx index d2c8fbf78..b47b4fdfe 100644 --- a/src/components/services/widget.jsx +++ b/src/components/services/widget.jsx @@ -22,6 +22,7 @@ import Tautulli from "./widgets/service/tautulli"; import CoinMarketCap from "./widgets/service/coinmarketcap"; import Gotify from "./widgets/service/gotify"; import Prowlarr from "./widgets/service/prowlarr"; +import Jackett from "./widgets/service/jackett"; const widgetMappings = { docker: Docker, @@ -45,7 +46,8 @@ const widgetMappings = { npm: Npm, tautulli: Tautulli, gotify: Gotify, - prowlarr: Prowlarr + prowlarr: Prowlarr, + jackett: Jackett }; export default function Widget({ service }) { diff --git a/src/components/services/widgets/service/jackett.jsx b/src/components/services/widgets/service/jackett.jsx new file mode 100644 index 000000000..c6583711d --- /dev/null +++ b/src/components/services/widgets/service/jackett.jsx @@ -0,0 +1,37 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import Widget from "../widget"; +import Block from "../block"; + +import { formatApiUrl } from "utils/api-helpers"; + +export default function Jackett({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: indexersData, error: indexersError } = useSWR(formatApiUrl(config, "indexers")); + + if (indexersError) { + return ; + } + + if (!indexersData) { + return ( + + + + + ); + } + + const errored = indexersData.filter((indexer) => indexer.last_error); + + return ( + + + + + ); +} diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index 8fe036f4f..9e3a57ff8 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -17,6 +17,7 @@ const serviceProxyHandlers = { tautulli: genericProxyHandler, traefik: genericProxyHandler, sabnzbd: genericProxyHandler, + jackett: genericProxyHandler, // uses X-API-Key (or similar) header auth gotify: credentialedProxyHandler, portainer: credentialedProxyHandler, diff --git a/src/utils/api-helpers.js b/src/utils/api-helpers.js index 13d520bf3..2c1929df9 100644 --- a/src/utils/api-helpers.js +++ b/src/utils/api-helpers.js @@ -19,6 +19,7 @@ const formats = { 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` }; export function formatApiCall(api, args) { diff --git a/src/utils/http.js b/src/utils/http.js index 76e882ed6..76f0f8b84 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -1,9 +1,44 @@ /* eslint-disable prefer-promise-reject-errors */ -import https from "https"; -import http from "http"; +/* eslint-disable no-param-reassign */ +import { http, https } from "follow-redirects"; +import { Cookie, CookieJar } from 'tough-cookie'; + +const cookieJar = new CookieJar(); + +function addCookieHandler(url, params) { + // add cookie header, if we have one in the jar + const existingCookie = cookieJar.getCookieStringSync(url.toString()); + if (existingCookie) { + params.headers = params.headers ?? {}; + params.headers.Cookie = existingCookie; + } + + // handle cookies during redirects + params.beforeRedirect = (options, responseInfo) => { + const cookieHeader = responseInfo.headers['set-cookie']; + if (!cookieHeader || cookieHeader.length === 0) return; + + let cookies = null; + if (cookieHeader instanceof Array) { + cookies = cookieHeader.map(Cookie.parse); + } + else { + cookies = [Cookie.parse(cookieHeader)]; + } + + for (let i = 0; i < cookies.length; i += 1) { + cookieJar.setCookieSync(cookies[i], options.href); + } + + const cookie = cookieJar.getCookieStringSync(options.href); + options.headers = options.headers ?? {}; + options.headers.Cookie = cookie; + }; +} export function httpsRequest(url, params) { return new Promise((resolve, reject) => { + addCookieHandler(url, params); const request = https.request(url, params, (response) => { const data = []; @@ -30,6 +65,7 @@ export function httpsRequest(url, params) { export function httpRequest(url, params) { return new Promise((resolve, reject) => { + addCookieHandler(url, params); const request = http.request(url, params, (response) => { const data = [];