Use range-finder for streaming range requests
This commit is contained in:
parent
ec0943cdad
commit
a795602e19
5 changed files with 185 additions and 121 deletions
|
@ -63,6 +63,28 @@ Signal Desktop makes use of the following open source projects.
|
|||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
## @indutny/range-finder
|
||||
|
||||
Copyright Fedor Indutny, 2022.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## @indutny/sneequals
|
||||
|
||||
Copyright Fedor Indutny, 2022.
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
import { ipcMain, protocol } from 'electron';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import { Readable, Transform, PassThrough } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable, PassThrough } from 'node:stream';
|
||||
import z from 'zod';
|
||||
import * as rimraf from 'rimraf';
|
||||
import { RangeFinder, DefaultStorage } from '@indutny/range-finder';
|
||||
import {
|
||||
getAllAttachments,
|
||||
getAvatarsPath,
|
||||
|
@ -29,6 +29,7 @@ import { sleep } from '../ts/util/sleep';
|
|||
import { isPathInside } from '../ts/util/isPathInside';
|
||||
import { missingCaseError } from '../ts/util/missingCaseError';
|
||||
import { safeParseInteger } from '../ts/util/numbers';
|
||||
import { SECOND } from '../ts/util/durations';
|
||||
import { drop } from '../ts/util/drop';
|
||||
import { strictAssert } from '../ts/util/assert';
|
||||
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
|
||||
|
@ -43,6 +44,81 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
|
|||
|
||||
const INTERACTIVITY_DELAY = 50;
|
||||
|
||||
type RangeFinderContextType = Readonly<
|
||||
| {
|
||||
type: 'ciphertext';
|
||||
path: string;
|
||||
keysBase64: string;
|
||||
size: number;
|
||||
}
|
||||
| {
|
||||
type: 'plaintext';
|
||||
path: string;
|
||||
}
|
||||
>;
|
||||
|
||||
async function safeDecryptToSink(
|
||||
...args: Parameters<typeof decryptAttachmentV2ToSink>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await decryptAttachmentV2ToSink(...args);
|
||||
} catch (error) {
|
||||
// These errors happen when canceling fetch from `attachment://` urls,
|
||||
// ignore them to avoid noise in the logs.
|
||||
if (
|
||||
error.name === 'AbortError' ||
|
||||
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'handleAttachmentRequest: decryption error',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new DefaultStorage<RangeFinderContextType>(
|
||||
ctx => {
|
||||
if (ctx.type === 'plaintext') {
|
||||
return createReadStream(ctx.path);
|
||||
}
|
||||
|
||||
if (ctx.type === 'ciphertext') {
|
||||
const options = {
|
||||
ciphertextPath: ctx.path,
|
||||
idForLogging: 'attachment_channel',
|
||||
keysBase64: ctx.keysBase64,
|
||||
type: 'local' as const,
|
||||
size: ctx.size,
|
||||
};
|
||||
|
||||
const plaintext = new PassThrough();
|
||||
drop(safeDecryptToSink(options, plaintext));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
throw missingCaseError(ctx);
|
||||
},
|
||||
{
|
||||
maxSize: 10,
|
||||
ttl: SECOND,
|
||||
cacheKey: ctx => {
|
||||
if (ctx.type === 'ciphertext') {
|
||||
return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`;
|
||||
}
|
||||
if (ctx.type === 'plaintext') {
|
||||
return `${ctx.type}:${ctx.path}`;
|
||||
}
|
||||
throw missingCaseError(ctx);
|
||||
},
|
||||
}
|
||||
);
|
||||
const rangeFinder = new RangeFinder<RangeFinderContextType>(storage, {
|
||||
noActiveReuse: true,
|
||||
});
|
||||
|
||||
const dispositionSchema = z.enum([
|
||||
'attachment',
|
||||
'temporary',
|
||||
|
@ -310,81 +386,58 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
|
|||
}
|
||||
}
|
||||
|
||||
let context: RangeFinderContextType;
|
||||
|
||||
// Legacy plaintext attachments
|
||||
if (url.host === 'v1') {
|
||||
context = {
|
||||
type: 'plaintext',
|
||||
path,
|
||||
};
|
||||
} else {
|
||||
// Encrypted attachments
|
||||
|
||||
// Get AES+MAC key
|
||||
const maybeKeysBase64 = url.searchParams.get('key');
|
||||
if (maybeKeysBase64 == null) {
|
||||
return new Response('Missing key', { status: 400 });
|
||||
}
|
||||
|
||||
// Size is required for trimming padding
|
||||
if (maybeSize == null) {
|
||||
return new Response('Missing size', { status: 400 });
|
||||
}
|
||||
|
||||
context = {
|
||||
type: 'ciphertext',
|
||||
path,
|
||||
keysBase64: maybeKeysBase64,
|
||||
size: maybeSize,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return handleRangeRequest({
|
||||
request: req,
|
||||
size: maybeSize,
|
||||
plaintext: createReadStream(path),
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('handleAttachmentRequest: error', Errors.toLogFormat(error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Encrypted attachments
|
||||
|
||||
// Get AES+MAC key
|
||||
const maybeKeysBase64 = url.searchParams.get('key');
|
||||
if (maybeKeysBase64 == null) {
|
||||
return new Response('Missing key', { status: 400 });
|
||||
}
|
||||
|
||||
// Size is required for trimming padding
|
||||
if (maybeSize == null) {
|
||||
return new Response('Missing size', { status: 400 });
|
||||
}
|
||||
|
||||
// Pacify typescript
|
||||
const size = maybeSize;
|
||||
const keysBase64 = maybeKeysBase64;
|
||||
|
||||
const plaintext = new PassThrough();
|
||||
|
||||
async function runSafe(): Promise<void> {
|
||||
try {
|
||||
await decryptAttachmentV2ToSink(
|
||||
{
|
||||
ciphertextPath: path,
|
||||
idForLogging: 'attachment_channel',
|
||||
keysBase64,
|
||||
type: 'local',
|
||||
size,
|
||||
},
|
||||
plaintext
|
||||
);
|
||||
} catch (error) {
|
||||
plaintext.destroy(error);
|
||||
|
||||
// These errors happen when canceling fetch from `attachment://` urls,
|
||||
// ignore them to avoid noise in the logs.
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'handleAttachmentRequest: decryption error',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(runSafe());
|
||||
|
||||
return handleRangeRequest({
|
||||
request: req,
|
||||
size: maybeSize,
|
||||
plaintext,
|
||||
});
|
||||
}
|
||||
|
||||
type HandleRangeRequestOptionsType = Readonly<{
|
||||
request: Request;
|
||||
size: number | undefined;
|
||||
plaintext: Readable;
|
||||
context: RangeFinderContextType;
|
||||
}>;
|
||||
|
||||
function handleRangeRequest({
|
||||
request,
|
||||
size,
|
||||
plaintext,
|
||||
context,
|
||||
}: HandleRangeRequestOptionsType): Response {
|
||||
const url = new URL(request.url);
|
||||
|
||||
|
@ -401,6 +454,7 @@ function handleRangeRequest({
|
|||
}
|
||||
|
||||
const create200Response = (): Response => {
|
||||
const plaintext = rangeFinder.get(0, context);
|
||||
return new Response(Readable.toWeb(plaintext) as ReadableStream<Buffer>, {
|
||||
status: 200,
|
||||
headers,
|
||||
|
@ -412,7 +466,8 @@ function handleRangeRequest({
|
|||
return create200Response();
|
||||
}
|
||||
|
||||
const match = range.match(/^bytes=(\d+)-(\d+)?$/);
|
||||
// Chromium only sends open-ended ranges: "start-"
|
||||
const match = range.match(/^bytes=(\d+)-$/);
|
||||
if (match == null) {
|
||||
console.error(`attachment_channel: invalid range header: ${range}`);
|
||||
return create200Response();
|
||||
|
@ -424,68 +479,16 @@ function handleRangeRequest({
|
|||
return create200Response();
|
||||
}
|
||||
|
||||
let endParam: number | undefined;
|
||||
if (match[2] != null) {
|
||||
const intValue = safeParseInteger(match[2]);
|
||||
if (intValue == null) {
|
||||
console.error(`attachment_channel: invalid range header: ${range}`);
|
||||
return create200Response();
|
||||
}
|
||||
endParam = intValue;
|
||||
}
|
||||
|
||||
const start = Math.min(startParam, size || Infinity);
|
||||
let end: number;
|
||||
if (endParam === undefined) {
|
||||
end = size || Infinity;
|
||||
} else {
|
||||
// Supplied range is inclusive
|
||||
end = Math.min(endParam + 1, size || Infinity);
|
||||
|
||||
headers['content-range'] = `bytes ${start}-/${size ?? '*'}`;
|
||||
|
||||
if (size !== undefined) {
|
||||
headers['content-length'] = (size - start).toString();
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const transform = new Transform({
|
||||
transform(data, _enc, callback) {
|
||||
if (offset + data.byteLength >= start && offset <= end) {
|
||||
this.push(data.subarray(Math.max(0, start - offset), end - offset));
|
||||
}
|
||||
|
||||
offset += data.byteLength;
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
headers['content-range'] =
|
||||
size === undefined
|
||||
? `bytes ${start}-${endParam === undefined ? '' : end - 1}/*`
|
||||
: `bytes ${start}-${end - 1}/${size}`;
|
||||
|
||||
if (endParam !== undefined || size !== undefined) {
|
||||
headers['content-length'] = (end - start).toString();
|
||||
}
|
||||
|
||||
drop(
|
||||
(async () => {
|
||||
try {
|
||||
await pipeline(plaintext, transform);
|
||||
} catch (error) {
|
||||
transform.destroy(error);
|
||||
|
||||
// These errors happen when canceling fetch from `attachment://` urls,
|
||||
// ignore them to avoid noise in the logs.
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'handleAttachmentRequest: range transform error',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
return new Response(Readable.toWeb(transform) as ReadableStream<Buffer>, {
|
||||
const stream = rangeFinder.get(start, context);
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream<Buffer>, {
|
||||
status: 206,
|
||||
headers,
|
||||
});
|
||||
|
|
35
package-lock.json
generated
35
package-lock.json
generated
|
@ -14,6 +14,7 @@
|
|||
"@formatjs/icu-messageformat-parser": "2.3.0",
|
||||
"@formatjs/intl-localematcher": "0.2.32",
|
||||
"@indutny/dicer": "0.3.2",
|
||||
"@indutny/range-finder": "1.3.0",
|
||||
"@indutny/sneequals": "4.0.0",
|
||||
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
||||
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
||||
|
@ -4077,6 +4078,40 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@indutny/range-finder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@indutny/range-finder/-/range-finder-1.3.0.tgz",
|
||||
"integrity": "sha512-nQ/rnV4824CSkONVpbPdi4YA69cgG7uzqwz8snbU8Mz80o0KFcYC7lYvsSpQvT6TwxI1XY1J92wMns+lWSoiFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "^4.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@indutny/range-finder/node_modules/readable-stream": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz",
|
||||
"integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@indutny/range-finder/node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@indutny/rezip-electron": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@indutny/rezip-electron/-/rezip-electron-1.3.1.tgz",
|
||||
|
|
|
@ -96,6 +96,7 @@
|
|||
"@formatjs/icu-messageformat-parser": "2.3.0",
|
||||
"@formatjs/intl-localematcher": "0.2.32",
|
||||
"@indutny/dicer": "0.3.2",
|
||||
"@indutny/range-finder": "1.3.0",
|
||||
"@indutny/sneequals": "4.0.0",
|
||||
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
||||
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
||||
|
|
|
@ -442,7 +442,10 @@ export async function decryptAttachmentV2ToSink(
|
|||
} catch (error) {
|
||||
// These errors happen when canceling fetch from `attachment://` urls,
|
||||
// ignore them to avoid noise in the logs.
|
||||
if (error.name === 'AbortError') {
|
||||
if (
|
||||
error.name === 'AbortError' ||
|
||||
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue