mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-02 12:47:41 +02:00
Api: Add capability of limiting downloads (#9788)
This commit is contained in:
parent
4d8fcff6d5
commit
17a8ce5010
@ -723,6 +723,7 @@ packages/lib/database.js
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/determineBaseAppDirs.js
|
||||
packages/lib/dom.js
|
||||
packages/lib/downloadController.js
|
||||
packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -703,6 +703,7 @@ packages/lib/database.js
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/determineBaseAppDirs.js
|
||||
packages/lib/dom.js
|
||||
packages/lib/downloadController.js
|
||||
packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
|
95
packages/lib/downloadController.ts
Normal file
95
packages/lib/downloadController.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import JoplinError from './JoplinError';
|
||||
import { ErrorCode } from './errors';
|
||||
import { bytesToHuman } from '@joplin/utils/bytes';
|
||||
|
||||
const logger = Logger.create('downloadController');
|
||||
|
||||
export interface DownloadController {
|
||||
totalBytes: number;
|
||||
imagesCount: number;
|
||||
maxImagesCount: number;
|
||||
imageCountExpected: number;
|
||||
printStats(imagesCountExpected: number): void;
|
||||
handleChunk(request: any): (chunk: any)=> void;
|
||||
limitMessage(): string;
|
||||
}
|
||||
|
||||
export class LimitedDownloadController implements DownloadController {
|
||||
private totalBytes_ = 0;
|
||||
// counts before the downloaded has finished, so at the end if the totalBytes > maxTotalBytesAllowed
|
||||
// it means that imageCount will be higher than the total downloaded during the process
|
||||
private imagesCount_ = 0;
|
||||
// how many images links the content has
|
||||
private imageCountExpected_ = 0;
|
||||
private isLimitExceeded_ = false;
|
||||
|
||||
private maxTotalBytes = 0;
|
||||
public readonly maxImagesCount: number;
|
||||
private ownerId = '';
|
||||
|
||||
public constructor(ownerId: string, maxTotalBytes: number, maxImagesCount: number) {
|
||||
this.ownerId = ownerId;
|
||||
this.maxTotalBytes = maxTotalBytes;
|
||||
this.maxImagesCount = maxImagesCount;
|
||||
}
|
||||
|
||||
public set totalBytes(value: number) {
|
||||
if (this.totalBytes_ >= this.maxTotalBytes) {
|
||||
throw new JoplinError(`Total bytes stored (${this.totalBytes_}) has exceeded the amount established (${this.maxTotalBytes})`, ErrorCode.DownloadLimiter);
|
||||
}
|
||||
this.totalBytes_ = value;
|
||||
}
|
||||
|
||||
public get totalBytes() {
|
||||
return this.totalBytes_;
|
||||
}
|
||||
|
||||
public set imagesCount(value: number) {
|
||||
if (this.imagesCount_ > this.maxImagesCount) {
|
||||
throw new JoplinError(`Total images to be stored (${this.imagesCount_}) has exceeded the amount established (${this.maxImagesCount})`, ErrorCode.DownloadLimiter);
|
||||
}
|
||||
this.imagesCount_ = value;
|
||||
}
|
||||
|
||||
public get imagesCount() {
|
||||
return this.imagesCount_;
|
||||
}
|
||||
|
||||
public set imageCountExpected(value: number) {
|
||||
this.imageCountExpected_ = value;
|
||||
}
|
||||
|
||||
public get imageCountExpected() {
|
||||
return this.imageCountExpected_;
|
||||
}
|
||||
|
||||
public handleChunk(request: any) {
|
||||
return (chunk: any) => {
|
||||
try {
|
||||
this.totalBytes += chunk.length;
|
||||
} catch (error) {
|
||||
request.destroy(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public printStats() {
|
||||
if (!this.isLimitExceeded_) return;
|
||||
|
||||
const owner = `Owner id: ${this.ownerId}`;
|
||||
const totalBytes = `Total bytes stored: ${this.totalBytes}. Maximum: ${this.maxTotalBytes}`;
|
||||
const totalImages = `Images initiated for download: ${this.imagesCount_}. Maximum: ${this.maxImagesCount}. Expected: ${this.imageCountExpected}`;
|
||||
logger.info(`${owner} - ${totalBytes} - ${totalImages}`);
|
||||
}
|
||||
|
||||
public limitMessage() {
|
||||
if (this.imagesCount_ > this.maxImagesCount) {
|
||||
return `The maximum image count of ${this.maxImagesCount} has been exceeded. Image count in your content: ${this.imageCountExpected}`;
|
||||
}
|
||||
if (this.totalBytes >= this.maxTotalBytes) {
|
||||
return `The maximum content size ${bytesToHuman(this.maxTotalBytes)} has been exceeded. Content size: (${bytesToHuman(this.totalBytes)})`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
@ -5,4 +5,5 @@ export enum ErrorCode {
|
||||
NotFound = 'notFound',
|
||||
UnsupportedMimeType = 'unsupportedMimeType',
|
||||
MustUpgradeApp = 'mustUpgradeApp',
|
||||
DownloadLimiter = 'downloadLimiter',
|
||||
}
|
||||
|
@ -54,7 +54,6 @@
|
||||
"color": "3.2.1",
|
||||
"compare-versions": "6.1.0",
|
||||
"diff-match-patch": "1.0.5",
|
||||
"es6-promise-pool": "2.5.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fast-xml-parser": "3.21.1",
|
||||
"follow-redirects": "1.15.5",
|
||||
|
@ -28,6 +28,7 @@ const { MarkupToHtml } = require('@joplin/renderer');
|
||||
const { ErrorNotFound } = require('../utils/errors');
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { NoteEntity } from '../../database/types';
|
||||
import { DownloadController } from '../../../downloadController';
|
||||
|
||||
const logger = Logger.create('routes/notes');
|
||||
|
||||
@ -66,6 +67,7 @@ type RequestNote = {
|
||||
type FetchOptions = {
|
||||
timeout?: number;
|
||||
maxRedirects?: number;
|
||||
downloadController?: DownloadController;
|
||||
};
|
||||
|
||||
async function requestNoteToNote(requestNote: RequestNote): Promise<NoteEntity> {
|
||||
@ -263,26 +265,31 @@ export async function downloadMediaFile(url: string, fetchOptions?: FetchOptions
|
||||
}
|
||||
|
||||
async function downloadMediaFiles(urls: string[], fetchOptions?: FetchOptions, allowedProtocols?: string[]) {
|
||||
const PromisePool = require('es6-promise-pool');
|
||||
|
||||
const output: any = {};
|
||||
|
||||
const downloadController = fetchOptions?.downloadController ?? null;
|
||||
|
||||
const downloadOne = async (url: string) => {
|
||||
if (downloadController) downloadController.imagesCount += 1;
|
||||
const mediaPath = await downloadMediaFile(url, fetchOptions, allowedProtocols);
|
||||
if (mediaPath) output[url] = { path: mediaPath, originalUrl: url };
|
||||
};
|
||||
|
||||
let urlIndex = 0;
|
||||
const promiseProducer = () => {
|
||||
if (urlIndex >= urls.length) return null;
|
||||
const maximumImageDownloadsAllowed = downloadController ? downloadController.maxImagesCount : Number.POSITIVE_INFINITY;
|
||||
const urlsAllowedByController = urls.slice(0, maximumImageDownloadsAllowed);
|
||||
logger.info(`Media files allowed to be downloaded: ${maximumImageDownloadsAllowed}`);
|
||||
|
||||
const url = urls[urlIndex++];
|
||||
return downloadOne(url);
|
||||
};
|
||||
const promises = [];
|
||||
for (const url of urlsAllowedByController) {
|
||||
promises.push(downloadOne(url));
|
||||
}
|
||||
|
||||
const concurrency = 10;
|
||||
const pool = new PromisePool(promiseProducer, concurrency);
|
||||
await pool.start();
|
||||
await Promise.all(promises);
|
||||
|
||||
if (downloadController) {
|
||||
downloadController.imageCountExpected = urls.length;
|
||||
downloadController.printStats(urls.length);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@ -459,7 +466,13 @@ export default async function(request: Request, id: string = null, link: string
|
||||
logger.info('Images:', imageSizes);
|
||||
|
||||
const allowedProtocolsForDownloadMediaFiles = ['http:', 'https:', 'file:', 'data:'];
|
||||
const extracted = await extractNoteFromHTML(requestNote, requestId, imageSizes, undefined, allowedProtocolsForDownloadMediaFiles);
|
||||
const extracted = await extractNoteFromHTML(
|
||||
requestNote,
|
||||
requestId,
|
||||
imageSizes,
|
||||
undefined,
|
||||
allowedProtocolsForDownloadMediaFiles,
|
||||
);
|
||||
|
||||
let note = await Note.save(extracted.note, extracted.saveOptions);
|
||||
|
||||
|
@ -9,6 +9,7 @@ import * as fs from 'fs-extra';
|
||||
import * as pdfJsNamespace from 'pdfjs-dist';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { ResourceEntity } from './services/database/types';
|
||||
import { DownloadController } from './downloadController';
|
||||
import { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import replaceUnsupportedCharacters from './utils/replaceUnsupportedCharacters';
|
||||
|
||||
@ -25,6 +26,15 @@ const dgram = require('dgram');
|
||||
|
||||
const proxySettings: any = {};
|
||||
|
||||
type FetchBlobOptions = {
|
||||
path?: string;
|
||||
method?: string;
|
||||
maxRedirects?: number;
|
||||
timeout?: number;
|
||||
headers?: any;
|
||||
downloadController?: DownloadController;
|
||||
};
|
||||
|
||||
function fileExists(filePath: string) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
@ -493,7 +503,7 @@ function shimInit(options: ShimInitOptions = null) {
|
||||
}, options);
|
||||
};
|
||||
|
||||
shim.fetchBlob = async function(url: any, options) {
|
||||
shim.fetchBlob = async function(url: any, options: FetchBlobOptions) {
|
||||
if (!options || !options.path) throw new Error('fetchBlob: target file path is missing');
|
||||
if (!options.method) options.method = 'GET';
|
||||
// if (!('maxRetry' in options)) options.maxRetry = 5;
|
||||
@ -510,6 +520,7 @@ function shimInit(options: ShimInitOptions = null) {
|
||||
const http = url.protocol.toLowerCase() === 'http:' ? require('follow-redirects').http : require('follow-redirects').https;
|
||||
const headers = options.headers ? options.headers : {};
|
||||
const filePath = options.path;
|
||||
const downloadController = options.downloadController;
|
||||
|
||||
function makeResponse(response: any) {
|
||||
return {
|
||||
@ -571,6 +582,11 @@ function shimInit(options: ShimInitOptions = null) {
|
||||
});
|
||||
|
||||
const request = http.request(requestOptions, (response: any) => {
|
||||
|
||||
if (downloadController) {
|
||||
response.on('data', downloadController.handleChunk(request));
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
const isGzipped = response.headers['content-encoding'] === 'gzip';
|
||||
|
12
packages/utils/bytes.ts
Normal file
12
packages/utils/bytes.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const bytesToHuman = (bytes: number) => {
|
||||
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
let unitIndex = 0;
|
||||
|
||||
while (bytes >= 1024 && unitIndex < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
@ -6866,7 +6866,6 @@ __metadata:
|
||||
color: 3.2.1
|
||||
compare-versions: 6.1.0
|
||||
diff-match-patch: 1.0.5
|
||||
es6-promise-pool: 2.5.0
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-xml-parser: 3.21.1
|
||||
follow-redirects: 1.15.5
|
||||
@ -20008,13 +20007,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es6-promise-pool@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "es6-promise-pool@npm:2.5.0"
|
||||
checksum: e472ec5959b022b28e678446674c78dd2d198dd50c537ef59916d32d2423fe4518c43f132d81f2e98249b8b8450c95f77b8d9aecc1fb15e8dcd224c5b98f0cce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es6-promise@npm:^4.0.3, es6-promise@npm:^4.1.1":
|
||||
version: 4.2.8
|
||||
resolution: "es6-promise@npm:4.2.8"
|
||||
|
Loading…
Reference in New Issue
Block a user