@aunucore/baileys 1.0.83 → 1.0.85

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.
@@ -1,4 +1,3 @@
1
- import NodeCache from '@cacheable/node-cache';
2
1
  import { Boom } from '@hapi/boom';
3
2
  import { proto } from '../../WAProto/index.js';
4
3
  import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults/index.js';
@@ -6,6 +5,7 @@ import { ALL_WA_PATCH_NAMES } from '../Types/index.js';
6
5
  import { SyncState } from '../Types/State.js';
7
6
  import { chatModificationToAppPatch, decodePatches, decodeSyncdSnapshot, encodeSyncdPatch, extractSyncdPatches, generateProfilePicture, getHistoryMsg, newLTHashState, processSyncAction } from '../Utils/index.js';
8
7
  import { makeMutex } from '../Utils/make-mutex.js';
8
+ import { makeBoundedTTLCache } from '../Utils/bounded-cache.js';
9
9
  import processMessage from '../Utils/process-message.js';
10
10
  import { getBinaryNodeChild, getBinaryNodeChildren, isJidUser, isLidUser, jidDecode, jidNormalizedUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary/index.js';
11
11
  import { USyncQuery, USyncUser } from '../WAUSync/index.js';
@@ -24,10 +24,16 @@ export const makeChatsSocket = (config) => {
24
24
  // Timeout for AwaitingInitialSync state
25
25
  let awaitingSyncTimeout;
26
26
  const placeholderResendCache = config.placeholderResendCache ||
27
- new NodeCache({
28
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
29
- useClones: false
30
- });
27
+ // AunuBaileys (1.0.84, HB-231): in the standard
28
+ // makeWASocket composition this factory runs BEFORE
29
+ // messages-recv creates its own default, and the
30
+ // write-back below means the instance created HERE is
31
+ // the one the whole receive path actually uses. It used
32
+ // to be a NodeCache with no size cap, silently shadowing
33
+ // the bounded default messages-recv intended ("bounded
34
+ // by default — a TTL alone lets these grow without
35
+ // limit"). Build the same bounded cache here.
36
+ makeBoundedTTLCache({ ttlSeconds: DEFAULT_CACHE_TTLS.MSG_RETRY, maxEntries: 1000 });
31
37
  if (!config.placeholderResendCache) {
32
38
  config.placeholderResendCache = placeholderResendCache;
33
39
  }
@@ -1,7 +1,7 @@
1
1
  // AunuCore fork backport: in-memory store dari Baileys 6.6.0, dikonversi ke ESM untuk basis 6.7.x
2
2
  import KeyedDBModule from '@adiwajshing/keyed-db';
3
3
  const KeyedDB = KeyedDBModule.default || KeyedDBModule;
4
- import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
5
5
  import { proto } from '../../WAProto/index.js';
6
6
  import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
7
7
  import { LabelAssociationType } from '../Types/LabelAssociation.js';
@@ -131,6 +131,16 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
131
131
  for (const update of updates) {
132
132
  if (contacts[update.id]) {
133
133
  Object.assign(contacts[update.id], update);
134
+ // AunuBaileys (1.0.84, HB-245): a picture
135
+ // notification arrives as imgUrl 'changed' /
136
+ // 'removed' — a sentinel, not a URL. Drop the
137
+ // cached URL instead of storing the sentinel:
138
+ // fetchImageUrl used to hand the literal string
139
+ // 'changed' to the host as the avatar URL, and
140
+ // writeToFile persisted the poison to disk.
141
+ if (update.imgUrl === 'changed' || update.imgUrl === 'removed') {
142
+ delete contacts[update.id].imgUrl;
143
+ }
134
144
  }
135
145
  else {
136
146
  logger.debug({ update }, 'got update for non-existant contact');
@@ -258,7 +268,18 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
258
268
  if (metadata) {
259
269
  switch (action) {
260
270
  case 'add':
261
- metadata.participants.push(...participants.map(id => ({ id, isAdmin: false, isSuperAdmin: false })));
271
+ // AunuBaileys (1.0.84, HB-237): skip ids
272
+ // that are already listed. The metadata is
273
+ // usually populated by a fetch that already
274
+ // contains the member, and a redelivery
275
+ // replays the notification — both used to
276
+ // push a duplicate entry every single time,
277
+ // without bound.
278
+ for (const participantId of participants) {
279
+ if (!metadata.participants.some(p => p.id === participantId)) {
280
+ metadata.participants.push({ id: participantId, isAdmin: false, isSuperAdmin: false });
281
+ }
282
+ }
262
283
  break;
263
284
  case 'demote':
264
285
  case 'promote':
@@ -301,14 +322,43 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
301
322
  labelAssociations
302
323
  });
303
324
  const fromJSON = (json) => {
304
- chats.upsert(...json.chats);
305
- labelAssociations.upsert(...json.labelAssociations || []);
306
- contactsUpsert(Object.values(json.contacts));
307
- labelsUpsert(Object.values(json.labels || {}));
308
- for (const jid in json.messages) {
325
+ // AunuBaileys (1.0.84, HB-134 + HB-142 + addenda): every
326
+ // stage — and every entry — is isolated. One poisoned
327
+ // message entry used to throw out of fromJSON mid-loop:
328
+ // that jid's remaining messages and EVERY later jid were
329
+ // silently lost on every boot, while the stages before
330
+ // the throw stayed applied and readFromFile logged
331
+ // "starting with an empty store" — a claim the state did
332
+ // not match. A missing/null contacts stage threw the
333
+ // same way (Object.values of undefined), and a poisoned
334
+ // contacts/labels entry aborted every stage after it.
335
+ // Wrong-typed stages now read as empty, unusable entries
336
+ // are skipped, and a bad message entry is caught per
337
+ // entry with a warning — the healthy remainder of the
338
+ // file always loads.
339
+ if (!json || typeof json !== 'object') {
340
+ return;
341
+ }
342
+ const entriesOf = (value) => (Array.isArray(value) ? value : []).filter(e => !!e && typeof e === 'object');
343
+ const valuesOf = (value) => (value && typeof value === 'object' ? Object.values(value) : []);
344
+ chats.upsert(...entriesOf(json.chats));
345
+ labelAssociations.upsert(...entriesOf(json.labelAssociations));
346
+ contactsUpsert(valuesOf(json.contacts).filter(c => !!c && typeof c === 'object' && typeof c.id === 'string'));
347
+ labelsUpsert(valuesOf(json.labels).filter(l => !!l && typeof l === 'object' && typeof l.id === 'string'));
348
+ const messageSets = json.messages && typeof json.messages === 'object' ? json.messages : {};
349
+ for (const jid in messageSets) {
350
+ const entries = messageSets[jid];
351
+ if (!Array.isArray(entries)) {
352
+ continue;
353
+ }
309
354
  const list = assertMessageList(jid);
310
- for (const msg of json.messages[jid]) {
311
- list.upsert(proto.WebMessageInfo.fromObject(msg), 'append');
355
+ for (const msg of entries) {
356
+ try {
357
+ list.upsert(proto.WebMessageInfo.fromObject(msg), 'append');
358
+ }
359
+ catch (err) {
360
+ logger.warn({ jid, err }, 'aunu: skipped an unreadable message entry while loading the store');
361
+ }
312
362
  }
313
363
  }
314
364
  };
@@ -329,6 +379,16 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
329
379
  const cursorKey = !!cursor ? ('before' in cursor ? cursor.before : cursor.after) : undefined;
330
380
  const cursorValue = cursorKey ? list.get(cursorKey.id) : undefined;
331
381
  let messages;
382
+ // AunuBaileys (1.0.84, HB-177): a count of 0 means
383
+ // "no messages" — the slicing below used
384
+ // slice(-count), and slice(-0) is slice(0), so
385
+ // loadMessages(jid, 0) returned the chat's ENTIRE
386
+ // history (and negative counts mis-sliced as well).
387
+ // Every other path in this function already resolves
388
+ // to []. An absent count keeps the legacy behaviour.
389
+ if (typeof count === 'number' && count <= 0) {
390
+ return [];
391
+ }
332
392
  if (list && mode === 'before' && (!cursorKey || cursorValue)) {
333
393
  if (cursorValue) {
334
394
  const msgIdx = list.array.findIndex(m => m.key.id === (cursorKey === null || cursorKey === void 0 ? void 0 : cursorKey.id));
@@ -386,7 +446,13 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
386
446
  if (!contact) {
387
447
  return sock === null || sock === void 0 ? void 0 : sock.profilePictureUrl(jid);
388
448
  }
389
- if (typeof contact.imgUrl === 'undefined') {
449
+ // AunuBaileys (1.0.84, HB-245): a store file written
450
+ // before this fix can still carry the 'changed' /
451
+ // 'removed' sentinel as imgUrl — treat it as no
452
+ // cached URL and fetch fresh; never hand a sentinel
453
+ // back as an avatar URL. A real cached URL is still
454
+ // returned as-is (and a fresh fetch re-caches it).
455
+ if (typeof contact.imgUrl === 'undefined' || contact.imgUrl === 'changed' || contact.imgUrl === 'removed') {
390
456
  contact.imgUrl = await (sock === null || sock === void 0 ? void 0 : sock.profilePictureUrl(jid));
391
457
  }
392
458
  return contact.imgUrl;
@@ -419,7 +485,17 @@ const makeInMemoryStore = ({ logger: _logger, chatKey, labelAssociationKey }) =>
419
485
  writeToFile: (path) => {
420
486
  // require fs here so that in case "fs" is not available -- the app does not crash
421
487
 
422
- writeFileSync(path, JSON.stringify(toJSON()));
488
+ // AunuBaileys (1.0.84, HB-120 + addendum): every
489
+ // other persistence writer in the lib is atomic and
490
+ // 0600 (auth files HB-116, the lid store); this one
491
+ // wrote the full chat/contact/message history
492
+ // world-readable AND non-atomically — a crash
493
+ // mid-write left a truncated file the next boot read
494
+ // as corrupt and discarded wholesale. Write a
495
+ // per-pid temp file, then rename it over the target.
496
+ const tmpPath = `${path}.tmp-${process.pid}`;
497
+ writeFileSync(tmpPath, JSON.stringify(toJSON()), { mode: 0o600 });
498
+ renameSync(tmpPath, path);
423
499
  },
424
500
  readFromFile: (path) => {
425
501
  // require fs here so that in case "fs" is not available -- the app does not crash
@@ -16,11 +16,35 @@ export function makeCacheableSignalKeyStore(store, logger, _cache) {
16
16
  new NodeCache({
17
17
  stdTTL: DEFAULT_CACHE_TTLS.SIGNAL_STORE, // 5 minutes
18
18
  useClones: false,
19
- deleteOnExpire: true
19
+ deleteOnExpire: true,
20
+ // AunuBaileys (1.0.84, HB-199): a TTL bounds an
21
+ // entry's AGE, not the entry COUNT — a burst of
22
+ // distinct sender addresses inside the 5-minute
23
+ // window grew this cache without limit (the exact
24
+ // warning written in bounded-cache.js). Cap the
25
+ // number of keys as well. NOTE: NodeCache's maxKeys
26
+ // does not evict — set() THROWS once the cap is hit —
27
+ // so every cache write below goes through cacheSet,
28
+ // which swallows that one error: the underlying store
29
+ // is authoritative, a skipped cache entry only costs
30
+ // a re-read, and the cap can never break a session
31
+ // operation.
32
+ maxKeys: 1000
20
33
  });
21
34
  function getUniqueId(type, id) {
22
35
  return `${type}.${id}`;
23
36
  }
37
+ // AunuBaileys (1.0.84, HB-199): see the maxKeys note above —
38
+ // a full cache must degrade to "not cached", never to a throw
39
+ // escaping into get()/set().
40
+ function cacheSet(key, value) {
41
+ try {
42
+ cache.set(key, value);
43
+ }
44
+ catch {
45
+ // cache is at its key cap; the store stays authoritative
46
+ }
47
+ }
24
48
  return {
25
49
  async get(type, ids) {
26
50
  const data = {};
@@ -41,7 +65,7 @@ export function makeCacheableSignalKeyStore(store, logger, _cache) {
41
65
  const item = fetched[id];
42
66
  if (item) {
43
67
  data[id] = item;
44
- cache.set(getUniqueId(type, id), item);
68
+ cacheSet(getUniqueId(type, id), item);
45
69
  }
46
70
  }
47
71
  }
@@ -67,7 +91,7 @@ export function makeCacheableSignalKeyStore(store, logger, _cache) {
67
91
  cache.del(getUniqueId(type, id));
68
92
  }
69
93
  else {
70
- cache.set(getUniqueId(type, id), data[type][id]);
94
+ cacheSet(getUniqueId(type, id), data[type][id]);
71
95
  }
72
96
  keys += 1;
73
97
  }
@@ -1,6 +1,6 @@
1
1
  import EventEmitter from 'events';
2
2
  import { createReadStream } from 'fs';
3
- import { writeFile } from 'fs/promises';
3
+ import { rename, writeFile } from 'fs/promises';
4
4
  import { createInterface } from 'readline';
5
5
  import { delay } from './generics.js';
6
6
  import { makeMutex } from './make-mutex.js';
@@ -13,13 +13,48 @@ export const captureEventStream = (ev, filename) => {
13
13
  const oldEmit = ev.emit;
14
14
  // write mutex so data is appended in order
15
15
  const writeMutex = makeMutex();
16
+ // AunuBaileys (1.0.84, HB-247): a creds.update payload is the
17
+ // full credential material (private noise / identity /
18
+ // signed-pre-key bytes, the adv secret). The capture below
19
+ // redacts it to the NAMES of the fields that changed, creates
20
+ // the file 0600, and rotates it to `<filename>.1` once per
21
+ // 10 MiB so a long-running debug capture cannot grow (or
22
+ // leak) without bound.
23
+ let capturedBytes = 0;
24
+ const MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
16
25
  // monkey patch eventemitter to capture all events
17
26
  ev.emit = function (...args) {
18
- const content = JSON.stringify({ timestamp: Date.now(), event: args[0], data: args[1] }) + '\n';
19
27
  const result = oldEmit.apply(ev, args);
28
+ let content;
29
+ try {
30
+ const data = args[0] === 'creds.update' && args[1] && typeof args[1] === 'object'
31
+ ? { redacted: 'creds.update payload withheld (credential material)', updatedFields: Object.keys(args[1]) }
32
+ : args[1];
33
+ content = JSON.stringify({ timestamp: Date.now(), event: args[0], data }) + '\n';
34
+ }
35
+ catch {
36
+ // AunuBaileys (1.0.84, HB-129 addendum): an
37
+ // unserialisable (circular) payload used to throw
38
+ // synchronously out of the patched emit — into
39
+ // whatever lib code had emitted the event — because
40
+ // the stringify ran before oldEmit. Serialisation now
41
+ // happens after the real emit, and a payload that
42
+ // cannot be serialised is simply not captured.
43
+ return result;
44
+ }
45
+ // AunuBaileys (1.0.84, HB-129): the mutex promise used to
46
+ // be discarded — a failed write (deleted directory, full
47
+ // disk, permissions) rejected with no handler attached
48
+ // and crashed the host process (Node >= 15 default). A
49
+ // debug capture must never take the host down with it.
20
50
  writeMutex.mutex(async () => {
21
- await writeFile(filename, content, { flag: 'a' });
22
- });
51
+ capturedBytes += Buffer.byteLength(content);
52
+ if (capturedBytes > MAX_CAPTURE_BYTES) {
53
+ capturedBytes = Buffer.byteLength(content);
54
+ await rename(filename, `${filename}.1`).catch(() => { });
55
+ }
56
+ await writeFile(filename, content, { flag: 'a', mode: 0o600 });
57
+ }).catch(() => { });
23
58
  return result;
24
59
  };
25
60
  };
@@ -45,11 +45,25 @@ export const makeEventBuffer = (logger) => {
45
45
  // had to run. Log the error and keep dispatching the
46
46
  // remaining events of the batch; the event itself
47
47
  // still fired, with its payload unchanged.
48
- try {
49
- ev.emit(event, map[event]);
50
- }
51
- catch (err) {
52
- logger.error({ err, event }, 'error in event listener — dispatch continues');
48
+ //
49
+ // AunuBaileys (1.0.84, HB-264): dispatch PER LISTENER.
50
+ // A single ev.emit stops at the first listener that
51
+ // throws, so every listener registered AFTER the
52
+ // thrower silently missed that emit — a throwing
53
+ // logging listener registered before the host's
54
+ // saveCreds made that credential update vanish.
55
+ // rawListeners returns the once-wrappers themselves,
56
+ // so once semantics are preserved, and each listener
57
+ // gets its own guard: a throw now costs only the
58
+ // listener that threw.
59
+ const listeners = ev.rawListeners(event);
60
+ for (const listener of listeners) {
61
+ try {
62
+ listener.call(ev, map[event]);
63
+ }
64
+ catch (err) {
65
+ logger.error({ err, event }, 'error in event listener — dispatch continues');
66
+ }
53
67
  }
54
68
  }
55
69
  });
@@ -65,6 +79,17 @@ export const makeEventBuffer = (logger) => {
65
79
  }
66
80
  logger.debug('Flushing event buffer');
67
81
  isBuffering = false;
82
+ // AunuBaileys (1.0.84, HB-130 + addendum): the history
83
+ // dedup set only has to live for ONE buffering session.
84
+ // It used to survive every flush (cleared only in
85
+ // destroy()), so a second history sync inside the same
86
+ // socket lifetime silently dropped every already-known
87
+ // chat — and every already-known message key, even when
88
+ // that message's content had changed: fresh unread
89
+ // counts and edits never reached the store. Dedup for
90
+ // THIS session's appends already happened above; the
91
+ // next session starts empty.
92
+ historyCache.clear();
68
93
  const newData = makeBufferData();
69
94
  const chatUpdates = Object.values(data.chatUpdates);
70
95
  let conditionalChatUpdatesLeft = 0;
@@ -305,7 +330,14 @@ eventData, logger) {
305
330
  data.contactUpserts[contact.id] = upsert;
306
331
  }
307
332
  if (data.contactUpdates[contact.id]) {
308
- upsert = Object.assign(data.contactUpdates[contact.id], trimUndefined(contact));
333
+ // AunuBaileys (1.0.84, HB-182): the pending
334
+ // update merges INTO the upsert. The old
335
+ // direction merged the contact into the pending
336
+ // update object and then deleted that object —
337
+ // the merged result went nowhere, and fields
338
+ // only the update carried (notify, verifiedName)
339
+ // silently vanished from the flushed upsert.
340
+ Object.assign(upsert, data.contactUpdates[contact.id]);
309
341
  delete data.contactUpdates[contact.id];
310
342
  }
311
343
  }
@@ -46,7 +46,26 @@ export const BufferJSON = {
46
46
  reviver: (_, value) => {
47
47
  if (typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) {
48
48
  const val = value.data || value.value;
49
- return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val || []);
49
+ if (typeof val === 'string') {
50
+ // AunuBaileys (1.0.84, HB-143): Node's base64
51
+ // decoder is lenient — it silently DROPS
52
+ // characters outside the alphabet, so a garbage
53
+ // or hand-mangled string in a creds/key file
54
+ // decoded to wrong-length, wrong-value key bytes
55
+ // with no error anywhere, and the corruption only
56
+ // surfaced later as baffling Curve / handshake
57
+ // failures. The replacer above only ever produces
58
+ // canonical base64; reject anything else loudly.
59
+ // Both in-tree readers contain the throw: the
60
+ // auth-state reader logs a corrupt file and starts
61
+ // clean, and the sender-key reader falls back to
62
+ // an empty (self-healing) record.
63
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(val)) {
64
+ throw new Error('BufferJSON: invalid base64 in serialized Buffer data');
65
+ }
66
+ return Buffer.from(val, 'base64');
67
+ }
68
+ return Buffer.from(val || []);
50
69
  }
51
70
  return value;
52
71
  }
@@ -13,6 +13,7 @@ export * from './auth-utils.js';
13
13
  export * from './baileys-event-stream.js';
14
14
  export * from './use-multi-file-auth-state.js';
15
15
  export * from './link-preview.js';
16
+ export * from './safe-fetch.js';
16
17
  export * from './event-buffer.js';
17
18
  export * from './process-message.js';
18
19
  //# sourceMappingURL=index.d.ts.map
@@ -31,6 +31,7 @@ export * from './auth-utils.js';
31
31
  export * from './baileys-event-stream.js';
32
32
  export * from './use-multi-file-auth-state.js';
33
33
  export * from './link-preview.js';
34
+ export * from './safe-fetch.js';
34
35
  export * from './event-buffer.js';
35
36
  export * from './process-message.js';
36
37
  //# sourceMappingURL=index.js.map
@@ -169,7 +169,10 @@ export const makeLIDStore = (options = {}) => {
169
169
  // AunuBaileys (1.0.70): atomic tmp+rename — a crash mid-write
170
170
  // used to leave a truncated store file behind.
171
171
  const tmpPath = `${path}.tmp-${process.pid}`;
172
- writeFileSync(tmpPath, JSON.stringify(Object.fromEntries(pnToLid)));
172
+ // AunuBaileys (1.0.84, HB-120): 0600, like the auth
173
+ // files (HB-116) — this file maps phone numbers to
174
+ // LIDs and must not land world-readable.
175
+ writeFileSync(tmpPath, JSON.stringify(Object.fromEntries(pnToLid)), { mode: 0o600 });
173
176
  renameSync(tmpPath, path);
174
177
  },
175
178
  loadFromFile: (path) => {
@@ -1,15 +1,71 @@
1
1
  import { prepareWAMessageMedia } from './messages.js';
2
- import { extractImageThumb, getHttpStream } from './messages-media.js';
2
+ import { extractImageThumb } from './messages-media.js';
3
+ import { readImageDimensions, safeHttpGet } from './safe-fetch.js';
3
4
  const THUMBNAIL_WIDTH_PX = 192;
5
+ // AunuBaileys (1.0.85): everything in this module is fetched on behalf
6
+ // of a REMOTE PAGE — the URL comes from message text and the og:image
7
+ // from the fetched page itself — so all of it now goes through the
8
+ // SSRF-safe fetch (lib/Utils/safe-fetch.js): non-public addresses are
9
+ // refused at connect time (peer-supplied URLs used to be able to make
10
+ // the host GET loopback / RFC1918 / cloud-metadata addresses), and
11
+ // every response is size- and time-bounded. link-preview-js is used
12
+ // for HTML parsing ONLY (getPreviewFromContent); it no longer
13
+ // performs any network access, so the protections do not depend on
14
+ // the installed link-preview-js version. This also closes HB-205:
15
+ // the HTML body used to be read in full with no cap and no overall
16
+ // timeout (the library's abort timer covered only time-to-headers).
17
+ const MAX_HTML_BYTES = 1024 * 1024; // og: tags live in <head>; the rest is cut off
18
+ const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024;
19
+ const MAX_THUMBNAIL_PIXELS = 40000000; // refuse decompression bombs before the image library sees them
20
+ const MAX_PREVIEW_TIMEOUT_MS = 30000;
21
+ const HTMLISH = /^(text\/html|application\/xhtml\+xml|text\/xml|application\/xml)\b/i;
22
+ /** Only these keys of the caller's fetchOpts are honoured now. */
23
+ const transportOpts = (fetchOpts = {}) => ({
24
+ // Sanitised at the boundary: a finite positive timeout is honored
25
+ // (clamped to a sane preview ceiling), anything else gets the
26
+ // historical 3000 ms default.
27
+ timeout: typeof fetchOpts.timeout === 'number' && Number.isFinite(fetchOpts.timeout) && fetchOpts.timeout > 0
28
+ ? Math.min(fetchOpts.timeout, MAX_PREVIEW_TIMEOUT_MS)
29
+ : 3000,
30
+ headers: fetchOpts.headers,
31
+ // Explicit, host-controlled hooks only — never derived from
32
+ // message content: an opt-in for deployments that really want
33
+ // previews of intranet links, and resolver/policy overrides.
34
+ allowPrivateNetworks: fetchOpts.allowPrivateNetworks === true,
35
+ isBlocked: typeof fetchOpts.isBlockedAddress === 'function' ? fetchOpts.isBlockedAddress : undefined,
36
+ lookup: typeof fetchOpts.lookup === 'function' ? fetchOpts.lookup : undefined
37
+ });
38
+ const decodeBody = (body, contentType) => {
39
+ const charset = /charset=["']?([\w-]+)/i.exec(contentType || '')?.[1];
40
+ try {
41
+ return new TextDecoder(charset || 'utf-8').decode(body);
42
+ }
43
+ catch {
44
+ return new TextDecoder('utf-8').decode(body);
45
+ }
46
+ };
47
+ /** Fetches an og:image through the safe fetch, with byte + pixel bounds. */
48
+ const fetchThumbnailBuffer = async (imageUrl, fetchOpts) => {
49
+ const res = await safeHttpGet(imageUrl, {
50
+ ...transportOpts(fetchOpts),
51
+ maxBytes: MAX_THUMBNAIL_BYTES,
52
+ onLimit: 'throw'
53
+ });
54
+ const dims = readImageDimensions(res.body);
55
+ if (dims && dims.width * dims.height > MAX_THUMBNAIL_PIXELS) {
56
+ throw new Error(`thumbnail dimensions ${dims.width}x${dims.height} exceed the safety limit`);
57
+ }
58
+ return res.body;
59
+ };
4
60
  /** Fetches an image and generates a thumbnail for it */
5
- // AunuBaileys (1.0.72): an og:image only ever becomes a small JPEG —
6
- // cap the source download instead of buffering a hostile endless
7
- // stream in full (HB-89). getUrlInfo catches thumbnail errors and
8
- // degrades to no thumbnail.
9
- const MAX_THUMBNAIL_SOURCE_BYTES = 8 * 1024 * 1024;
10
61
  const getCompressedJpegThumbnail = async (url, { thumbnailWidth, fetchOpts }) => {
11
- const stream = await getHttpStream(url, fetchOpts);
12
- const result = await extractImageThumb(stream, thumbnailWidth, MAX_THUMBNAIL_SOURCE_BYTES);
62
+ // AunuBaileys (1.0.72): an og:image only ever becomes a small
63
+ // JPEG — cap the source download instead of buffering a hostile
64
+ // endless stream in full (HB-89). (1.0.85): the cap is now
65
+ // enforced by the safe fetch (5 MiB, throw) before the bytes
66
+ // reach the image library, and pixel bombs are refused up front.
67
+ const buffer = await fetchThumbnailBuffer(url, fetchOpts);
68
+ const result = await extractImageThumb(buffer, thumbnailWidth);
13
69
  return result;
14
70
  };
15
71
  /**
@@ -23,34 +79,22 @@ export const getUrlInfo = async (text, opts = {
23
79
  fetchOpts: { timeout: 3000 }
24
80
  }) => {
25
81
  try {
26
- // retries
27
- const retries = 0;
28
- const maxRetry = 5;
29
- const { getLinkPreview } = await import('link-preview-js');
82
+ const { getPreviewFromContent } = await import('link-preview-js');
30
83
  let previewLink = text;
31
84
  if (!text.startsWith('https://') && !text.startsWith('http://')) {
32
85
  previewLink = 'https://' + previewLink;
33
86
  }
34
- const info = await getLinkPreview(previewLink, {
35
- ...opts.fetchOpts,
36
- followRedirects: 'follow',
37
- handleRedirects: (baseURL, forwardedURL) => {
38
- const urlObj = new URL(baseURL);
39
- const forwardedURLObj = new URL(forwardedURL);
40
- if (retries >= maxRetry) {
41
- return false;
42
- }
43
- if (forwardedURLObj.hostname === urlObj.hostname ||
44
- forwardedURLObj.hostname === 'www.' + urlObj.hostname ||
45
- 'www.' + forwardedURLObj.hostname === urlObj.hostname) {
46
- retries + 1;
47
- return true;
48
- }
49
- else {
50
- return false;
51
- }
52
- },
53
- headers: opts.fetchOpts
87
+ const res = await safeHttpGet(previewLink, {
88
+ ...transportOpts(opts.fetchOpts),
89
+ maxBytes: MAX_HTML_BYTES,
90
+ onLimit: 'truncate',
91
+ shouldReadBody: (_status, headers) => !headers['content-type'] || HTMLISH.test(headers['content-type'])
92
+ });
93
+ const contentType = res.headers['content-type'];
94
+ const info = await getPreviewFromContent({
95
+ url: res.url,
96
+ data: decodeBody(res.body, contentType),
97
+ headers: contentType ? { 'content-type': contentType } : {}
54
98
  });
55
99
  if (info && 'title' in info && info.title) {
56
100
  const [image] = info.images;
@@ -61,8 +105,16 @@ export const getUrlInfo = async (text, opts = {
61
105
  description: info.description,
62
106
  originalThumbnailUrl: image
63
107
  };
64
- if (opts.uploadImage) {
65
- const { imageMessage } = await prepareWAMessageMedia({ image: { url: image } }, {
108
+ if (opts.uploadImage && image) {
109
+ // AunuBaileys (1.0.85): the high-quality thumbnail is
110
+ // fetched through the safe fetch as a Buffer instead
111
+ // of handing the page-supplied URL to the generic
112
+ // media pipeline. The `&& image` guard also closes
113
+ // HB-150: a page without an og:image used to explode
114
+ // here with a raw TypeError and lose the whole
115
+ // preview.
116
+ const thumb = await fetchThumbnailBuffer(image, opts.fetchOpts);
117
+ const { imageMessage } = await prepareWAMessageMedia({ image: thumb }, {
66
118
  upload: opts.uploadImage,
67
119
  mediaTypeOverride: 'thumbnail-link',
68
120
  options: opts.fetchOpts
@@ -87,4 +139,4 @@ export const getUrlInfo = async (text, opts = {
87
139
  }
88
140
  }
89
141
  };
90
- //# sourceMappingURL=link-preview.js.map
142
+ //# sourceMappingURL=link-preview.js.map