mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
feat: implement partial media downloads
This commit is contained in:
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
"roots": [
|
||||||
|
"<rootDir>/src"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"**/Tests/test.*.+(ts|tsx|js)",
|
||||||
|
],
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||||
|
},
|
||||||
|
}
|
||||||
64
src/Tests/test.media-download.ts
Normal file
64
src/Tests/test.media-download.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { MediaType, DownloadableMessage } from '../Types'
|
||||||
|
import { downloadContentFromMessage } from '../Utils'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
|
type TestVector = {
|
||||||
|
type: MediaType
|
||||||
|
message: DownloadableMessage
|
||||||
|
plaintext: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_VECTORS: TestVector[] = [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
message: proto.ImageMessage.decode(
|
||||||
|
Buffer.from(
|
||||||
|
'Ck1odHRwczovL21tZy53aGF0c2FwcC5uZXQvZC9mL0FwaHR4WG9fWXZZcDZlUVNSa0tjOHE5d2ozVUpleWdoY3poM3ExX3I0ektnLmVuYxIKaW1hZ2UvanBlZyIgKTuVFyxDc6mTm4GXPlO3Z911Wd8RBeTrPLSWAEdqW8MomcUBQiB7wH5a4nXMKyLOT0A2nFgnnM/DUH8YjQf8QtkCIekaSkogTB+BXKCWDFrmNzozY0DCPn0L4VKd7yG1ZbZwbgRhzVc=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
plaintext: readFileSync('./Media/cat.jpeg')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Media Download Tests', () => {
|
||||||
|
|
||||||
|
it('should download a full encrypted media correctly', async() => {
|
||||||
|
for(const { type, message, plaintext } of TEST_VECTORS) {
|
||||||
|
const readPipe = await downloadContentFromMessage(message, type)
|
||||||
|
|
||||||
|
let buffer = Buffer.alloc(0)
|
||||||
|
for await(const read of readPipe) {
|
||||||
|
buffer = Buffer.concat([ buffer, read ])
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buffer).toEqual(plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should download an encrypted media correctly piece', async() => {
|
||||||
|
for(const { type, message, plaintext } of TEST_VECTORS) {
|
||||||
|
// check all edge cases
|
||||||
|
const ranges = [
|
||||||
|
{ startByte: 51, endByte: plaintext.length-100 }, // random numbers
|
||||||
|
{ startByte: 1024, endByte: 2038 }, // larger random multiples of 16
|
||||||
|
{ startByte: 1, endByte: plaintext.length-1 } // borders
|
||||||
|
]
|
||||||
|
for(const range of ranges) {
|
||||||
|
const readPipe = await downloadContentFromMessage(message, type, range)
|
||||||
|
|
||||||
|
let buffer = Buffer.alloc(0)
|
||||||
|
for await(const read of readPipe) {
|
||||||
|
buffer = Buffer.concat([ buffer, read ])
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = buffer.toString('hex')
|
||||||
|
const expectedHex = plaintext.slice(range.startByte || 0, range.endByte || undefined).toString('hex')
|
||||||
|
expect(hex).toBe(expectedHex)
|
||||||
|
|
||||||
|
console.log('success on ', range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,8 @@ export type WAMediaUpload = Buffer | { url: URL | string } | { stream: Readable
|
|||||||
/** Set of message types that are supported by the library */
|
/** Set of message types that are supported by the library */
|
||||||
export type MessageType = keyof proto.Message
|
export type MessageType = keyof proto.Message
|
||||||
|
|
||||||
|
export type DownloadableMessage = { mediaKey?: Uint8Array, directPath?: string, url?: string }
|
||||||
|
|
||||||
export type MediaConnInfo = {
|
export type MediaConnInfo = {
|
||||||
auth: string
|
auth: string
|
||||||
ttl: number
|
ttl: number
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { URL } from 'url'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { once } from 'events'
|
import { once } from 'events'
|
||||||
import got, { Options, Response } from 'got'
|
import got, { Options, Response } from 'got'
|
||||||
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType } from '../Types'
|
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage } from '../Types'
|
||||||
import { generateMessageID } from './generics'
|
import { generateMessageID } from './generics'
|
||||||
import { hkdf } from './crypto'
|
import { hkdf } from './crypto'
|
||||||
import { DEFAULT_ORIGIN } from '../Defaults'
|
import { DEFAULT_ORIGIN } from '../Defaults'
|
||||||
@@ -223,30 +223,96 @@ export const encryptedStream = async(media: WAMediaUpload, mediaType: MediaType,
|
|||||||
didSaveToTmpPath
|
didSaveToTmpPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEF_HOST = 'mmg.whatsapp.net'
|
const DEF_HOST = 'mmg.whatsapp.net'
|
||||||
|
const AES_CHUNK_SIZE = 16
|
||||||
|
|
||||||
|
const toSmallestChunkSize = (num: number) => {
|
||||||
|
return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaDownloadOptions = {
|
||||||
|
startByte?: number
|
||||||
|
endByte?: number
|
||||||
|
}
|
||||||
|
|
||||||
export const downloadContentFromMessage = async(
|
export const downloadContentFromMessage = async(
|
||||||
{ mediaKey, directPath, url }: { mediaKey?: Uint8Array, directPath?: string, url?: string },
|
{ mediaKey, directPath, url }: DownloadableMessage,
|
||||||
type: MediaType
|
type: MediaType,
|
||||||
|
{ startByte, endByte }: MediaDownloadOptions = { }
|
||||||
) => {
|
) => {
|
||||||
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
|
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
|
||||||
|
let bytesFetched = 0
|
||||||
|
let startChunk = 0
|
||||||
|
let firstBlockIsIV = false
|
||||||
|
// if a start byte is specified -- then we need to fetch the previous chunk as that will form the IV
|
||||||
|
if(startByte) {
|
||||||
|
const chunk = toSmallestChunkSize(startByte || 0)
|
||||||
|
if(chunk) {
|
||||||
|
startChunk = chunk-AES_CHUNK_SIZE
|
||||||
|
bytesFetched = chunk
|
||||||
|
|
||||||
|
firstBlockIsIV = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endChunk = endByte ? toSmallestChunkSize(endByte || 0)+AES_CHUNK_SIZE : undefined
|
||||||
|
let rangeHeader: string | undefined = undefined
|
||||||
|
if(startChunk || endChunk) {
|
||||||
|
rangeHeader = `bytes=${startChunk}-`
|
||||||
|
if(endChunk) rangeHeader += endChunk
|
||||||
|
}
|
||||||
// download the message
|
// download the message
|
||||||
const fetched = await getGotStream(downloadUrl, {
|
const fetched = await getGotStream(downloadUrl, {
|
||||||
headers: { Origin: DEFAULT_ORIGIN }
|
headers: {
|
||||||
|
Origin: DEFAULT_ORIGIN,
|
||||||
|
Range: rangeHeader
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let remainingBytes = Buffer.from([])
|
let remainingBytes = Buffer.from([])
|
||||||
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
|
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
|
||||||
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
|
|
||||||
|
let aes: Crypto.Decipher
|
||||||
|
|
||||||
|
const pushBytes = (bytes: Buffer, push: (bytes: Buffer) => void) => {
|
||||||
|
if(startByte || endByte) {
|
||||||
|
const start = bytesFetched >= startByte ? undefined : Math.max(startByte-bytesFetched, 0)
|
||||||
|
const end = bytesFetched+bytes.length < endByte ? undefined : Math.max(endByte-bytesFetched, 0)
|
||||||
|
|
||||||
|
push(bytes.slice(start, end))
|
||||||
|
|
||||||
|
bytesFetched += bytes.length
|
||||||
|
} else {
|
||||||
|
push(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const output = new Transform({
|
const output = new Transform({
|
||||||
transform(chunk, _, callback) {
|
transform(chunk, _, callback) {
|
||||||
let data = Buffer.concat([remainingBytes, chunk])
|
let data = Buffer.concat([remainingBytes, chunk])
|
||||||
const decryptLength =
|
|
||||||
Math.floor(data.length / 16) * 16
|
const decryptLength = toSmallestChunkSize(data.length)
|
||||||
remainingBytes = data.slice(decryptLength)
|
remainingBytes = data.slice(decryptLength)
|
||||||
data = data.slice(0, decryptLength)
|
data = data.slice(0, decryptLength)
|
||||||
|
|
||||||
|
if(!aes) {
|
||||||
|
let ivValue = iv
|
||||||
|
if(firstBlockIsIV) {
|
||||||
|
ivValue = data.slice(0, AES_CHUNK_SIZE)
|
||||||
|
data = data.slice(AES_CHUNK_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, ivValue)
|
||||||
|
// if an end byte that is not EOF is specified
|
||||||
|
// stop auto padding (PKCS7) -- otherwise throws an error for decryption
|
||||||
|
if(endByte) {
|
||||||
|
aes.setAutoPadding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.push(aes.update(data))
|
pushBytes(aes.update(data), b => this.push(b))
|
||||||
callback()
|
callback()
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
callback(error)
|
callback(error)
|
||||||
@@ -254,7 +320,7 @@ export const downloadContentFromMessage = async(
|
|||||||
},
|
},
|
||||||
final(callback) {
|
final(callback) {
|
||||||
try {
|
try {
|
||||||
this.push(aes.final())
|
pushBytes(aes.final(), b => this.push(b))
|
||||||
callback()
|
callback()
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
callback(error)
|
callback(error)
|
||||||
|
|||||||
Reference in New Issue
Block a user