From 363bcf16a4e6e04598fede4dd1711acbc469ba26 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Fri, 28 Jun 2024 12:03:01 +0100 Subject: [PATCH] feat: adding authorization to rpc --- src/main/services/download/http-download.ts | 121 ++++++++++++++++++ .../download/real-debrid-downloader.ts | 62 +++------ src/main/services/download/torrent-client.ts | 3 + .../services/download/torrent-downloader.ts | 5 +- src/renderer/src/hooks/use-download.ts | 9 +- torrent-client/main.py | 15 ++- 6 files changed, 166 insertions(+), 49 deletions(-) create mode 100644 src/main/services/download/http-download.ts diff --git a/src/main/services/download/http-download.ts b/src/main/services/download/http-download.ts new file mode 100644 index 00000000..bf290379 --- /dev/null +++ b/src/main/services/download/http-download.ts @@ -0,0 +1,121 @@ +import path from "node:path"; +import fs from "node:fs"; +import crypto from "node:crypto"; + +import axios, { type AxiosProgressEvent } from "axios"; +import { app } from "electron"; +import { logger } from "../logger"; + +export class HttpDownload { + private abortController: AbortController; + public lastProgressEvent: AxiosProgressEvent; + private trackerFilePath: string; + + private trackerProgressEvent: AxiosProgressEvent | null = null; + private downloadPath: string; + + private downloadTrackersPath = path.join( + app.getPath("documents"), + "Hydra", + "Downloads" + ); + + constructor( + private url: string, + private savePath: string + ) { + this.abortController = new AbortController(); + + const sha256Hasher = crypto.createHash("sha256"); + const hash = sha256Hasher.update(url).digest("hex"); + + this.trackerFilePath = path.join( + this.downloadTrackersPath, + `${hash}.hydradownload` + ); + + const filename = path.win32.basename(this.url); + this.downloadPath = path.join(this.savePath, filename); + } + + private updateTrackerFile() { + if (!fs.existsSync(this.downloadTrackersPath)) { + fs.mkdirSync(this.downloadTrackersPath, { + recursive: true, + }); + } + + fs.writeFileSync( + this.trackerFilePath, + JSON.stringify(this.lastProgressEvent), + { encoding: "utf-8" } + ); + } + + private removeTrackerFile() { + if (fs.existsSync(this.trackerFilePath)) { + fs.rm(this.trackerFilePath, () => {}); + } + } + + public async startDownload() { + // Check if there's already a tracker file and download file + if ( + fs.existsSync(this.trackerFilePath) && + fs.existsSync(this.downloadPath) + ) { + this.trackerProgressEvent = JSON.parse( + fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" }) + ); + } + + const response = await axios.get(this.url, { + responseType: "stream", + signal: this.abortController.signal, + headers: { + Range: `bytes=${this.trackerProgressEvent?.loaded ?? 0}-`, + }, + onDownloadProgress: (progressEvent) => { + const total = + this.trackerProgressEvent?.total ?? progressEvent.total ?? 0; + const loaded = + (this.trackerProgressEvent?.loaded ?? 0) + progressEvent.loaded; + + const progress = loaded / total; + + this.lastProgressEvent = { + ...progressEvent, + total, + progress, + loaded, + }; + this.updateTrackerFile(); + + if (progressEvent.progress === 1) { + this.removeTrackerFile(); + } + }, + }); + + response.data.pipe( + fs.createWriteStream(this.downloadPath, { + flags: "a", + }) + ); + } + + public async pauseDownload() { + this.abortController.abort(); + } + + public cancelDownload() { + this.pauseDownload(); + + fs.rm(this.downloadPath, (err) => { + if (err) logger.error(err); + }); + fs.rm(this.trackerFilePath, (err) => { + if (err) logger.error(err); + }); + } +} diff --git a/src/main/services/download/real-debrid-downloader.ts b/src/main/services/download/real-debrid-downloader.ts index 2f6192d5..476d8f3e 100644 --- a/src/main/services/download/real-debrid-downloader.ts +++ b/src/main/services/download/real-debrid-downloader.ts @@ -1,18 +1,15 @@ -import fs from "node:fs"; -import path from "node:path"; import { Game } from "@main/entity"; import { RealDebridClient } from "../real-debrid"; -import axios, { AxiosProgressEvent } from "axios"; import { gameRepository } from "@main/repository"; import { calculateETA } from "./helpers"; import { DownloadProgress } from "@types"; +import { HttpDownload } from "./http-download"; export class RealDebridDownloader { private static downloadingGame: Game | null = null; private static realDebridTorrentId: string | null = null; - private static lastProgressEvent: AxiosProgressEvent | null = null; - private static abortController: AbortController | null = null; + private static httpDownload: HttpDownload | null = null; private static async getRealDebridDownloadUrl() { if (this.realDebridTorrentId) { @@ -38,13 +35,15 @@ export class RealDebridDownloader { } public static async getStatus() { - if (this.lastProgressEvent) { + const lastProgressEvent = this.httpDownload?.lastProgressEvent; + + if (lastProgressEvent) { await gameRepository.update( { id: this.downloadingGame!.id }, { - bytesDownloaded: this.lastProgressEvent.loaded, - fileSize: this.lastProgressEvent.total, - progress: this.lastProgressEvent.progress, + bytesDownloaded: lastProgressEvent.loaded, + fileSize: lastProgressEvent.total, + progress: lastProgressEvent.progress, status: "active", } ); @@ -52,19 +51,19 @@ export class RealDebridDownloader { const progress = { numPeers: 0, numSeeds: 0, - downloadSpeed: this.lastProgressEvent.rate, + downloadSpeed: lastProgressEvent.rate, timeRemaining: calculateETA( - this.lastProgressEvent.total ?? 0, - this.lastProgressEvent.loaded, - this.lastProgressEvent.rate ?? 0 + lastProgressEvent.total ?? 0, + lastProgressEvent.loaded, + lastProgressEvent.rate ?? 0 ), isDownloadingMetadata: false, isCheckingFiles: false, - progress: this.lastProgressEvent.progress, + progress: lastProgressEvent.progress, gameId: this.downloadingGame!.id, } as DownloadProgress; - if (this.lastProgressEvent.progress === 1) { + if (lastProgressEvent.progress === 1) { this.pauseDownload(); } @@ -102,13 +101,8 @@ export class RealDebridDownloader { } static async pauseDownload() { - if (this.abortController) { - this.abortController.abort(); - } - - this.abortController = null; + this.httpDownload?.pauseDownload(); this.realDebridTorrentId = null; - this.lastProgressEvent = null; this.downloadingGame = null; } @@ -120,30 +114,12 @@ export class RealDebridDownloader { if (downloadUrl) { this.realDebridTorrentId = null; - this.abortController = new AbortController(); - - const response = await axios.get(downloadUrl, { - responseType: "stream", - signal: this.abortController.signal, - onDownloadProgress: (progressEvent) => { - this.lastProgressEvent = progressEvent; - }, - }); - - const filename = path.win32.basename(downloadUrl); - - const downloadPath = path.join(game.downloadPath!, filename); - - await gameRepository.update( - { id: this.downloadingGame.id }, - { folderName: filename } - ); - - response.data.pipe(fs.createWriteStream(downloadPath)); + this.httpDownload = new HttpDownload(downloadUrl, game!.downloadPath!); + this.httpDownload.startDownload(); } } - static async cancelDownload() { - return this.pauseDownload(); + static cancelDownload() { + return this.httpDownload?.cancelDownload(); } } diff --git a/src/main/services/download/torrent-client.ts b/src/main/services/download/torrent-client.ts index dea1b9e7..176ec664 100644 --- a/src/main/services/download/torrent-client.ts +++ b/src/main/services/download/torrent-client.ts @@ -1,5 +1,6 @@ import path from "node:path"; import cp from "node:child_process"; +import crypto from "node:crypto"; import fs from "node:fs"; import { app, dialog } from "electron"; import type { StartDownloadPayload } from "./types"; @@ -12,11 +13,13 @@ const binaryNameByPlatform: Partial> = { export const BITTORRENT_PORT = "5881"; export const RPC_PORT = "8084"; +export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); export const startTorrentClient = (args: StartDownloadPayload) => { const commonArgs = [ BITTORRENT_PORT, RPC_PORT, + RPC_PASSWORD, encodeURIComponent(JSON.stringify(args)), ]; diff --git a/src/main/services/download/torrent-downloader.ts b/src/main/services/download/torrent-downloader.ts index 53b3df04..e35bb617 100644 --- a/src/main/services/download/torrent-downloader.ts +++ b/src/main/services/download/torrent-downloader.ts @@ -1,7 +1,7 @@ import cp from "node:child_process"; import { Game } from "@main/entity"; -import { RPC_PORT, startTorrentClient } from "./torrent-client"; +import { RPC_PASSWORD, RPC_PORT, startTorrentClient } from "./torrent-client"; import { gameRepository } from "@main/repository"; import { DownloadProgress } from "@types"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; @@ -21,6 +21,9 @@ export class TorrentDownloader { private static rpc = axios.create({ baseURL: `http://localhost:${RPC_PORT}`, + headers: { + "x-hydra-rpc-password": RPC_PASSWORD, + }, }); private static spawn(args: StartDownloadPayload) { diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index 4e4f9dbf..f58a8765 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -22,13 +22,14 @@ export function useDownload() { ); const dispatch = useAppDispatch(); - const startDownload = (payload: StartGameDownloadPayload) => + const startDownload = (payload: StartGameDownloadPayload) => { + dispatch(clearDownload()); window.electron.startGameDownload(payload).then((game) => { - dispatch(clearDownload()); updateLibrary(); return game; }); + }; const pauseDownload = async (gameId: number) => { await window.electron.pauseGameDownload(gameId); @@ -65,7 +66,7 @@ export function useDownload() { updateLibrary(); }); - const getETA = () => { + const calculateETA = () => { if (!lastPacket || lastPacket.timeRemaining < 0) return ""; try { @@ -87,7 +88,7 @@ export function useDownload() { downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`, progress: formatDownloadProgress(lastPacket?.progress ?? 0), lastPacket, - eta: getETA(), + eta: calculateETA(), startDownload, pauseDownload, resumeDownload, diff --git a/torrent-client/main.py b/torrent-client/main.py index 01dd5922..01f65557 100644 --- a/torrent-client/main.py +++ b/torrent-client/main.py @@ -8,7 +8,8 @@ import urllib.parse torrent_port = sys.argv[1] http_port = sys.argv[2] -initial_download = json.loads(urllib.parse.unquote(sys.argv[3])) +rpc_password = sys.argv[3] +initial_download = json.loads(urllib.parse.unquote(sys.argv[4])) class Downloader: def __init__(self): @@ -67,8 +68,15 @@ downloader = Downloader() downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path']) class Handler(BaseHTTPRequestHandler): + rpc_password_header = 'x-hydra-rpc-password' + def do_GET(self): if self.path == "/status": + if self.headers.get(self.rpc_password_header) != rpc_password: + self.send_response(401) + self.end_headers() + return + self.send_response(200) self.send_header("Content-type", "application/json") self.end_headers() @@ -82,6 +90,11 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path == "/action": + if self.headers.get(self.rpc_password_header) != rpc_password: + self.send_response(401) + self.end_headers() + return + content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) data = json.loads(post_data.decode('utf-8'))