@nexustechpro/baileys 2.0.6 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -1
- package/lib/Defaults/index.js +3 -2
- package/lib/Signal/libsignal.js +552 -358
- package/lib/Socket/chats.js +192 -273
- package/lib/Socket/messages-recv.js +4 -2
- package/lib/Socket/messages-send.js +42 -18
- package/lib/Socket/newsletter.js +87 -36
- package/lib/Socket/nexus-handler.js +44 -42
- package/lib/Socket/socket.js +23 -10
- package/lib/Store/make-in-memory-store.js +29 -20
- package/lib/Utils/auth-utils.js +2 -2
- package/lib/Utils/chat-utils.js +319 -620
- package/lib/Utils/decode-wa-message.js +11 -26
- package/lib/Utils/key-store.js +1 -1
- package/lib/Utils/link-preview.js +134 -71
- package/lib/Utils/messages-media.js +97 -26
- package/lib/Utils/messages.js +723 -820
- package/lib/Utils/use-multi-file-auth-state.js +183 -90
- package/lib/index.js +1 -1
- package/package.json +10 -8
|
@@ -135,13 +135,13 @@ export function decodeMessageNode(stanza, meId, meLid) {
|
|
|
135
135
|
const isMe = (jid) => areJidsSameUser(jid, meId)
|
|
136
136
|
const isMeLid = (jid) => areJidsSameUser(jid, meLid)
|
|
137
137
|
if (isPnUser(from) || isLidUser(from) || isHostedLidUser(from) || isHostedPnUser(from)) {
|
|
138
|
+
if (isMe(from) || isMeLid(from)) {
|
|
139
|
+
fromMe = true
|
|
140
|
+
}
|
|
138
141
|
if (recipient && !isJidMetaAI(recipient)) {
|
|
139
|
-
if (!
|
|
142
|
+
if (!fromMe) {
|
|
140
143
|
throw new Boom("receipient present, but msg not from me", { data: stanza })
|
|
141
144
|
}
|
|
142
|
-
if (isMe(from) || isMeLid(from)) {
|
|
143
|
-
fromMe = true
|
|
144
|
-
}
|
|
145
145
|
chatId = recipient
|
|
146
146
|
} else {
|
|
147
147
|
chatId = from
|
|
@@ -254,12 +254,13 @@ export const decryptMessageNode = (stanza, meId, meLid, repository, logger) => {
|
|
|
254
254
|
})
|
|
255
255
|
} catch (decryptErr) {
|
|
256
256
|
const errMsg = decryptErr?.message || decryptErr?.toString() || ''
|
|
257
|
-
if (errMsg.includes('
|
|
258
|
-
|
|
257
|
+
if (errMsg.includes('memory access out of bounds')) {
|
|
258
|
+
console.error('[Signal] Stale sender key — group:', sender, 'author:', author, 'err:', errMsg)
|
|
259
259
|
try {
|
|
260
260
|
await repository.deleteSenderKey(sender, author)
|
|
261
|
+
console.error('[Signal] Sender key deleted successfully')
|
|
261
262
|
} catch (e) {
|
|
262
|
-
|
|
263
|
+
console.error('[Signal] Failed to delete sender key:', e)
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
throw decryptErr
|
|
@@ -267,24 +268,8 @@ export const decryptMessageNode = (stanza, meId, meLid, repository, logger) => {
|
|
|
267
268
|
break
|
|
268
269
|
case "pkmsg":
|
|
269
270
|
case "msg":
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (msgBuffer === null) {
|
|
273
|
-
throw new Error("DuplicatedMessage")
|
|
274
|
-
}
|
|
275
|
-
} catch (decryptErr) {
|
|
276
|
-
const errMsg = decryptErr?.message || decryptErr?.toString() || ''
|
|
277
|
-
if ((errMsg.includes('InvalidMessage') || errMsg.includes('BadMac')) && e2eType === 'msg') {
|
|
278
|
-
logger?.debug?.({ jid: decryptionJid, errMsg }, '[Signal] Stale session (InvalidMessage/BadMac), deleting')
|
|
279
|
-
try {
|
|
280
|
-
const toDelete = [decryptionJid]
|
|
281
|
-
await repository.deleteSession(toDelete)
|
|
282
|
-
} catch { }
|
|
283
|
-
}
|
|
284
|
-
const cleanError = new Error(errMsg || 'Decryption failed')
|
|
285
|
-
cleanError.stack = decryptErr?.stack
|
|
286
|
-
throw cleanError
|
|
287
|
-
}
|
|
271
|
+
msgBuffer = await repository.decryptMessage({ jid: decryptionJid, type: e2eType, ciphertext: content })
|
|
272
|
+
if (msgBuffer === null) return // DuplicatedMessage — libsignal already handled it silently
|
|
288
273
|
break
|
|
289
274
|
case "plaintext":
|
|
290
275
|
msgBuffer = content
|
|
@@ -324,7 +309,7 @@ export const decryptMessageNode = (stanza, meId, meLid, repository, logger) => {
|
|
|
324
309
|
} catch (err) {
|
|
325
310
|
const errorMessage = err?.message || err?.toString() || ""
|
|
326
311
|
const errStr = err?.message || (typeof err === "string" ? err : "") || ""
|
|
327
|
-
const isExpectedDecryptErr = errStr.includes("InvalidPreKeyId") || errStr.includes("SessionNotFound") || errStr.includes("InvalidMessage") || errStr.includes("no sender key state") || errStr.includes("
|
|
312
|
+
const isExpectedDecryptErr = errStr.includes("InvalidPreKeyId") || errStr.includes("SessionNotFound") || errStr.includes("InvalidMessage") || errStr.includes("no sender key state") || errStr.includes("memory access out of bounds") || errStr.includes("old counter") || errStr.includes("DuplicatedMessage") || errStr.includes("BadMac")
|
|
328
313
|
; (isExpectedDecryptErr ? logger?.debug?.bind(logger) : logger?.error?.bind(logger))?.({ key: fullMessage.key, err, errorMessage, messageType: tag === "plaintext" ? "plaintext" : attrs.type, sender, author }, "failed to decrypt message")
|
|
329
314
|
fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT
|
|
330
315
|
fullMessage.messageStubParameters = [errorMessage]
|
package/lib/Utils/key-store.js
CHANGED
|
@@ -8,7 +8,7 @@ export const migrateIndexKey = async (keys, type) => {
|
|
|
8
8
|
const oldKey = '_index'
|
|
9
9
|
const newKey = 'index'
|
|
10
10
|
const oldData = await keys.get(type, [oldKey])
|
|
11
|
-
if (oldData?.[oldKey] && typeof oldData[oldKey] === 'object') { // only migrate if old blob actually exists and has data
|
|
11
|
+
if (oldData?.[oldKey] !== null && oldData?.[oldKey] !== undefined && typeof oldData[oldKey] === 'object') { // only migrate if old blob actually exists and has data
|
|
12
12
|
await keys.set({ [type]: { [newKey]: oldData[oldKey], [oldKey]: null } })
|
|
13
13
|
return oldData[oldKey]
|
|
14
14
|
}
|
|
@@ -1,108 +1,171 @@
|
|
|
1
|
-
import { promises as dnsPromises } from 'dns'
|
|
2
1
|
import { getLinkPreview } from 'link-preview-js'
|
|
2
|
+
import mql from '@microlink/mql'
|
|
3
|
+
import { lookup } from 'dns'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { LRUCache } from 'lru-cache'
|
|
3
6
|
import { prepareWAMessageMedia } from './messages.js'
|
|
4
7
|
import { extractImageThumb, getHttpStream } from './messages-media.js'
|
|
5
8
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
// Concurrency pool — max simultaneous link preview fetches
|
|
9
|
+
const dnsLookup = promisify(lookup)
|
|
10
|
+
const THUMBNAIL_WIDTH = 192
|
|
11
|
+
const TIMEOUT = 4_000
|
|
10
12
|
const MAX_CONCURRENT = 20
|
|
13
|
+
const MAX_INFLIGHT = 1000
|
|
14
|
+
|
|
15
|
+
const _previewCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 10 })
|
|
16
|
+
const _negCache = new LRUCache({ max: 500, ttl: 1000 * 60 * 2 })
|
|
17
|
+
const _thumbCache = new LRUCache({ max: 200, ttl: 1000 * 60 * 30 })
|
|
18
|
+
const _inflight = new Map()
|
|
19
|
+
|
|
11
20
|
let _active = 0
|
|
12
21
|
const _queue = []
|
|
22
|
+
|
|
13
23
|
const _drain = () => {
|
|
14
|
-
if (_queue.length
|
|
24
|
+
if (!_queue.length || _active >= MAX_CONCURRENT) return
|
|
15
25
|
_active++
|
|
16
26
|
const { fn, resolve, reject } = _queue.shift()
|
|
17
27
|
fn().then(resolve).catch(reject).finally(() => { _active--; _drain() })
|
|
18
28
|
}
|
|
19
|
-
const _enqueue = fn => new Promise((resolve, reject) => { _queue.push({ fn, resolve, reject }); _drain() })
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
const _enqueue = fn => new Promise((resolve, reject) => {
|
|
31
|
+
_queue.push({ fn, resolve, reject }); _drain()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const _resolveDNS = async url => {
|
|
35
|
+
try { return (await dnsLookup(new URL(url).hostname)).address }
|
|
36
|
+
catch { return new URL(url).hostname }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const _normalize = text => {
|
|
40
|
+
const t = text.trim()
|
|
41
|
+
return /^https?:\/\//i.test(t) ? t : `https://${t}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const _previewType = (mediaType, image) => {
|
|
45
|
+
if (!mediaType) return image ? 5 : 0
|
|
46
|
+
const mt = mediaType.toLowerCase()
|
|
47
|
+
if (mt === 'video' || mt.startsWith('video.')) return 1
|
|
48
|
+
return mt === 'image' || image ? 5 : 0
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
const _compressedThumb = async (url, opts) => {
|
|
23
52
|
const stream = await getHttpStream(url, opts.fetchOpts)
|
|
24
|
-
|
|
25
|
-
return result.buffer
|
|
53
|
+
return (await extractImageThumb(stream, opts.thumbnailWidth ?? THUMBNAIL_WIDTH)).buffer
|
|
26
54
|
}
|
|
27
55
|
|
|
28
|
-
/** Resolves jpegThumbnail + highQualityThumbnail from an image URL, never throws */
|
|
29
56
|
const _resolveThumbnail = async (image, opts) => {
|
|
30
57
|
if (!image) return {}
|
|
58
|
+
const key = `thumb:${image}`
|
|
59
|
+
const hit = _thumbCache.get(key)
|
|
60
|
+
if (hit) return hit
|
|
61
|
+
|
|
62
|
+
let thumbs = {}
|
|
31
63
|
if (opts.uploadImage) {
|
|
32
64
|
try {
|
|
33
65
|
const { imageMessage } = await prepareWAMessageMedia(
|
|
34
66
|
{ image: { url: image } },
|
|
35
67
|
{ upload: opts.uploadImage, mediaTypeOverride: 'thumbnail-link', options: opts.fetchOpts }
|
|
36
68
|
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
} catch
|
|
42
|
-
|
|
69
|
+
const jpeg = imageMessage?.jpegThumbnail
|
|
70
|
+
? Buffer.from(imageMessage.jpegThumbnail)
|
|
71
|
+
: await _compressedThumb(image, opts).catch(() => undefined)
|
|
72
|
+
thumbs = { jpegThumbnail: jpeg, highQualityThumbnail: imageMessage ?? undefined }
|
|
73
|
+
} catch {
|
|
74
|
+
try { thumbs = { jpegThumbnail: await _compressedThumb(image, opts) } } catch { }
|
|
43
75
|
}
|
|
76
|
+
} else {
|
|
77
|
+
try { thumbs = { jpegThumbnail: await _compressedThumb(image, opts) } } catch { }
|
|
44
78
|
}
|
|
45
|
-
|
|
79
|
+
|
|
80
|
+
if (thumbs.jpegThumbnail) _thumbCache.set(key, thumbs)
|
|
81
|
+
return thumbs
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const _tryLinkPreview = async (url, opts) => {
|
|
46
85
|
try {
|
|
47
|
-
|
|
86
|
+
const info = await getLinkPreview(url, {
|
|
87
|
+
timeout: opts.fetchOpts?.timeout ?? TIMEOUT,
|
|
88
|
+
followRedirects: 'follow',
|
|
89
|
+
resolveDNSHost: _resolveDNS,
|
|
90
|
+
})
|
|
91
|
+
if (info?.title) return info
|
|
48
92
|
} catch (err) {
|
|
49
|
-
opts.logger?.
|
|
50
|
-
return {}
|
|
93
|
+
opts.logger?.warn({ err: err?.message || err, url }, 'getLinkPreview failed')
|
|
51
94
|
}
|
|
95
|
+
return undefined
|
|
52
96
|
}
|
|
53
97
|
|
|
54
|
-
|
|
55
|
-
* Extracts link preview info from a text string or URL.
|
|
56
|
-
* Queued for concurrency control — never throws on preview failure.
|
|
57
|
-
* @param {string} text - Raw text or URL to preview
|
|
58
|
-
* @param {object} opts - Options: thumbnailWidth, fetchOpts, uploadImage, logger
|
|
59
|
-
* @returns {Promise<object|undefined>} urlInfo or undefined if no valid URL/title found
|
|
60
|
-
*/
|
|
61
|
-
export const getUrlInfo = (text, opts = {}) => _enqueue(async () => {
|
|
62
|
-
const fetchOpts = opts.fetchOpts ?? { timeout: PREVIEW_TIMEOUT }
|
|
63
|
-
const thumbnailWidth = opts.thumbnailWidth ?? THUMBNAIL_WIDTH_PX
|
|
64
|
-
const resolvedOpts = { ...opts, fetchOpts, thumbnailWidth }
|
|
98
|
+
const _tryMicrolink = async (url, opts) => {
|
|
65
99
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const info = await getLinkPreview(previewLink, {
|
|
69
|
-
...fetchOpts,
|
|
70
|
-
followRedirects: 'manual',
|
|
71
|
-
handleRedirects: (baseURL, forwardedURL) => {
|
|
72
|
-
if (retries >= MAX_REDIRECTS) return false
|
|
73
|
-
const base = new URL(baseURL)
|
|
74
|
-
const fwd = new URL(forwardedURL)
|
|
75
|
-
const sameHost = fwd.hostname === base.hostname || fwd.hostname === 'www.' + base.hostname || 'www.' + fwd.hostname === base.hostname
|
|
76
|
-
if (sameHost) { retries++; return true }
|
|
77
|
-
return false
|
|
78
|
-
},
|
|
79
|
-
resolveDNSHost: async (url) => {
|
|
80
|
-
const hostname = new URL(url).hostname
|
|
81
|
-
try {
|
|
82
|
-
const { address } = await dnsPromises.lookup(hostname)
|
|
83
|
-
return address
|
|
84
|
-
} catch {
|
|
85
|
-
return hostname
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
headers: fetchOpts.headers
|
|
89
|
-
})
|
|
90
|
-
if (!info || !('title' in info) || !info.title) return undefined
|
|
91
|
-
const [image] = info.images ?? []
|
|
92
|
-
const thumbs = await _resolveThumbnail(image, resolvedOpts)
|
|
100
|
+
const { data } = await mql(url)
|
|
101
|
+
if (!data?.title) return undefined
|
|
93
102
|
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
...thumbs
|
|
103
|
+
url: data.url ?? url,
|
|
104
|
+
title: data.title,
|
|
105
|
+
description: data.description ?? '',
|
|
106
|
+
images: [data.image?.url].filter(Boolean),
|
|
107
|
+
mediaType: 'website',
|
|
100
108
|
}
|
|
101
109
|
} catch (err) {
|
|
102
|
-
|
|
103
|
-
if (!err.message?.includes('receive a valid') && err.code !== 'ERR_MODULE_NOT_FOUND' && err.code !== 'MODULE_NOT_FOUND') {
|
|
104
|
-
throw err
|
|
105
|
-
}
|
|
110
|
+
opts.logger?.warn({ err: err?.message || err, url }, 'microlink failed')
|
|
106
111
|
}
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const _buildResult = async (info, text, opts) => {
|
|
116
|
+
const [image] = info.images ?? []
|
|
117
|
+
return {
|
|
118
|
+
'canonical-url': info.url,
|
|
119
|
+
'matched-text': text,
|
|
120
|
+
title: info.title,
|
|
121
|
+
description: info.description,
|
|
122
|
+
originalThumbnailUrl: image,
|
|
123
|
+
previewType: _previewType(info.mediaType, image),
|
|
124
|
+
...await _resolveThumbnail(image, opts),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const getUrlInfo = (text, opts = {}) => {
|
|
129
|
+
const url = _normalize(text)
|
|
130
|
+
|
|
131
|
+
if (_negCache.has(url)) return Promise.resolve(undefined)
|
|
132
|
+
if (_previewCache.has(url)) return Promise.resolve(_previewCache.get(url))
|
|
133
|
+
if (_inflight.has(url)) return _inflight.get(url)
|
|
134
|
+
if (_inflight.size >= MAX_INFLIGHT) return Promise.resolve(undefined)
|
|
135
|
+
|
|
136
|
+
const o = {
|
|
137
|
+
fetchOpts: { timeout: TIMEOUT, ...opts.fetchOpts },
|
|
138
|
+
thumbnailWidth: opts.thumbnailWidth ?? THUMBNAIL_WIDTH,
|
|
139
|
+
uploadImage: opts.uploadImage,
|
|
140
|
+
logger: opts.logger,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const promise = _enqueue(async () => {
|
|
144
|
+
try {
|
|
145
|
+
// Primary: link-preview-js
|
|
146
|
+
let info = await _tryLinkPreview(url, o)
|
|
147
|
+
|
|
148
|
+
// Fallback: microlink (if no result or no image)
|
|
149
|
+
if (!info?.title || !(info.images ?? []).length) {
|
|
150
|
+
o.logger?.debug({ url }, 'falling back to microlink')
|
|
151
|
+
info = await _tryMicrolink(url, o) ?? info
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!info?.title) {
|
|
155
|
+
_negCache.set(url, true)
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await _buildResult(info, text, o)
|
|
160
|
+
_previewCache.set(url, result)
|
|
161
|
+
return result
|
|
162
|
+
} catch (err) {
|
|
163
|
+
_negCache.set(url, true)
|
|
164
|
+
if (!err.message?.includes('receive a valid')) throw err
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}).finally(() => _inflight.delete(url))
|
|
168
|
+
|
|
169
|
+
_inflight.set(url, promise)
|
|
170
|
+
return promise
|
|
171
|
+
}
|
|
@@ -2,8 +2,8 @@ import { Boom } from '@hapi/boom'
|
|
|
2
2
|
import { spawn } from 'child_process'
|
|
3
3
|
import * as Crypto from 'crypto'
|
|
4
4
|
import { once } from 'events'
|
|
5
|
-
import { createReadStream, createWriteStream, promises as fs } from 'fs'
|
|
6
|
-
import { tmpdir } from 'os'
|
|
5
|
+
import { createReadStream, createWriteStream, mkdirSync, promises as fs } from 'fs'
|
|
6
|
+
import { tmpdir as osTmpdir } from 'os'
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { Readable, Transform } from 'stream'
|
|
9
9
|
import { URL } from 'url'
|
|
@@ -24,6 +24,22 @@ export const getImageProcessingLibrary = async () => {
|
|
|
24
24
|
throw new Boom('No image processing library available')
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// ─── CUSTOM TMPDIR (real disk, fall back to tmpfs if cwd isnt writable (e.g, read-only Docker containers)) ─────────────────────────────────────
|
|
28
|
+
const BAILEYS_TMP_DIR = (() => {
|
|
29
|
+
const candidates = [
|
|
30
|
+
join(process.cwd(), '.b-tmp'),
|
|
31
|
+
join(osTmpdir(), 'b-tmp'),
|
|
32
|
+
]
|
|
33
|
+
for (const dir of candidates) {
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
36
|
+
return dir
|
|
37
|
+
} catch { }
|
|
38
|
+
}
|
|
39
|
+
return osTmpdir()
|
|
40
|
+
})()
|
|
41
|
+
const tmpdir = () => BAILEYS_TMP_DIR
|
|
42
|
+
|
|
27
43
|
// ─── FFMPEG ───────────────────────────────────────────────────────────────────
|
|
28
44
|
let ffmpegPathResolved = null
|
|
29
45
|
const getFfmpegPath = async () => {
|
|
@@ -42,7 +58,7 @@ export const hkdfInfoKey = (type) => `WhatsApp ${MEDIA_HKDF_KEY_MAPPING[type]} K
|
|
|
42
58
|
export const getMediaKeys = async (buffer, mediaType) => {
|
|
43
59
|
if (!buffer) throw new Boom('Cannot derive from empty media key')
|
|
44
60
|
if (typeof buffer === 'string') buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
|
|
45
|
-
const expandedMediaKey =
|
|
61
|
+
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
|
|
46
62
|
return {
|
|
47
63
|
iv: expandedMediaKey.slice(0, 16),
|
|
48
64
|
cipherKey: expandedMediaKey.slice(16, 48),
|
|
@@ -270,16 +286,18 @@ export const getStream = async (item, opts) => {
|
|
|
270
286
|
if (Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' }
|
|
271
287
|
if (item?.stream?.pipe) return { stream: item.stream, type: 'readable' }
|
|
272
288
|
if (item?.pipe) return { stream: item, type: 'readable' }
|
|
289
|
+
const isHttpUrl = (str) => { try { const u = new URL(str); return u.protocol === 'http:' || u.protocol === 'https:' } catch { return false } }
|
|
290
|
+
|
|
273
291
|
if (item && typeof item === 'object' && 'url' in item) {
|
|
274
292
|
const urlStr = item.url.toString()
|
|
275
293
|
if (Buffer.isBuffer(item.url)) return { stream: toReadable(item.url), type: 'buffer' }
|
|
276
294
|
if (urlStr.startsWith('data:')) return { stream: toReadable(Buffer.from(urlStr.split(',')[1], 'base64')), type: 'buffer' }
|
|
277
|
-
if (urlStr
|
|
295
|
+
if (isHttpUrl(urlStr)) return { stream: await getHttpStream(item.url, opts), type: 'remote' }
|
|
278
296
|
return { stream: createReadStream(item.url), type: 'file' }
|
|
279
297
|
}
|
|
280
298
|
if (typeof item === 'string') {
|
|
281
299
|
if (item.startsWith('data:')) return { stream: toReadable(Buffer.from(item.split(',')[1], 'base64')), type: 'buffer' }
|
|
282
|
-
if (item
|
|
300
|
+
if (isHttpUrl(item)) return { stream: await getHttpStream(item, opts), type: 'remote' }
|
|
283
301
|
return { stream: createReadStream(item), type: 'file' }
|
|
284
302
|
}
|
|
285
303
|
throw new Boom(`Invalid input type for getStream: ${typeof item}`, { statusCode: 400 })
|
|
@@ -340,52 +358,100 @@ export const encryptedStream = async (media, mediaType, { logger, saveOriginalFi
|
|
|
340
358
|
finalStream = (await getStream(media, opts)).stream
|
|
341
359
|
}
|
|
342
360
|
}
|
|
361
|
+
|
|
343
362
|
const mediaKey = providedMediaKey || Crypto.randomBytes(32)
|
|
344
363
|
const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType)
|
|
345
|
-
|
|
346
|
-
const encFileWriteStream = createWriteStream(encFilePath)
|
|
347
|
-
let originalFileStream, originalFilePath
|
|
348
|
-
if (saveOriginalFileIfRequired) {
|
|
349
|
-
originalFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-original')
|
|
350
|
-
originalFileStream = createWriteStream(originalFilePath)
|
|
351
|
-
}
|
|
352
|
-
let fileLength = 0
|
|
364
|
+
|
|
353
365
|
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
|
|
354
366
|
const hmac = Crypto.createHmac('sha256', macKey).update(iv)
|
|
355
367
|
const sha256Plain = Crypto.createHash('sha256')
|
|
356
368
|
const sha256Enc = Crypto.createHash('sha256')
|
|
369
|
+
const encChunks = []
|
|
370
|
+
const plainChunks = saveOriginalFileIfRequired ? [] : null
|
|
371
|
+
let fileLength = 0
|
|
372
|
+
|
|
357
373
|
try {
|
|
358
374
|
for await (const data of finalStream) {
|
|
359
375
|
fileLength += data.length
|
|
360
|
-
if (type === 'remote' && opts?.maxContentLength && fileLength > opts.maxContentLength)
|
|
361
|
-
|
|
376
|
+
if (type === 'remote' && opts?.maxContentLength && fileLength > opts.maxContentLength) {
|
|
377
|
+
throw new Boom('content length exceeded', { data: { media, type } })
|
|
378
|
+
}
|
|
379
|
+
if (plainChunks) plainChunks.push(data)
|
|
362
380
|
sha256Plain.update(data)
|
|
363
381
|
const encrypted = aes.update(data)
|
|
364
382
|
sha256Enc.update(encrypted)
|
|
365
383
|
hmac.update(encrypted)
|
|
366
|
-
|
|
384
|
+
encChunks.push(encrypted)
|
|
367
385
|
}
|
|
386
|
+
|
|
368
387
|
const finalData = aes.final()
|
|
369
388
|
sha256Enc.update(finalData)
|
|
370
389
|
hmac.update(finalData)
|
|
371
|
-
|
|
390
|
+
encChunks.push(finalData)
|
|
391
|
+
|
|
372
392
|
const mac = hmac.digest().slice(0, 10)
|
|
373
393
|
sha256Enc.update(mac)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
originalFileStream?.end?.()
|
|
394
|
+
encChunks.push(mac)
|
|
395
|
+
|
|
377
396
|
finalStream.destroy()
|
|
378
|
-
logger?.debug('encrypted data
|
|
379
|
-
|
|
397
|
+
logger?.debug('encrypted data in memory')
|
|
398
|
+
|
|
399
|
+
const encBuffer = Buffer.concat(encChunks)
|
|
400
|
+
const fileEncSha256 = sha256Enc.digest()
|
|
401
|
+
const fileSha256 = sha256Plain.digest()
|
|
402
|
+
|
|
403
|
+
let originalFilePath = null
|
|
404
|
+
if (plainChunks) {
|
|
405
|
+
try {
|
|
406
|
+
originalFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-original')
|
|
407
|
+
await fs.writeFile(originalFilePath, Buffer.concat(plainChunks))
|
|
408
|
+
logger?.debug('saved original file for processing')
|
|
409
|
+
} catch (err) {
|
|
410
|
+
logger?.warn({ err: err.message }, 'failed to save original file, bodyPath will be null')
|
|
411
|
+
originalFilePath = null
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
let encFilePath = null
|
|
415
|
+
let useMemory = false
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
encFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-enc')
|
|
419
|
+
await fs.writeFile(encFilePath, encBuffer)
|
|
420
|
+
logger?.debug('wrote enc file to disk')
|
|
421
|
+
} catch (err) {
|
|
422
|
+
logger?.warn({ err: err.message, code: err.code }, 'failed to write enc file to disk, falling back to memory upload')
|
|
423
|
+
if (encFilePath) {
|
|
424
|
+
try { await fs.unlink(encFilePath) } catch { }
|
|
425
|
+
encFilePath = null
|
|
426
|
+
}
|
|
427
|
+
useMemory = true
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const cleanup = async () => {
|
|
431
|
+
if (encFilePath) try { await fs.unlink(encFilePath) } catch { }
|
|
432
|
+
if (originalFilePath) try { await fs.unlink(originalFilePath) } catch { }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
mediaKey,
|
|
437
|
+
bodyPath: originalFilePath,
|
|
438
|
+
encFilePath,
|
|
439
|
+
encBuffer: useMemory ? encBuffer : null,
|
|
440
|
+
mac,
|
|
441
|
+
fileEncSha256,
|
|
442
|
+
fileSha256,
|
|
443
|
+
fileLength,
|
|
444
|
+
opusConverted,
|
|
445
|
+
cleanup
|
|
446
|
+
}
|
|
380
447
|
} catch (error) {
|
|
381
|
-
encFileWriteStream.destroy()
|
|
382
|
-
originalFileStream?.destroy?.()
|
|
383
448
|
aes.destroy()
|
|
384
449
|
hmac.destroy()
|
|
385
450
|
sha256Plain.destroy()
|
|
386
451
|
sha256Enc.destroy()
|
|
387
452
|
finalStream.destroy()
|
|
388
|
-
|
|
453
|
+
if (encFilePath) try { await fs.unlink(encFilePath) } catch { }
|
|
454
|
+
if (originalFilePath) try { await fs.unlink(originalFilePath) } catch { }
|
|
389
455
|
throw error
|
|
390
456
|
}
|
|
391
457
|
}
|
|
@@ -459,7 +525,12 @@ export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, opt
|
|
|
459
525
|
const toUploadBody = async (input) => {
|
|
460
526
|
if (!input) throw new Boom('Upload input is null or undefined', { statusCode: 400 })
|
|
461
527
|
if (Buffer.isBuffer(input)) return input
|
|
462
|
-
if (typeof input === 'string')
|
|
528
|
+
if (typeof input === 'string') {
|
|
529
|
+
const stream = createReadStream(input)
|
|
530
|
+
stream.on('end', () => stream.destroy())
|
|
531
|
+
stream.on('error', () => stream.destroy())
|
|
532
|
+
return stream
|
|
533
|
+
}
|
|
463
534
|
if (typeof ReadableStream !== 'undefined' && input instanceof ReadableStream) return Readable.fromWeb(input)
|
|
464
535
|
if (typeof input.pipe === 'function' || typeof input[Symbol.asyncIterator] === 'function') return input
|
|
465
536
|
throw new Boom(`Unsupported upload input type: ${Object.prototype.toString.call(input)}`, { statusCode: 400 })
|