Use range-finder for streaming range requests

This commit is contained in:
Fedor Indutny 2024-07-29 16:23:51 -07:00 committed by GitHub
parent ec0943cdad
commit a795602e19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 185 additions and 121 deletions

View File

@ -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.

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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;
}