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
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||||
IN THE SOFTWARE.
|
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
|
## @indutny/sneequals
|
||||||
|
|
||||||
Copyright Fedor Indutny, 2022.
|
Copyright Fedor Indutny, 2022.
|
||||||
|
|
|
@ -4,10 +4,10 @@
|
||||||
import { ipcMain, protocol } from 'electron';
|
import { ipcMain, protocol } from 'electron';
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { join, normalize } from 'node:path';
|
import { join, normalize } from 'node:path';
|
||||||
import { Readable, Transform, PassThrough } from 'node:stream';
|
import { Readable, PassThrough } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import * as rimraf from 'rimraf';
|
import * as rimraf from 'rimraf';
|
||||||
|
import { RangeFinder, DefaultStorage } from '@indutny/range-finder';
|
||||||
import {
|
import {
|
||||||
getAllAttachments,
|
getAllAttachments,
|
||||||
getAvatarsPath,
|
getAvatarsPath,
|
||||||
|
@ -29,6 +29,7 @@ import { sleep } from '../ts/util/sleep';
|
||||||
import { isPathInside } from '../ts/util/isPathInside';
|
import { isPathInside } from '../ts/util/isPathInside';
|
||||||
import { missingCaseError } from '../ts/util/missingCaseError';
|
import { missingCaseError } from '../ts/util/missingCaseError';
|
||||||
import { safeParseInteger } from '../ts/util/numbers';
|
import { safeParseInteger } from '../ts/util/numbers';
|
||||||
|
import { SECOND } from '../ts/util/durations';
|
||||||
import { drop } from '../ts/util/drop';
|
import { drop } from '../ts/util/drop';
|
||||||
import { strictAssert } from '../ts/util/assert';
|
import { strictAssert } from '../ts/util/assert';
|
||||||
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
|
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
|
||||||
|
@ -43,6 +44,81 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
|
||||||
|
|
||||||
const INTERACTIVITY_DELAY = 50;
|
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([
|
const dispositionSchema = z.enum([
|
||||||
'attachment',
|
'attachment',
|
||||||
'temporary',
|
'temporary',
|
||||||
|
@ -310,81 +386,58 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context: RangeFinderContextType;
|
||||||
|
|
||||||
// Legacy plaintext attachments
|
// Legacy plaintext attachments
|
||||||
if (url.host === 'v1') {
|
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({
|
return handleRangeRequest({
|
||||||
request: req,
|
request: req,
|
||||||
size: maybeSize,
|
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<{
|
type HandleRangeRequestOptionsType = Readonly<{
|
||||||
request: Request;
|
request: Request;
|
||||||
size: number | undefined;
|
size: number | undefined;
|
||||||
plaintext: Readable;
|
context: RangeFinderContextType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function handleRangeRequest({
|
function handleRangeRequest({
|
||||||
request,
|
request,
|
||||||
size,
|
size,
|
||||||
plaintext,
|
context,
|
||||||
}: HandleRangeRequestOptionsType): Response {
|
}: HandleRangeRequestOptionsType): Response {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
@ -401,6 +454,7 @@ function handleRangeRequest({
|
||||||
}
|
}
|
||||||
|
|
||||||
const create200Response = (): Response => {
|
const create200Response = (): Response => {
|
||||||
|
const plaintext = rangeFinder.get(0, context);
|
||||||
return new Response(Readable.toWeb(plaintext) as ReadableStream<Buffer>, {
|
return new Response(Readable.toWeb(plaintext) as ReadableStream<Buffer>, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
headers,
|
||||||
|
@ -412,7 +466,8 @@ function handleRangeRequest({
|
||||||
return create200Response();
|
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) {
|
if (match == null) {
|
||||||
console.error(`attachment_channel: invalid range header: ${range}`);
|
console.error(`attachment_channel: invalid range header: ${range}`);
|
||||||
return create200Response();
|
return create200Response();
|
||||||
|
@ -424,68 +479,16 @@ function handleRangeRequest({
|
||||||
return create200Response();
|
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);
|
const start = Math.min(startParam, size || Infinity);
|
||||||
let end: number;
|
|
||||||
if (endParam === undefined) {
|
headers['content-range'] = `bytes ${start}-/${size ?? '*'}`;
|
||||||
end = size || Infinity;
|
|
||||||
} else {
|
if (size !== undefined) {
|
||||||
// Supplied range is inclusive
|
headers['content-length'] = (size - start).toString();
|
||||||
end = Math.min(endParam + 1, size || Infinity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = 0;
|
const stream = rangeFinder.get(start, context);
|
||||||
const transform = new Transform({
|
return new Response(Readable.toWeb(stream) as ReadableStream<Buffer>, {
|
||||||
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>, {
|
|
||||||
status: 206,
|
status: 206,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
35
package-lock.json
generated
35
package-lock.json
generated
|
@ -14,6 +14,7 @@
|
||||||
"@formatjs/icu-messageformat-parser": "2.3.0",
|
"@formatjs/icu-messageformat-parser": "2.3.0",
|
||||||
"@formatjs/intl-localematcher": "0.2.32",
|
"@formatjs/intl-localematcher": "0.2.32",
|
||||||
"@indutny/dicer": "0.3.2",
|
"@indutny/dicer": "0.3.2",
|
||||||
|
"@indutny/range-finder": "1.3.0",
|
||||||
"@indutny/sneequals": "4.0.0",
|
"@indutny/sneequals": "4.0.0",
|
||||||
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
||||||
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
||||||
|
@ -4077,6 +4078,40 @@
|
||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@indutny/rezip-electron": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@indutny/rezip-electron/-/rezip-electron-1.3.1.tgz",
|
"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/icu-messageformat-parser": "2.3.0",
|
||||||
"@formatjs/intl-localematcher": "0.2.32",
|
"@formatjs/intl-localematcher": "0.2.32",
|
||||||
"@indutny/dicer": "0.3.2",
|
"@indutny/dicer": "0.3.2",
|
||||||
|
"@indutny/range-finder": "1.3.0",
|
||||||
"@indutny/sneequals": "4.0.0",
|
"@indutny/sneequals": "4.0.0",
|
||||||
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
||||||
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
||||||
|
|
|
@ -442,7 +442,10 @@ export async function decryptAttachmentV2ToSink(
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// These errors happen when canceling fetch from `attachment://` urls,
|
// These errors happen when canceling fetch from `attachment://` urls,
|
||||||
// ignore them to avoid noise in the logs.
|
// ignore them to avoid noise in the logs.
|
||||||
if (error.name === 'AbortError') {
|
if (
|
||||||
|
error.name === 'AbortError' ||
|
||||||
|
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue