mirror of
https://github.com/zoriya/flood.git
synced 2026-06-02 19:11:14 +00:00
server: rTorrent: revert 96c754d, add torrent with base64 if possible
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import axios, {AxiosError, AxiosResponse} from 'axios';
|
||||
import fs from 'fs';
|
||||
import geoip from 'geoip-country';
|
||||
import {moveSync} from 'fs-extra';
|
||||
import path from 'path';
|
||||
import parseTorrent from 'parse-torrent';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
import type {
|
||||
@@ -32,14 +32,14 @@ import type {TorrentTracker} from '@shared/types/TorrentTracker';
|
||||
import type {TransferSummary} from '@shared/types/TransferData';
|
||||
import type {SetClientSettingsOptions} from '@shared/types/api/client';
|
||||
|
||||
import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil';
|
||||
import {isAllowedPath, sanitizePath} from '../../util/fileUtil';
|
||||
import ClientGatewayService from '../interfaces/clientGatewayService';
|
||||
import ClientRequestManager from './clientRequestManager';
|
||||
import {getMethodCalls, processMethodCallResponse} from './util/rTorrentMethodCallUtil';
|
||||
import {fetchURLToTempFile, saveBufferToTempFile} from '../../util/tempFileUtil';
|
||||
import {setCompleted, setTrackers} from '../../util/torrentFileUtil';
|
||||
import {
|
||||
encodeTags,
|
||||
getAddTorrentPropertiesCalls,
|
||||
getTorrentETAFromProperties,
|
||||
getTorrentPercentCompleteFromProperties,
|
||||
getTorrentStatusFromProperties,
|
||||
@@ -70,29 +70,63 @@ class RTorrentClientGatewayService extends ClientGatewayService {
|
||||
isInitialSeeding,
|
||||
start,
|
||||
}: Required<AddTorrentByFileOptions>): Promise<string[]> {
|
||||
const torrentPaths = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return saveBufferToTempFile(Buffer.from(file, 'base64'), 'torrent', {
|
||||
mode: 0o664,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const {hasLoadThrow} = await this.availableMethodCalls;
|
||||
|
||||
return this.addTorrentsByURL({
|
||||
urls: torrentPaths as [string, ...string[]],
|
||||
cookies: {},
|
||||
await fs.promises.mkdir(destination, {recursive: true});
|
||||
|
||||
let processedFiles: string[] = files;
|
||||
if (isCompleted) {
|
||||
processedFiles = (
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return (await setCompleted(Buffer.from(file, 'base64'), destination, isBasePath))?.toString('base64');
|
||||
}),
|
||||
)
|
||||
).filter((file) => file) as string[];
|
||||
}
|
||||
|
||||
if (!processedFiles[0]) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const additionalCalls = getAddTorrentPropertiesCalls({
|
||||
destination,
|
||||
tags,
|
||||
isBasePath,
|
||||
isCompleted,
|
||||
isSequential,
|
||||
isInitialSeeding,
|
||||
start,
|
||||
tags,
|
||||
});
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
if (hasLoadThrow && this.clientRequestManager.isJSONCapable) {
|
||||
await this.clientRequestManager
|
||||
.methodCall('system.multicall', [
|
||||
processedFiles.map((file) => ({
|
||||
methodName: start ? 'load.start_throw' : 'load.throw',
|
||||
params: ['', `data:applications/x-bittorrent;base64,${file}`, ...additionalCalls],
|
||||
})),
|
||||
])
|
||||
.then(this.processClientRequestSuccess, this.processRTorrentRequestError)
|
||||
.then((response: Array<Array<string | number>>) => {
|
||||
const hashes = response.flat(2).filter((value) => typeof value === 'string') as string[];
|
||||
result.push(...hashes);
|
||||
});
|
||||
} else {
|
||||
await Promise.all(
|
||||
processedFiles.map(async (file) => {
|
||||
await this.clientRequestManager
|
||||
.methodCall(start ? 'load.raw_start' : 'load.raw', ['', Buffer.from(file, 'base64'), ...additionalCalls])
|
||||
.then(this.processClientRequestSuccess, this.processRTorrentRequestError);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async addTorrentsByURL({
|
||||
urls,
|
||||
urls: inputUrls,
|
||||
cookies,
|
||||
destination,
|
||||
tags,
|
||||
@@ -104,111 +138,98 @@ class RTorrentClientGatewayService extends ClientGatewayService {
|
||||
}: Required<AddTorrentByURLOptions>): Promise<string[]> {
|
||||
const {hasLoadThrow} = await this.availableMethodCalls;
|
||||
|
||||
// validate filesystem access
|
||||
if (!hasLoadThrow) {
|
||||
await this.clientRequestManager
|
||||
.methodCall('import', [
|
||||
'',
|
||||
await saveBufferToTempFile(Buffer.from('#'), 'rc', {
|
||||
mode: 0o664,
|
||||
}),
|
||||
])
|
||||
.then(this.processClientRequestSuccess, this.processRTorrentRequestError)
|
||||
.catch(() => {
|
||||
throw accessDeniedError();
|
||||
});
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destination, {recursive: true});
|
||||
|
||||
const torrentPaths: Array<string> = (
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
if (/^(http|https):\/\//.test(url)) {
|
||||
const domain = url.split('/')[2];
|
||||
const files: string[] = [];
|
||||
const urls: string[] = [];
|
||||
|
||||
// TODO: properly handle error and let frontend know
|
||||
const torrentPath = await fetchURLToTempFile(url, cookies[domain], 'torrent').catch((e) =>
|
||||
console.error(e),
|
||||
);
|
||||
await Promise.all(
|
||||
inputUrls.map(async (url) => {
|
||||
if (url.startsWith('http:') || url.startsWith('https:')) {
|
||||
const domain = url.split('/')[2];
|
||||
|
||||
if (typeof torrentPath === 'string') {
|
||||
return torrentPath;
|
||||
}
|
||||
const file = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
responseType: 'arraybuffer',
|
||||
headers: cookies?.[domain]
|
||||
? {
|
||||
Cookie: cookies[domain].join('; ').concat(';'),
|
||||
}
|
||||
: undefined,
|
||||
}).then(
|
||||
(res: AxiosResponse) => res.data,
|
||||
(e: AxiosError) => console.error(e),
|
||||
);
|
||||
|
||||
return '';
|
||||
if (file instanceof Buffer) {
|
||||
files.push(file.toString('base64'));
|
||||
}
|
||||
|
||||
return url;
|
||||
}),
|
||||
)
|
||||
).filter((torrentPath) => torrentPath !== '');
|
||||
return;
|
||||
}
|
||||
|
||||
const torrentHashes: string[] = (
|
||||
await Promise.all(
|
||||
torrentPaths.map(
|
||||
async (torrentPath): Promise<string | undefined> => {
|
||||
try {
|
||||
if (torrentPath.startsWith('magnet:')) {
|
||||
return parseTorrent(torrentPath).infoHash;
|
||||
}
|
||||
if (fs.existsSync(url) && isAllowedPath(path.resolve(url))) {
|
||||
try {
|
||||
files.push(fs.readFileSync(url).toString('base64'));
|
||||
return;
|
||||
} catch {
|
||||
// do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(torrentPath)) {
|
||||
return;
|
||||
}
|
||||
urls.push(url);
|
||||
}),
|
||||
);
|
||||
|
||||
if (isCompleted) {
|
||||
await setCompleted(torrentPath, destination, isBasePath);
|
||||
}
|
||||
|
||||
return parseTorrent(fs.readFileSync(torrentPath)).infoHash;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
).filter((torrentHash) => torrentHash) as string[];
|
||||
|
||||
if (torrentHashes[0] == null) {
|
||||
if (!files[0] && !urls[0]) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const methodCalls: MultiMethodCalls = torrentPaths.map((torrentPath) => {
|
||||
const additionalCalls: Array<string> = ['d.tied_to_file.set='];
|
||||
const result: string[] = [];
|
||||
|
||||
additionalCalls.push(`${isBasePath ? 'd.directory_base.set' : 'd.directory.set'}="${destination}"`);
|
||||
|
||||
if (tags.length > 0) {
|
||||
additionalCalls.push(`d.custom1.set="${encodeTags(tags)}"`);
|
||||
if (urls[0]) {
|
||||
let methodName: string;
|
||||
if (hasLoadThrow) {
|
||||
methodName = start ? 'load.start_throw' : 'load.throw';
|
||||
} else {
|
||||
methodName = start ? 'load.start' : 'load.normal';
|
||||
}
|
||||
|
||||
additionalCalls.push(`d.custom.set=addtime,${Math.round(Date.now() / 1000)}`);
|
||||
await this.clientRequestManager
|
||||
.methodCall('system.multicall', [
|
||||
urls.map((url) => ({
|
||||
methodName,
|
||||
params: [
|
||||
'',
|
||||
url,
|
||||
...getAddTorrentPropertiesCalls({destination, isBasePath, isSequential, isInitialSeeding, tags}),
|
||||
],
|
||||
})),
|
||||
])
|
||||
.then(this.processClientRequestSuccess, this.processRTorrentRequestError)
|
||||
.then((response: Array<Array<string | number>>) => {
|
||||
const hashes = response.flat(2).filter((value) => typeof value === 'string') as string[];
|
||||
result.push(...hashes);
|
||||
});
|
||||
}
|
||||
|
||||
if (isSequential) {
|
||||
additionalCalls.push(`d.down.sequential.set=1`);
|
||||
}
|
||||
if (files[0]) {
|
||||
await this.addTorrentsByFile({
|
||||
files: files as [string, ...string[]],
|
||||
destination,
|
||||
tags,
|
||||
isBasePath,
|
||||
isCompleted,
|
||||
isSequential,
|
||||
isInitialSeeding,
|
||||
start,
|
||||
}).then((hashes) => {
|
||||
result.push(...hashes);
|
||||
});
|
||||
}
|
||||
|
||||
if (isInitialSeeding) {
|
||||
additionalCalls.push(`d.connection_seed.set=initial_seed`);
|
||||
}
|
||||
|
||||
return hasLoadThrow
|
||||
? {
|
||||
methodName: start ? 'load.start_throw' : 'load.throw',
|
||||
params: ['', torrentPath, ...additionalCalls],
|
||||
}
|
||||
: {
|
||||
methodName: start ? 'load.start' : 'load.normal',
|
||||
params: ['', torrentPath, ...additionalCalls],
|
||||
};
|
||||
}, []);
|
||||
|
||||
await this.clientRequestManager
|
||||
.methodCall('system.multicall', [methodCalls])
|
||||
.then(this.processClientRequestSuccess, this.processRTorrentRequestError);
|
||||
|
||||
return torrentHashes;
|
||||
return result;
|
||||
}
|
||||
|
||||
async checkTorrents({hashes}: CheckTorrentsOptions): Promise<void> {
|
||||
|
||||
@@ -6,14 +6,7 @@
|
||||
// Copyright (C) 2021, Contributors to the Flood project
|
||||
// Imported and modified for the Flood project
|
||||
|
||||
export type XMLRPCValue =
|
||||
| Array<XMLRPCValue>
|
||||
| {[key: string]: XMLRPCValue}
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| Date
|
||||
| ArrayBuffer;
|
||||
export type XMLRPCValue = Array<XMLRPCValue> | {[key: string]: XMLRPCValue} | number | string | boolean | Date | Buffer;
|
||||
|
||||
const value = (value: XMLRPCValue): string => {
|
||||
if (value == null) return '';
|
||||
@@ -41,9 +34,9 @@ const value = (value: XMLRPCValue): string => {
|
||||
} else if (value instanceof Date) {
|
||||
type = 'dateTime.iso8601';
|
||||
value = value.toISOString();
|
||||
} else if (value instanceof ArrayBuffer) {
|
||||
} else if (value instanceof Buffer) {
|
||||
type = 'base64';
|
||||
value = btoa(String.fromCharCode(...new Uint8Array(value)));
|
||||
value = value.toString('base64');
|
||||
} else {
|
||||
type = 'struct';
|
||||
value = members(value);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import truncateTo from './numberUtils';
|
||||
|
||||
import type {TorrentProperties} from '../../../../shared/types/Torrent';
|
||||
import type {TorrentStatus} from '../../../../shared/constants/torrentStatusMap';
|
||||
import type {AddTorrentByFileOptions} from '@shared/schema/api/torrents';
|
||||
import type {TorrentProperties} from '@shared/types/Torrent';
|
||||
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
|
||||
|
||||
export const getTorrentETAFromProperties = (
|
||||
processingTorrentProperties: Record<string, unknown>,
|
||||
@@ -91,3 +92,33 @@ export const encodeTags = (tags: TorrentProperties['tags']): string => {
|
||||
}, [])
|
||||
.join(',');
|
||||
};
|
||||
|
||||
export const getAddTorrentPropertiesCalls = ({
|
||||
destination,
|
||||
isBasePath,
|
||||
isSequential,
|
||||
isInitialSeeding,
|
||||
tags,
|
||||
}: Required<
|
||||
Pick<AddTorrentByFileOptions, 'destination' | 'isBasePath' | 'isSequential' | 'isInitialSeeding' | 'tags'>
|
||||
>) => {
|
||||
const result: Array<string> = [
|
||||
'd.tied_to_file.set=',
|
||||
`d.custom.set=addtime,${Math.round(Date.now() / 1000)}`,
|
||||
`${isBasePath ? 'd.directory_base.set' : 'd.directory.set'}="${destination}"`,
|
||||
];
|
||||
|
||||
if (tags.length > 0) {
|
||||
result.push(`d.custom1.set="${encodeTags(tags)}"`);
|
||||
}
|
||||
|
||||
if (isSequential) {
|
||||
result.push(`d.down.sequential.set=1`);
|
||||
}
|
||||
|
||||
if (isInitialSeeding) {
|
||||
result.push(`d.connection_seed.set=initial_seed`);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import axios, {AxiosError, AxiosResponse} from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import fs, {WriteFileOptions} from 'fs';
|
||||
|
||||
import {getTempPath} from '../models/TemporaryStorage';
|
||||
|
||||
/**
|
||||
* Gets a randomly generated file path for temp file.
|
||||
*
|
||||
* @return {string} - path
|
||||
*/
|
||||
const getTempFilePath = (extension = 'tmp'): string => {
|
||||
return getTempPath(`${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the file after 5 minutes
|
||||
*/
|
||||
const delayedDelete = (tempPath: string): void => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// do nothing.
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves buffer to temporary storage as a file.
|
||||
*
|
||||
* @param {Buffer} buffer - buffer
|
||||
* @param {string} extension - file extension of temp file
|
||||
* @return {string} - path of saved temporary file. deleted after 5 minutes.
|
||||
*/
|
||||
export const saveBufferToTempFile = async (
|
||||
buffer: Buffer,
|
||||
extension?: string,
|
||||
options?: WriteFileOptions,
|
||||
): Promise<string> => {
|
||||
const tempPath = getTempFilePath(extension);
|
||||
|
||||
fs.writeFileSync(tempPath, buffer, options);
|
||||
|
||||
delayedDelete(tempPath);
|
||||
|
||||
return tempPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches from URL to temporary storage.
|
||||
*
|
||||
* @param {string} url - URL
|
||||
* @param {string} extension - file extension of temp file
|
||||
* @return {string} - path of saved temporary file. deleted after 5 minutes.
|
||||
*/
|
||||
export const fetchURLToTempFile = async (url: string, cookies?: Array<string>, extension?: string): Promise<string> => {
|
||||
const tempPath = getTempFilePath(extension);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
responseType: 'stream',
|
||||
headers: cookies
|
||||
? {
|
||||
Cookie: cookies.join('; ').concat(';'),
|
||||
}
|
||||
: undefined,
|
||||
}).then(
|
||||
(res: AxiosResponse) => {
|
||||
delayedDelete(tempPath);
|
||||
res.data.pipe(fs.createWriteStream(tempPath)).on('finish', () => resolve(tempPath));
|
||||
},
|
||||
(e: AxiosError) => {
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -70,22 +70,22 @@ export const setTrackers = async (torrent: string, trackers: Array<string>): Pro
|
||||
return true;
|
||||
};
|
||||
|
||||
export const setCompleted = async (torrent: string, destination: string, isBasePath = true): Promise<boolean> => {
|
||||
const torrentData = await openAndDecodeTorrent(torrent);
|
||||
export const setCompleted = async (torrent: Buffer, destination: string, isBasePath = true): Promise<Buffer | null> => {
|
||||
const torrentData: TorrentFile | null = await bencode.decode(torrent);
|
||||
|
||||
if (torrentData == null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const {info} = torrentData;
|
||||
if (info == null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentSize = await getContentSize(info);
|
||||
const pieceSize = Number(info['piece length']);
|
||||
if (contentSize === 0 || pieceSize == null || pieceSize === 0) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentPathsWithLengths: Array<[string, number]> = [];
|
||||
@@ -103,7 +103,7 @@ export const setCompleted = async (torrent: string, destination: string, isBaseP
|
||||
]);
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedFileResumeTree: LibTorrentResume['files'] = contentPathsWithLengths.map((contentPathWithLength) => {
|
||||
@@ -143,10 +143,8 @@ export const setCompleted = async (torrent: string, destination: string, isBaseP
|
||||
});
|
||||
|
||||
try {
|
||||
fs.writeFileSync(torrent, bencode.encode(torrentDataWithResume));
|
||||
return bencode.encode(torrentDataWithResume);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user