whalibmob 5.10.8 → 5.10.12
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 +94 -31
- package/cli.js +1 -1
- package/lib/Client.js +42 -7
- package/lib/MediaThumbnail.js +163 -18
- package/lib/auth-utils.js +357 -330
- package/lib/image/Bmp.js +94 -0
- package/lib/image/Gif.js +186 -0
- package/lib/image/Jpeg.js +406 -0
- package/lib/image/JpegEncoder.js +332 -0
- package/lib/image/Png.js +205 -0
- package/lib/image/index.js +256 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -928,55 +928,65 @@ const {
|
|
|
928
928
|
|
|
929
929
|
### `makeCacheableSignalKeyStore`
|
|
930
930
|
|
|
931
|
-
Wraps a `SignalStore` with an in-memory
|
|
931
|
+
Wraps a `SignalStore` with an in-memory cache (5-minute TTL). Reads for sessions, pre-keys, signed pre-keys and identity keys are served from cache on subsequent accesses; `store*` calls write through to both, and `remove*` / `delete*` calls drop the entry.
|
|
932
|
+
|
|
933
|
+
A lookup that finds nothing is **not** cached. Absence is the state most likely to change from underneath — a session about to be built, a pre-key about to be uploaded — so a remembered miss is the one that would hurt.
|
|
932
934
|
|
|
933
935
|
`useClones` is set to `false` so that `SessionRecord` objects — which carry internal state and methods — are returned by reference and never deep-cloned.
|
|
934
936
|
|
|
935
|
-
|
|
937
|
+
Every method the underlying store has is forwarded, including `transaction()` and `isInTransaction()` when present, so the wrapper is a drop-in replacement and safe to stack with `addTransactionCapability`.
|
|
936
938
|
|
|
937
939
|
```js
|
|
938
|
-
const { SignalStore } = require('whalibmob')
|
|
939
|
-
const { makeCacheableSignalKeyStore } = require('whalibmob')
|
|
940
|
+
const { SignalStore, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
940
941
|
|
|
941
|
-
const store = new SignalStore(
|
|
942
|
+
const store = new SignalStore()
|
|
942
943
|
const cached = makeCacheableSignalKeyStore(store)
|
|
943
944
|
|
|
944
945
|
// reads hit cache after first access
|
|
945
|
-
const session = await cached.
|
|
946
|
+
const session = await cached.loadSession('919634847671.0')
|
|
946
947
|
```
|
|
947
948
|
|
|
949
|
+
Call `await cached.flushCache()` to drop everything — after a key rotation, or when another process may have written to the same session file.
|
|
950
|
+
|
|
948
951
|
**When to use:** whenever your `SignalStore` is backed by a remote or disk-based store (database, Redis, file system) and you want to reduce repeated lookups for sessions that haven't changed between sends.
|
|
949
952
|
|
|
950
953
|
### `addTransactionCapability`
|
|
951
954
|
|
|
952
|
-
Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory
|
|
955
|
+
Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory and flushed to the underlying store in one shot when the callback returns — there is no `commit()` to call. Reads check the buffer first, so a transaction sees its own writes. If the callback throws, nothing is written at all and the error reaches the caller.
|
|
953
956
|
|
|
954
|
-
Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a
|
|
957
|
+
Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a `Mutex` with reference-counting per transaction key.
|
|
955
958
|
|
|
956
959
|
```js
|
|
957
|
-
const { addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
960
|
+
const { SignalStore, addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
|
|
958
961
|
|
|
959
962
|
// recommended: cache first, then transactions on top
|
|
960
|
-
const base
|
|
961
|
-
const cached
|
|
962
|
-
const txnStore
|
|
963
|
+
const base = new SignalStore()
|
|
964
|
+
const cached = makeCacheableSignalKeyStore(base)
|
|
965
|
+
const txnStore = addTransactionCapability(cached)
|
|
963
966
|
|
|
964
967
|
// inside a send flow
|
|
965
968
|
await txnStore.transaction(async () => {
|
|
966
|
-
// all writes are buffered
|
|
967
|
-
await txnStore.
|
|
968
|
-
await txnStore.
|
|
969
|
-
|
|
970
|
-
})
|
|
969
|
+
// all writes are buffered until this callback returns
|
|
970
|
+
await txnStore.storeSession('919634847671.0', sessionRecord)
|
|
971
|
+
await txnStore.storePreKey(1, preKeyPair)
|
|
972
|
+
}, 'send')
|
|
971
973
|
```
|
|
972
974
|
|
|
973
|
-
|
|
975
|
+
The second argument is a scope: two transactions with different keys run concurrently, two with the same key run one after the other. It defaults to `'default'`. Any key is safe — the transaction locks are kept separate from the locks reads take, so no choice of key can make a transaction wait on itself.
|
|
976
|
+
|
|
977
|
+
Nested `transaction()` calls reuse the enclosing context instead of opening a second one, so an inner transaction does not commit on its own.
|
|
978
|
+
|
|
979
|
+
Stacking order matters: put `makeCacheableSignalKeyStore` below `addTransactionCapability`, so that a transaction which rolls back never reaches the cache. The other order works too — a failed transaction flushes the cache rather than leave it holding writes the store never took — but it throws away good entries to do it.
|
|
974
980
|
|
|
975
981
|
**When to use:** for high-throughput servers that send to many recipients concurrently and need to batch Signal key writes into a single atomic flush per message.
|
|
976
982
|
|
|
977
983
|
### `assertMeId`
|
|
978
984
|
|
|
979
|
-
|
|
985
|
+
Returns the account's JID, or throws an `Error` describing what is missing.
|
|
986
|
+
|
|
987
|
+
Works with both kinds of store — the SMS store from `initAuthCreds` / `createNewStore`, and the companion store from `createNewWebStore`. Only the wording of the error differs, since the way out of "not registered yet" is SMS verification in one case and pairing in the other.
|
|
988
|
+
|
|
989
|
+
Once registered, the JID the server assigned is preferred (`store.me.id`) — on a companion it carries the device suffix, and rebuilding it from the phone number drops that silently. Before registration it throws, including during the pairing window: requesting a pairing code writes a placeholder `me` with no suffix, and that is not treated as being linked.
|
|
980
990
|
|
|
981
991
|
```js
|
|
982
992
|
const { assertMeId } = require('whalibmob')
|
|
@@ -985,7 +995,8 @@ const store = loadStore(sessFile)
|
|
|
985
995
|
|
|
986
996
|
try {
|
|
987
997
|
const jid = assertMeId(store)
|
|
988
|
-
//
|
|
998
|
+
// '919634847671:12@s.whatsapp.net' once connected,
|
|
999
|
+
// '919634847671@s.whatsapp.net' before that
|
|
989
1000
|
console.log('account JID:', jid)
|
|
990
1001
|
} catch (err) {
|
|
991
1002
|
console.error('store is not registered:', err.message)
|
|
@@ -996,7 +1007,9 @@ try {
|
|
|
996
1007
|
|
|
997
1008
|
### `initAuthCreds`
|
|
998
1009
|
|
|
999
|
-
Creates a fresh credential store for the given phone number
|
|
1010
|
+
Creates a fresh credential store for the given phone number — the key pairs, registration ID and device identifiers a number needs before it can even ask for an SMS code. This is what `/reg code` calls.
|
|
1011
|
+
|
|
1012
|
+
It returns everything `createNewStore` does, plus a few fields kept for application code that expects them: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, `processedHistoryMessages` and `advSecretKey`. Those extras live on the object only — `saveStore` does not write them, so they are not there again after a reload. Nothing in the library reads them; treat them as a convenience, not as state.
|
|
1000
1013
|
|
|
1001
1014
|
```js
|
|
1002
1015
|
const { initAuthCreds, saveStore } = require('whalibmob')
|
|
@@ -1013,10 +1026,35 @@ const store = initAuthCreds(phone)
|
|
|
1013
1026
|
saveStore(store, sessFile)
|
|
1014
1027
|
```
|
|
1015
1028
|
|
|
1016
|
-
This is the function
|
|
1029
|
+
This is the function the CLI uses for every new SMS session. Prefer it over `createNewStore` for forward compatibility.
|
|
1017
1030
|
|
|
1018
1031
|
> [!NOTE]
|
|
1019
|
-
> `initAuthCreds` and `createNewStore` produce equivalent stores for
|
|
1032
|
+
> `initAuthCreds` and `createNewStore` produce equivalent stores for every current library operation, and identical files on disk. Use `createNewWebStore` instead when the device will be linked as a companion rather than registered by SMS — that one adds the pairing fields, and its own serialiser persists them.
|
|
1033
|
+
|
|
1034
|
+
The file it writes has exactly these 16 keys:
|
|
1035
|
+
|
|
1036
|
+
```json
|
|
1037
|
+
{
|
|
1038
|
+
"phoneNumber": "40712345678",
|
|
1039
|
+
"noiseKeyPair": { "private": "…", "public": "…" },
|
|
1040
|
+
"identityKeyPair": { "private": "…", "public": "…" },
|
|
1041
|
+
"signedPreKey": { "id": 3649616, "private": "…", "public": "…", "signature": "…" },
|
|
1042
|
+
"registrationId": 2183,
|
|
1043
|
+
"fdid": "2f701f8b-d693-4728-…",
|
|
1044
|
+
"deviceId": "CBIyyJRAokOdYoTYEjTbng==",
|
|
1045
|
+
"identityId": "SE8Q785oful7dGdBgNpwjg==",
|
|
1046
|
+
"advertisingId": "5c97eea9-783f-4fcc-…",
|
|
1047
|
+
"backupToken": "…",
|
|
1048
|
+
"registered": true,
|
|
1049
|
+
"codePending": false,
|
|
1050
|
+
"name": "Boss",
|
|
1051
|
+
"version": "2.26.9.75",
|
|
1052
|
+
"device": { "os": "ios", "platform": 1, "model": "iPhone 15 Pro", "…": "…" },
|
|
1053
|
+
"advIdentity": "…"
|
|
1054
|
+
}
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
`registered` and `codePending` are the two that move: both `false` when the store is created, `codePending` flips to `true` once a code has been requested, and `verifyCode` sets `registered` to `true` and `codePending` back to `false`. `advIdentity` stays `null` until the server sends the signed device identity in `<success>`.
|
|
1020
1058
|
|
|
1021
1059
|
### Recommended Stacking Pattern
|
|
1022
1060
|
|
|
@@ -1036,7 +1074,8 @@ const {
|
|
|
1036
1074
|
let store = loadStore(sessFile) || initAuthCreds(phone)
|
|
1037
1075
|
|
|
1038
1076
|
// 2. build the layered Signal key store
|
|
1039
|
-
const signalStore = new SignalStore(
|
|
1077
|
+
const signalStore = new SignalStore()
|
|
1078
|
+
signalStore.attachFile(sessFile)
|
|
1040
1079
|
const cachedStore = makeCacheableSignalKeyStore(signalStore)
|
|
1041
1080
|
const txnStore = addTransactionCapability(cachedStore)
|
|
1042
1081
|
|
|
@@ -1791,9 +1830,11 @@ Photos and videos are sent with an inline preview so they show up in the chat
|
|
|
1791
1830
|
directly, rather than as a placeholder the recipient has to tap. The dimensions
|
|
1792
1831
|
are read out of the file header with no dependency at all. The preview itself
|
|
1793
1832
|
uses `jimp` (installed automatically as an optional dependency) or `sharp` if
|
|
1794
|
-
you have it; failing both, a photo that carries an EXIF thumbnail
|
|
1795
|
-
|
|
1796
|
-
|
|
1833
|
+
you have it; failing both, a photo that carries an EXIF thumbnail gets one for
|
|
1834
|
+
free, and anything else is scaled in process — JPEG, PNG, GIF and BMP all work
|
|
1835
|
+
with nothing installed. Video previews need `ffmpeg` on PATH — on Termux,
|
|
1836
|
+
`pkg install ffmpeg`. Without any of these the media still sends, just without
|
|
1837
|
+
the preview.
|
|
1797
1838
|
|
|
1798
1839
|
### Image Message
|
|
1799
1840
|
|
|
@@ -2180,8 +2221,25 @@ rather than refused — so an unprepared file fails as a timeout that looks like
|
|
|
2180
2221
|
network fault. The image is cropped square, scaled and written out as a fresh
|
|
2181
2222
|
JPEG, which also strips EXIF and any progressive encoding the file was carrying.
|
|
2182
2223
|
|
|
2183
|
-
|
|
2184
|
-
|
|
2224
|
+
**No image library is required.** JPEG, PNG, GIF and BMP are decoded, cropped
|
|
2225
|
+
and re-encoded in process, so a bare `npm install` on a phone or a container
|
|
2226
|
+
built without a compiler converts a picture just as well as a full desktop.
|
|
2227
|
+
Whatever is installed is preferred, and the fallbacks run in this order:
|
|
2228
|
+
|
|
2229
|
+
| | Handles | Needs |
|
|
2230
|
+
|---|---|---|
|
|
2231
|
+
| `sharp` or `jimp` | everything, best quality | either installed |
|
|
2232
|
+
| built in | JPEG, PNG, GIF, BMP | nothing |
|
|
2233
|
+
| `ffmpeg` | WebP, HEIC, progressive JPEG | `ffmpeg` on PATH |
|
|
2234
|
+
| metadata strip | an already-square JPEG | nothing |
|
|
2235
|
+
|
|
2236
|
+
The built-in converter also reads the EXIF orientation tag, so a photo taken in
|
|
2237
|
+
portrait is turned upright instead of arriving on its side, and flattens
|
|
2238
|
+
transparency onto white, since a JPEG has no alpha channel.
|
|
2239
|
+
|
|
2240
|
+
Only an exotic format with nothing installed — a WebP or a HEIC on a machine
|
|
2241
|
+
with no `ffmpeg` — still fails, and the error says so rather than reporting a
|
|
2242
|
+
bare `406`.
|
|
2185
2243
|
|
|
2186
2244
|
```js
|
|
2187
2245
|
// skip the re-encode if you have prepared the image yourself
|
|
@@ -3133,11 +3191,16 @@ about updated
|
|
|
3133
3191
|
|
|
3134
3192
|
#### CLI Change Profile Picture
|
|
3135
3193
|
|
|
3136
|
-
Reads the image from disk
|
|
3194
|
+
Reads the image from disk, crops it square, scales it to 640×640 and uploads it
|
|
3195
|
+
as your profile picture. JPEG, PNG, GIF and BMP need nothing installed; WebP and
|
|
3196
|
+
HEIC need `ffmpeg` or `jimp`.
|
|
3137
3197
|
|
|
3138
3198
|
```sh
|
|
3139
3199
|
wa> /photo ./avatar.jpg
|
|
3140
|
-
profile picture updated
|
|
3200
|
+
profile picture updated id=1753912045
|
|
3201
|
+
|
|
3202
|
+
wa> /photo remove
|
|
3203
|
+
profile picture removed
|
|
3141
3204
|
```
|
|
3142
3205
|
|
|
3143
3206
|
#### CLI Change Privacy Settings
|
package/cli.js
CHANGED
|
@@ -447,7 +447,7 @@ const HELP = `
|
|
|
447
447
|
Profile
|
|
448
448
|
/name <text> change display name
|
|
449
449
|
/about <text> change own bio / about text
|
|
450
|
-
/photo <file>|remove change or remove own profile picture (
|
|
450
|
+
/photo <file>|remove change or remove own profile picture (any image)
|
|
451
451
|
/privacy [<type> <value>] show or change privacy settings
|
|
452
452
|
types: last_seen profile_picture status
|
|
453
453
|
online read_receipts groups_add
|
package/lib/Client.js
CHANGED
|
@@ -10,7 +10,7 @@ const { NoiseSocket } = require('./noise');
|
|
|
10
10
|
const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
|
|
11
11
|
const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
|
|
12
12
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
13
|
-
const { prepareProfilePicture } = require('./MediaThumbnail');
|
|
13
|
+
const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
|
|
14
14
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
15
15
|
const { BinaryNode } = require('./BinaryNode');
|
|
16
16
|
const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
|
|
@@ -252,6 +252,38 @@ function describeNodeBriefly(node, depth) {
|
|
|
252
252
|
return out + '/>';
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// Say why a picture was turned down, from what can be read off the file.
|
|
256
|
+
//
|
|
257
|
+
// "not acceptable" is all the server offers, so the useful part is what we can
|
|
258
|
+
// work out ourselves: what the image is, whether it is square, and whether
|
|
259
|
+
// anything was available to fix it before it went out.
|
|
260
|
+
function _pictureRejected(what, picture, original, opts) {
|
|
261
|
+
const size = picture ? probeImageSize(picture) : null;
|
|
262
|
+
const bits = ['the server would not take this image (406 not-acceptable)'];
|
|
263
|
+
|
|
264
|
+
if (size && size.width && size.height) {
|
|
265
|
+
bits.push('it went out as ' + size.width + 'x' + size.height +
|
|
266
|
+
(size.width === size.height ? '' : ', which is not square'));
|
|
267
|
+
}
|
|
268
|
+
const isJpeg = picture && picture[0] === 0xff && picture[1] === 0xd8;
|
|
269
|
+
if (picture && !isJpeg) bits.push('and it is not a JPEG');
|
|
270
|
+
|
|
271
|
+
if (opts && opts.raw) {
|
|
272
|
+
bits.push('this call passed { raw: true }, so the file was sent exactly as given');
|
|
273
|
+
} else if (!isJpeg && original && !canDecodeImage(original)) {
|
|
274
|
+
// WebP, HEIC and the like need an outside converter; everything ordinary
|
|
275
|
+
// is handled without one.
|
|
276
|
+
bits.push('this format could not be converted here — install ffmpeg, or ' +
|
|
277
|
+
'save the picture as a JPEG or PNG first');
|
|
278
|
+
} else {
|
|
279
|
+
bits.push('the picture was cropped square and re-encoded before sending, ' +
|
|
280
|
+
'so the file itself is unlikely to be the problem — the account may not ' +
|
|
281
|
+
'be allowed to set one');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return what + ': ' + bits.join('. ') + '.';
|
|
285
|
+
}
|
|
286
|
+
|
|
255
287
|
function getNodeContent(node) {
|
|
256
288
|
if (!node) return null;
|
|
257
289
|
if (Buffer.isBuffer(node.content)) return node.content;
|
|
@@ -4365,15 +4397,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4365
4397
|
'The server drops a picture it will not take rather than refusing it, so ' +
|
|
4366
4398
|
'this usually means the image was not in the form it wants' +
|
|
4367
4399
|
(opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
|
|
4368
|
-
: '
|
|
4369
|
-
're-encoded before sending') + '.');
|
|
4400
|
+
: '') + '.');
|
|
4370
4401
|
}
|
|
4371
4402
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
4372
4403
|
const errNode = findChild(resp, 'error');
|
|
4373
4404
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
4405
|
+
if (String(code) === '406') {
|
|
4406
|
+
throw new Error(_pictureRejected('changeProfilePicture', picture, buf, opts));
|
|
4407
|
+
}
|
|
4374
4408
|
throw new Error('changeProfilePicture: ' +
|
|
4375
|
-
(String(code) === '
|
|
4376
|
-
: String(code) === '401' ? 'not authorised to change this picture'
|
|
4409
|
+
(String(code) === '401' ? 'not authorised to change this picture'
|
|
4377
4410
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
4378
4411
|
}
|
|
4379
4412
|
|
|
@@ -5960,9 +5993,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5960
5993
|
if (resp.attrs && resp.attrs.type === 'error') {
|
|
5961
5994
|
const errNode = findChild(resp, 'error');
|
|
5962
5995
|
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
5996
|
+
if (String(code) === '406') {
|
|
5997
|
+
throw new Error(_pictureRejected('changeGroupPicture', picture, buf, opts));
|
|
5998
|
+
}
|
|
5963
5999
|
throw new Error('changeGroupPicture: ' +
|
|
5964
|
-
(String(code) === '
|
|
5965
|
-
: String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
6000
|
+
(String(code) === '403' ? 'you are not allowed to change this group picture'
|
|
5966
6001
|
: 'server rejected the request' + (code ? ' (' + code + ')' : '')));
|
|
5967
6002
|
}
|
|
5968
6003
|
if (!buf) return 'remove';
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// GIF and BMP — with no library at all.
|
|
16
16
|
// • The thumbnail is made with sharp or jimp when either is installed. When
|
|
17
17
|
// neither is, a JPEG that carries an EXIF thumbnail (which is to say, most
|
|
18
|
-
// photos taken on a phone)
|
|
18
|
+
// photos taken on a phone) yields one for free, and anything else goes
|
|
19
|
+
// through the decoder in lib/image — still with nothing installed.
|
|
19
20
|
// • Video frames need ffmpeg. Without it the video still sends, just without
|
|
20
21
|
// an inline preview.
|
|
21
22
|
//
|
|
@@ -30,6 +31,8 @@ const path = require('path');
|
|
|
30
31
|
const crypto = require('crypto');
|
|
31
32
|
const { execFile } = require('child_process');
|
|
32
33
|
|
|
34
|
+
const { toSquareJpeg, toScaledJpeg, canDecode: canDecodeImage } = require('./image');
|
|
35
|
+
|
|
33
36
|
// The preview is deliberately tiny; it is only ever shown blurred behind the
|
|
34
37
|
// real image while that downloads.
|
|
35
38
|
const THUMB_WIDTH = 32;
|
|
@@ -220,8 +223,8 @@ async function loadImageLib() {
|
|
|
220
223
|
}
|
|
221
224
|
} catch (_) {}
|
|
222
225
|
|
|
223
|
-
_whaDbg('[DBG] THUMB no image library —
|
|
224
|
-
'
|
|
226
|
+
_whaDbg('[DBG] THUMB no image library — falling back to the built-in ' +
|
|
227
|
+
'decoder, which reads JPEG, PNG, GIF and BMP');
|
|
225
228
|
return _imageLib;
|
|
226
229
|
}
|
|
227
230
|
|
|
@@ -277,16 +280,32 @@ async function imageThumbnail(buf, opts) {
|
|
|
277
280
|
_whaDbg('[DBG] THUMB image library failed: ' + (err && err.message));
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
// No library, or it could not read this file
|
|
281
|
-
//
|
|
283
|
+
// No library, or it could not read this file. The camera's own embedded
|
|
284
|
+
// preview is the cheapest thing left, and on a phone photo it is usually
|
|
285
|
+
// there — a few kilobytes already sized about right.
|
|
282
286
|
const exif = extractExifThumbnail(buf);
|
|
283
287
|
if (exif) {
|
|
284
288
|
out.thumbnail = exif;
|
|
285
289
|
_whaDbg('[DBG] THUMB using embedded EXIF thumbnail (' + exif.length + ' bytes)');
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Otherwise scale it here. Slower than a library, but a preview is 32 pixels
|
|
294
|
+
// wide and this is the difference between the picture showing in the chat and
|
|
295
|
+
// a grey download placeholder.
|
|
296
|
+
try {
|
|
297
|
+
const built = toScaledJpeg(buf, width, quality);
|
|
298
|
+
if (built && built.length) {
|
|
299
|
+
out.thumbnail = built;
|
|
300
|
+
_whaDbg('[DBG] THUMB made in-process (' + built.length + ' bytes)');
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
_whaDbg('[DBG] THUMB built-in scaler failed: ' + (err && err.message));
|
|
289
305
|
}
|
|
306
|
+
|
|
307
|
+
_whaDbg('[DBG] THUMB none available — the image will show as a download ' +
|
|
308
|
+
'placeholder; install jimp to fix');
|
|
290
309
|
return out;
|
|
291
310
|
}
|
|
292
311
|
|
|
@@ -301,13 +320,22 @@ const AVATAR_QUALITY = 50;
|
|
|
301
320
|
/**
|
|
302
321
|
* Re-encode an image into the form a profile picture has to be in.
|
|
303
322
|
*
|
|
304
|
-
* Cropped square
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
323
|
+
* Cropped square, scaled to 640x640, and written out as a fresh JPEG.
|
|
324
|
+
* Re-encoding is the point rather than a side effect: it strips EXIF,
|
|
325
|
+
* progressive scans and unusual chroma subsampling — anything the file happened
|
|
326
|
+
* to carry — and produces a plain baseline JPEG.
|
|
308
327
|
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
328
|
+
* Four converters are tried in turn, so a picture goes through whatever the
|
|
329
|
+
* machine happens to have:
|
|
330
|
+
*
|
|
331
|
+
* 1. sharp or jimp, when one is installed — the best quality and the widest
|
|
332
|
+
* range of formats.
|
|
333
|
+
* 2. the built-in converter, which reads JPEG, PNG, GIF and BMP with no
|
|
334
|
+
* dependency at all. This is the one that runs on a bare install.
|
|
335
|
+
* 3. ffmpeg, which covers what the built-in one does not: WebP, HEIC,
|
|
336
|
+
* progressive JPEG.
|
|
337
|
+
* 4. failing all of that, the file with its metadata segments stripped, which
|
|
338
|
+
* at least gives an already-square JPEG a chance.
|
|
311
339
|
*/
|
|
312
340
|
async function prepareProfilePicture(buf, opts) {
|
|
313
341
|
opts = opts || {};
|
|
@@ -345,15 +373,130 @@ async function prepareProfilePicture(buf, opts) {
|
|
|
345
373
|
return await img.getBufferAsync(Jimp.MIME_JPEG);
|
|
346
374
|
}
|
|
347
375
|
} catch (err) {
|
|
348
|
-
_whaDbg('[DBG] AVATAR
|
|
349
|
-
|
|
376
|
+
_whaDbg('[DBG] AVATAR image library failed: ' + (err && err.message));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// No image library, or it could not read this file. The built-in converter
|
|
380
|
+
// needs nothing installed and handles the formats a picture is usually in.
|
|
381
|
+
try {
|
|
382
|
+
const built = toSquareJpeg(buf, size, quality);
|
|
383
|
+
if (built && built.length) {
|
|
384
|
+
_whaDbg('[DBG] AVATAR converted in-process to ' + size + 'x' + size +
|
|
385
|
+
' (' + buf.length + ' → ' + built.length + ' bytes)');
|
|
386
|
+
return built;
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
_whaDbg('[DBG] AVATAR built-in converter failed: ' + (err && err.message));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A format the built-in decoder does not read — WebP, HEIC, a progressive
|
|
393
|
+
// JPEG. ffmpeg can do the same job and is already used by the video path, so
|
|
394
|
+
// try it before giving up.
|
|
395
|
+
const viaFfmpeg = await avatarViaFfmpeg(buf, size, quality);
|
|
396
|
+
if (viaFfmpeg) return viaFfmpeg;
|
|
397
|
+
|
|
398
|
+
// Nothing that can re-encode. Strip the metadata segments at least: EXIF and
|
|
399
|
+
// the other APPn blocks are the part of an ordinary camera JPEG the server
|
|
400
|
+
// most often will not take, and removing them needs no decoder — the image
|
|
401
|
+
// data is left exactly as it was.
|
|
402
|
+
const stripped = stripJpegMetadata(buf);
|
|
403
|
+
if (stripped !== buf) {
|
|
404
|
+
_whaDbg('[DBG] AVATAR no converter — sent with metadata stripped (' +
|
|
405
|
+
buf.length + ' → ' + stripped.length + ' bytes). Install jimp or ffmpeg ' +
|
|
406
|
+
'to have the image resized properly.');
|
|
407
|
+
return stripped;
|
|
350
408
|
}
|
|
351
409
|
|
|
352
|
-
_whaDbg('[DBG] AVATAR
|
|
353
|
-
'WhatsApp wants a square JPEG;
|
|
410
|
+
_whaDbg('[DBG] AVATAR nothing could read this file — sending it as it is. ' +
|
|
411
|
+
'WhatsApp wants a square JPEG; JPEG, PNG, GIF and BMP are converted here ' +
|
|
412
|
+
'on their own, and ffmpeg or jimp covers the rest.');
|
|
354
413
|
return buf;
|
|
355
414
|
}
|
|
356
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Crop square and resize with ffmpeg, which needs no native npm module.
|
|
418
|
+
*
|
|
419
|
+
* Returns null when ffmpeg is not installed or cannot read the file, so the
|
|
420
|
+
* caller can carry on to the next fallback.
|
|
421
|
+
*/
|
|
422
|
+
async function avatarViaFfmpeg(buf, size, quality) {
|
|
423
|
+
if (!(await hasFfmpeg())) return null;
|
|
424
|
+
|
|
425
|
+
const src = tmpFile('.img');
|
|
426
|
+
const dst = tmpFile('.jpg');
|
|
427
|
+
try {
|
|
428
|
+
fs.writeFileSync(src, buf);
|
|
429
|
+
// Crop to the shorter side, scale, and write baseline JPEG. -q:v runs 2..31
|
|
430
|
+
// worst-to-best in ffmpeg, the opposite way round to a percentage quality.
|
|
431
|
+
const q = Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
|
|
432
|
+
const ok = await run('ffmpeg', [
|
|
433
|
+
'-y', '-i', src,
|
|
434
|
+
'-vf', "crop='min(iw,ih)':'min(iw,ih)',scale=" + size + ':' + size,
|
|
435
|
+
'-q:v', String(q),
|
|
436
|
+
'-f', 'mjpeg',
|
|
437
|
+
dst
|
|
438
|
+
]);
|
|
439
|
+
if (ok === null) return null;
|
|
440
|
+
const out = fs.readFileSync(dst);
|
|
441
|
+
if (!out.length || out[0] !== 0xff || out[1] !== 0xd8) return null;
|
|
442
|
+
_whaDbg('[DBG] AVATAR re-encoded with ffmpeg (' + out.length + ' bytes)');
|
|
443
|
+
return out;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
_whaDbg('[DBG] AVATAR ffmpeg failed: ' + (err && err.message));
|
|
446
|
+
return null;
|
|
447
|
+
} finally {
|
|
448
|
+
try { fs.unlinkSync(src); } catch (_) {}
|
|
449
|
+
try { fs.unlinkSync(dst); } catch (_) {}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Drop every APPn segment from a JPEG, keeping the image itself byte for byte.
|
|
455
|
+
*
|
|
456
|
+
* EXIF, ICC profiles, thumbnails and camera maker notes all live in these, and
|
|
457
|
+
* a picture that carries them is the ordinary case a phone produces. Removing
|
|
458
|
+
* them takes no decoder — it is a walk over the segment headers — so it works
|
|
459
|
+
* where nothing else is installed.
|
|
460
|
+
*
|
|
461
|
+
* Returns the input unchanged when it is not a JPEG or has nothing to drop.
|
|
462
|
+
*/
|
|
463
|
+
function stripJpegMetadata(buf) {
|
|
464
|
+
if (!Buffer.isBuffer(buf) || buf.length < 4) return buf;
|
|
465
|
+
if (buf[0] !== 0xff || buf[1] !== 0xd8) return buf; // not a JPEG
|
|
466
|
+
|
|
467
|
+
const keep = [buf.slice(0, 2)];
|
|
468
|
+
let i = 2, dropped = 0;
|
|
469
|
+
|
|
470
|
+
while (i + 4 <= buf.length) {
|
|
471
|
+
if (buf[i] !== 0xff) break; // out of step; leave the rest be
|
|
472
|
+
const marker = buf[i + 1];
|
|
473
|
+
|
|
474
|
+
// Start of scan — everything from here is entropy-coded image data.
|
|
475
|
+
if (marker === 0xda) { keep.push(buf.slice(i)); i = buf.length; break; }
|
|
476
|
+
// Standalone markers carry no length.
|
|
477
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
478
|
+
keep.push(buf.slice(i, i + 2)); i += 2; continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const len = buf.readUInt16BE(i + 2);
|
|
482
|
+
if (len < 2 || i + 2 + len > buf.length) break; // malformed; stop here
|
|
483
|
+
const seg = buf.slice(i, i + 2 + len);
|
|
484
|
+
|
|
485
|
+
// APP0 is the JFIF header, which is the one to keep; APP1..APPF and the
|
|
486
|
+
// comment segment are metadata.
|
|
487
|
+
const isAppN = marker >= 0xe1 && marker <= 0xef;
|
|
488
|
+
const isComment = marker === 0xfe;
|
|
489
|
+
if (isAppN || isComment) dropped += seg.length;
|
|
490
|
+
else keep.push(seg);
|
|
491
|
+
|
|
492
|
+
i += 2 + len;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!dropped) return buf;
|
|
496
|
+
if (i < buf.length) keep.push(buf.slice(i));
|
|
497
|
+
return Buffer.concat(keep);
|
|
498
|
+
}
|
|
499
|
+
|
|
357
500
|
// ─── ffmpeg ──────────────────────────────────────────────────────────────────
|
|
358
501
|
|
|
359
502
|
function run(cmd, args) {
|
|
@@ -450,6 +593,8 @@ module.exports = {
|
|
|
450
593
|
extractExifThumbnail,
|
|
451
594
|
imageThumbnail,
|
|
452
595
|
prepareProfilePicture,
|
|
596
|
+
stripJpegMetadata,
|
|
597
|
+
canDecodeImage,
|
|
453
598
|
videoThumbnail,
|
|
454
599
|
hasFfmpeg,
|
|
455
600
|
THUMB_WIDTH,
|