@oxidezap/baileyrs 0.1.0 → 0.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 +70 -0
- package/lib/Compatibility/legacy-store/namespaces.d.ts +20 -0
- package/lib/Compatibility/legacy-store/namespaces.js +27 -0
- package/lib/Compatibility/legacy-store/routing.js +13 -8
- package/lib/Compatibility/newsletter-results.d.ts +15 -0
- package/lib/Compatibility/newsletter-results.js +38 -0
- package/lib/Socket/business.d.ts +29 -0
- package/lib/Socket/business.js +104 -0
- package/lib/Socket/chat-actions.d.ts +20 -11
- package/lib/Socket/chat-actions.js +171 -83
- package/lib/Socket/events.js +5 -5
- package/lib/Socket/index.d.ts +85 -14
- package/lib/Socket/index.js +36 -26
- package/lib/Socket/internals.d.ts +88 -0
- package/lib/Socket/internals.js +145 -0
- package/lib/Socket/messages.d.ts +1 -15
- package/lib/Socket/messages.js +3 -22
- package/lib/Socket/newsletter.d.ts +61 -6
- package/lib/Socket/newsletter.js +125 -7
- package/lib/Socket/privacy.d.ts +25 -0
- package/lib/Socket/privacy.js +54 -0
- package/lib/Socket/server-queries.d.ts +38 -0
- package/lib/Socket/server-queries.js +121 -0
- package/lib/Socket/types.d.ts +6 -0
- package/lib/Types/Product.d.ts +9 -0
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/index.js +3 -0
- package/lib/Utils/link-preview.d.ts +60 -0
- package/lib/Utils/link-preview.js +357 -0
- package/lib/Utils/messages.d.ts +20 -7
- package/lib/Utils/messages.js +14 -3
- package/lib/Utils/wrap-legacy-store.d.ts +1 -0
- package/lib/Utils/wrap-legacy-store.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Boom } from '../Utils/boom.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every section, flattened and deduplicated, rather than only the section the
|
|
4
|
+
* server types `all`.
|
|
5
|
+
*
|
|
6
|
+
* Upstream reads that one section, and the core deliberately refused to: the
|
|
7
|
+
* real client walks every section and uses the type only for layout, so a bot
|
|
8
|
+
* that appears solely under a category is dropped by the narrower reading. A
|
|
9
|
+
* caller iterating this list handles the extra entries; one that silently lost
|
|
10
|
+
* a bot has no way to notice.
|
|
11
|
+
*/
|
|
12
|
+
const flattenBotList = (list) => {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const bots = [];
|
|
15
|
+
for (const section of list.sections) {
|
|
16
|
+
for (const bot of section.bots) {
|
|
17
|
+
if (seen.has(bot.jid))
|
|
18
|
+
continue;
|
|
19
|
+
seen.add(bot.jid);
|
|
20
|
+
bots.push({ jid: bot.jid, personaId: bot.personaId });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return bots;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Upstream names these in snake case and types the three timestamps as
|
|
27
|
+
* strings. Every field is optional because the server omits whatever does not
|
|
28
|
+
* apply to the account's tier, and an absent quota stays absent: `0` here means
|
|
29
|
+
* the quota is spent.
|
|
30
|
+
*/
|
|
31
|
+
const toCapInfo = (result) => ({
|
|
32
|
+
...(result.totalQuota !== undefined ? { total_quota: result.totalQuota } : {}),
|
|
33
|
+
...(result.usedQuota !== undefined ? { used_quota: result.usedQuota } : {}),
|
|
34
|
+
...(result.remainingQuota !== undefined ? { remaining_quota: result.remainingQuota } : {}),
|
|
35
|
+
...(result.cycleStartTimestamp !== undefined ? { cycle_start_timestamp: String(result.cycleStartTimestamp) } : {}),
|
|
36
|
+
...(result.cycleEndTimestamp !== undefined ? { cycle_end_timestamp: String(result.cycleEndTimestamp) } : {}),
|
|
37
|
+
...(result.serverSentTimestamp !== undefined ? { server_sent_timestamp: String(result.serverSentTimestamp) } : {}),
|
|
38
|
+
...(result.oteStatus !== undefined ? { ote_status: result.oteStatus } : {}),
|
|
39
|
+
...(result.mvStatus !== undefined ? { mv_status: result.mvStatus } : {}),
|
|
40
|
+
...(result.cappingStatus !== undefined
|
|
41
|
+
? { capping_status: result.cappingStatus }
|
|
42
|
+
: {})
|
|
43
|
+
});
|
|
44
|
+
export const makeServerQueryMethods = (ctx) => {
|
|
45
|
+
/** Last host seen, so the synchronous accessor upstream exposes can answer. */
|
|
46
|
+
let mediaHost = '';
|
|
47
|
+
/**
|
|
48
|
+
* When the credentials were obtained, not when they were last handed out.
|
|
49
|
+
* The engine serves a live connection from its cache and gives no signal
|
|
50
|
+
* for which calls were actual fetches, so the stamp is kept only while the
|
|
51
|
+
* connection it describes is still live: past its own ttl, on a forced
|
|
52
|
+
* call, or on rotated credentials, whatever comes back is a fetch.
|
|
53
|
+
*
|
|
54
|
+
* A caller renewing on `fetchDate + ttl` needs both halves of that. Always
|
|
55
|
+
* restamping would push its deadline forward forever; never restamping
|
|
56
|
+
* would leave it renewing against a moment that has already passed.
|
|
57
|
+
*/
|
|
58
|
+
let fetched;
|
|
59
|
+
const isLive = (held) => Date.now() - held.at.getTime() < held.ttl * 1000;
|
|
60
|
+
return {
|
|
61
|
+
/**
|
|
62
|
+
* `maxContentLengthBytes` is absent by design: the core's hosts carry
|
|
63
|
+
* nothing but a hostname, so the field upstream declares has no source
|
|
64
|
+
* and is not invented here.
|
|
65
|
+
*/
|
|
66
|
+
refreshMediaConn: async (forceGet = false) => {
|
|
67
|
+
// Forced on the first call, as upstream's first call is: the engine may
|
|
68
|
+
// already hold a connection acquired by an upload, and stamping that
|
|
69
|
+
// one as fetched now would report it fresher than it is.
|
|
70
|
+
const held = fetched;
|
|
71
|
+
const conn = await (await ctx.getClient()).getMediaConn(forceGet || !held);
|
|
72
|
+
mediaHost = conn.hosts[0]?.hostname ?? mediaHost;
|
|
73
|
+
const isFetch = forceGet || !held || held.auth !== conn.auth || !isLive(held);
|
|
74
|
+
fetched = isFetch ? { auth: conn.auth, ttl: conn.ttl, at: new Date() } : held;
|
|
75
|
+
// A copy: the stored instant decides when the next call restamps, and a
|
|
76
|
+
// consumer holding the same Date could move it.
|
|
77
|
+
return { auth: conn.auth, ttl: conn.ttl, hosts: conn.hosts, fetchDate: new Date(fetched.at) };
|
|
78
|
+
},
|
|
79
|
+
/** Synchronous, as upstream has it, so it reads what the last refresh saw. */
|
|
80
|
+
getMediaHost: () => mediaHost,
|
|
81
|
+
getBotListV2: async () => {
|
|
82
|
+
return flattenBotList(await (await ctx.getClient()).getBotList());
|
|
83
|
+
},
|
|
84
|
+
fetchNewChatMessageCap: async () => {
|
|
85
|
+
return toCapInfo(await (await ctx.getClient()).fetchNewChatMessageCappingInfo());
|
|
86
|
+
},
|
|
87
|
+
cleanDirtyBits: async (type, fromTimestamp) => {
|
|
88
|
+
let timestamp = null;
|
|
89
|
+
if (fromTimestamp !== undefined) {
|
|
90
|
+
// A blank string is not a timestamp, and `Number('')` is the epoch,
|
|
91
|
+
// so without this a caller meaning "no timestamp" would ask the
|
|
92
|
+
// server to clean from the beginning of time.
|
|
93
|
+
const blank = typeof fromTimestamp === 'string' && fromTimestamp.trim() === '';
|
|
94
|
+
timestamp = typeof fromTimestamp === 'string' ? Number(fromTimestamp) : fromTimestamp;
|
|
95
|
+
if (blank || !Number.isFinite(timestamp)) {
|
|
96
|
+
throw new Boom(`cleanDirtyBits: fromTimestamp '${fromTimestamp}' is not a number`, { statusCode: 400 });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await (await ctx.getClient()).cleanDirtyBits(type, timestamp);
|
|
100
|
+
},
|
|
101
|
+
/**
|
|
102
|
+
* Refused rather than wired up. The core already fires a peer data
|
|
103
|
+
* request itself when a message fails to decrypt, with its own age
|
|
104
|
+
* policy, so a second one here would duplicate it. The request a
|
|
105
|
+
* consumer actually drives, asking for history, is `fetchMessageHistory`.
|
|
106
|
+
*/
|
|
107
|
+
sendPeerDataOperationMessage: async (_pdoMessage) => {
|
|
108
|
+
throw new Boom('sendPeerDataOperationMessage is not supported: use fetchMessageHistory to request history, and note the engine issues its own peer data request when a message fails to decrypt', { statusCode: 501 });
|
|
109
|
+
},
|
|
110
|
+
/**
|
|
111
|
+
* Refused one layer down, as a build decision. `create_call_link` exists
|
|
112
|
+
* in the core behind its voip feature, and the bridge pins the core with
|
|
113
|
+
* default features off, so it is not compiled into the wasm artifact at
|
|
114
|
+
* all. Reaching it would pull the webrtc stack into the bundle.
|
|
115
|
+
*/
|
|
116
|
+
createCallLink: async (_type, _event, _timeoutMs) => {
|
|
117
|
+
throw new Boom('createCallLink is not available: the call-link operation sits behind the core voip feature, which is not compiled into the wasm bridge', { statusCode: 501 });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
//# sourceMappingURL=server-queries.js.map
|
package/lib/Socket/types.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface SocketContext {
|
|
|
23
23
|
getClientSync: () => WasmWhatsAppClient;
|
|
24
24
|
/** Raw stanza EventEmitter for CB: pattern compat */
|
|
25
25
|
ws: EventEmitter;
|
|
26
|
+
/**
|
|
27
|
+
* Where a failure goes when it has nowhere else to go: a dispatcher that
|
|
28
|
+
* threw, a wire batch that would not decode. Also what the socket exposes
|
|
29
|
+
* as `onUnexpectedError`, so the two are one reporter rather than two.
|
|
30
|
+
*/
|
|
31
|
+
reportUnexpectedError: (err: unknown, msg: string) => void;
|
|
26
32
|
}
|
|
27
33
|
/** Convert a bridge Jid struct to a string */
|
|
28
34
|
export declare const jidStr: (jid: {
|
package/lib/Types/Product.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import type { CatalogResult as CatalogPageResult, CollectionsResult } from '@oxidezap/whatsapp-rust-bridge';
|
|
1
2
|
import type { WAMediaUpload } from './Message.js';
|
|
3
|
+
/**
|
|
4
|
+
* What `getCatalog` returns, under a name of its own. The `CatalogResult`
|
|
5
|
+
* below is the raw catalog envelope and is a different shape, so a consumer
|
|
6
|
+
* typing the call has one name to reach for and it is this one.
|
|
7
|
+
*/
|
|
8
|
+
export type CatalogPage = CatalogPageResult;
|
|
9
|
+
/** As `CatalogPage`, for `getCollections`. */
|
|
10
|
+
export type CollectionsPage = CollectionsResult;
|
|
2
11
|
export type CatalogResult = {
|
|
3
12
|
data: {
|
|
4
13
|
paging: {
|
package/lib/Utils/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './auth-utils.js';
|
|
|
3
3
|
export * from './crypto.js';
|
|
4
4
|
export * from './generics.js';
|
|
5
5
|
export * from './messages.js';
|
|
6
|
+
export { getUrlInfo, type URLGenerationOptions } from './link-preview.js';
|
|
6
7
|
export * from './messages-media.js';
|
|
7
8
|
export * from './process-history-message.js';
|
|
8
9
|
export * from './process-message.js';
|
package/lib/Utils/index.js
CHANGED
|
@@ -3,6 +3,9 @@ export * from './auth-utils.js';
|
|
|
3
3
|
export * from './crypto.js';
|
|
4
4
|
export * from './generics.js';
|
|
5
5
|
export * from './messages.js';
|
|
6
|
+
// Named rather than `*`: the underscore hooks in that file exist for the tests
|
|
7
|
+
// and would otherwise become released API.
|
|
8
|
+
export { getUrlInfo } from './link-preview.js';
|
|
6
9
|
export * from './messages-media.js';
|
|
7
10
|
export * from './process-history-message.js';
|
|
8
11
|
export * from './process-message.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { WAUrlInfo } from '../Types/Message.js';
|
|
2
|
+
import type { ILogger } from './logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* The first link in a piece of text, or undefined when there is none. Exported
|
|
5
|
+
* under an underscore so the extraction can be tested without a network.
|
|
6
|
+
*
|
|
7
|
+
* Trailing prose punctuation is dropped: a link at the end of a sentence
|
|
8
|
+
* carries the full stop with it, and `https://example.com.` is not what the
|
|
9
|
+
* writer meant. A closing bracket goes the same way, which costs the rare url
|
|
10
|
+
* that genuinely ends in one and saves the common case of a link in
|
|
11
|
+
* parentheses.
|
|
12
|
+
*/
|
|
13
|
+
export declare const _firstLink: (text: string) => string | undefined;
|
|
14
|
+
export type URLGenerationOptions = {
|
|
15
|
+
thumbnailWidth: number;
|
|
16
|
+
fetchOpts: {
|
|
17
|
+
/** Timeout in ms */
|
|
18
|
+
timeout: number;
|
|
19
|
+
proxyUrl?: string;
|
|
20
|
+
headers?: HeadersInit;
|
|
21
|
+
};
|
|
22
|
+
uploadImage?: (encFilePath: string, opts: {
|
|
23
|
+
fileEncSha256B64: string;
|
|
24
|
+
mediaType: string;
|
|
25
|
+
}) => Promise<unknown>;
|
|
26
|
+
logger?: ILogger;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Fetched here rather than through `getHttpStream`, which forwards neither the
|
|
30
|
+
* timeout nor the proxy and validates no destination. The credentials in
|
|
31
|
+
* `headers` are for the page, so they are sent to the thumbnail only when it
|
|
32
|
+
* is the same origin; another host advertised by that page must not receive
|
|
33
|
+
* them.
|
|
34
|
+
*/
|
|
35
|
+
/** Exported under an underscore so the destination guard can be driven directly. */
|
|
36
|
+
export declare const _getCompressedJpegThumbnail: (url: string, pageUrl: string, { thumbnailWidth, fetchOpts }: URLGenerationOptions) => Promise<{
|
|
37
|
+
buffer: any;
|
|
38
|
+
original: {
|
|
39
|
+
width: any;
|
|
40
|
+
height: any;
|
|
41
|
+
};
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Reads the first URL out of a piece of text and fetches what a link preview
|
|
45
|
+
* needs. Nothing here is protocol: it is an HTTP fetch, an OpenGraph parse and
|
|
46
|
+
* a thumbnail, which is why it belongs in this layer rather than the engine.
|
|
47
|
+
*
|
|
48
|
+
* Resolves to undefined for the two cases that mean "no preview": text with no
|
|
49
|
+
* link in it, and a page with no title. Everything else throws, including a
|
|
50
|
+
* timeout, because a swallowed failure is indistinguishable from a page that
|
|
51
|
+
* genuinely had nothing, and a caller retrying the first would give up on the
|
|
52
|
+
* second.
|
|
53
|
+
*
|
|
54
|
+
* The metadata parse comes from `link-preview-js`, an optional peer dependency
|
|
55
|
+
* this package already declares and had no reader for, so nothing new is
|
|
56
|
+
* pulled in: a consumer who does not want link previews does not install it
|
|
57
|
+
* and never calls this.
|
|
58
|
+
*/
|
|
59
|
+
export declare const getUrlInfo: (text: string, opts?: URLGenerationOptions) => Promise<WAUrlInfo | undefined>;
|
|
60
|
+
//# sourceMappingURL=link-preview.d.ts.map
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { Buffer } from 'node:buffer';
|
|
10
|
+
import { lookup } from 'node:dns/promises';
|
|
11
|
+
import { isIP } from 'node:net';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
import { Boom } from './boom.js';
|
|
14
|
+
import { extractImageThumb } from './messages-media.js';
|
|
15
|
+
const THUMBNAIL_WIDTH_PX = 192;
|
|
16
|
+
/** An explicit scheme names a link wherever it sits, so nothing is required before it. */
|
|
17
|
+
const SCHEMED_URL = /https?:\/\/[^\s<>]+/i;
|
|
18
|
+
/**
|
|
19
|
+
* A bare host has to earn it: something before it, and an alphabetic last
|
|
20
|
+
* label, which is what separates `example.com` from `version 1.22`.
|
|
21
|
+
*/
|
|
22
|
+
const BARE_HOST = /(^|[\s([{'"<])((?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s<>]*)?)(?=[\s.,!?)\]}>]|$)/i;
|
|
23
|
+
/**
|
|
24
|
+
* The first link in a piece of text, or undefined when there is none. Exported
|
|
25
|
+
* under an underscore so the extraction can be tested without a network.
|
|
26
|
+
*
|
|
27
|
+
* Trailing prose punctuation is dropped: a link at the end of a sentence
|
|
28
|
+
* carries the full stop with it, and `https://example.com.` is not what the
|
|
29
|
+
* writer meant. A closing bracket goes the same way, which costs the rare url
|
|
30
|
+
* that genuinely ends in one and saves the common case of a link in
|
|
31
|
+
* parentheses.
|
|
32
|
+
*/
|
|
33
|
+
export const _firstLink = (text) => {
|
|
34
|
+
const schemed = SCHEMED_URL.exec(text);
|
|
35
|
+
const bare = BARE_HOST.exec(text);
|
|
36
|
+
// Whichever starts earlier. A bare match begins at the character before the
|
|
37
|
+
// host, so it is that offset, not the match's, that compares.
|
|
38
|
+
const bareAt = bare ? bare.index + bare[1].length : Number.POSITIVE_INFINITY;
|
|
39
|
+
const found = (schemed && schemed.index <= bareAt ? schemed[0] : bare?.[2])?.replace(/[.,!?;:'")\]}]+$/, '');
|
|
40
|
+
if (!found)
|
|
41
|
+
return undefined;
|
|
42
|
+
// Case-insensitively, since `HTTPS://` is a scheme too and prefixing it
|
|
43
|
+
// again would build a URL that parses as nothing.
|
|
44
|
+
return /^https?:\/\//i.test(found) ? found : `https://${found}`;
|
|
45
|
+
};
|
|
46
|
+
/** Enough for any preview thumbnail, and small enough that a hostile one cannot exhaust memory. */
|
|
47
|
+
const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* The eight groups an IPv6 literal stands for, with `::` expanded and any
|
|
50
|
+
* trailing dotted quad folded in. Undefined when the text is not one, so an
|
|
51
|
+
* unrecognised form is refused rather than read as public.
|
|
52
|
+
*/
|
|
53
|
+
const hextetsOf = (address) => {
|
|
54
|
+
const [head, tail, ...rest] = address.split('::');
|
|
55
|
+
if (rest.length)
|
|
56
|
+
return undefined;
|
|
57
|
+
const groupsOf = (part) => {
|
|
58
|
+
if (!part)
|
|
59
|
+
return [];
|
|
60
|
+
const groups = [];
|
|
61
|
+
for (const piece of part.split(':')) {
|
|
62
|
+
if (piece.includes('.')) {
|
|
63
|
+
if (isIP(piece) !== 4)
|
|
64
|
+
return undefined;
|
|
65
|
+
const [a, b, c, d] = piece.split('.').map(Number);
|
|
66
|
+
groups.push((a << 8) | b, (c << 8) | d);
|
|
67
|
+
}
|
|
68
|
+
else if (/^[0-9a-f]{1,4}$/.test(piece)) {
|
|
69
|
+
groups.push(Number.parseInt(piece, 16));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return groups;
|
|
76
|
+
};
|
|
77
|
+
const left = groupsOf(head ?? '');
|
|
78
|
+
const right = tail === undefined ? [] : groupsOf(tail);
|
|
79
|
+
if (!left || !right)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (tail === undefined)
|
|
82
|
+
return left.length === 8 ? left : undefined;
|
|
83
|
+
const gap = 8 - left.length - right.length;
|
|
84
|
+
return gap < 0 ? undefined : [...left, ...Array.from({ length: gap }).fill(0), ...right];
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* The v4 address an IPv4-mapped IPv6 literal stands for, in each spelling of
|
|
88
|
+
* it: `::ffff:127.0.0.1`, `::ffff:7f00:1` and `::ffff:0:7f00:1` are one
|
|
89
|
+
* address, and judging only one leaves the others a way through.
|
|
90
|
+
*/
|
|
91
|
+
const dottedQuad = (high, low) => `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
92
|
+
const mappedIPv4 = (groups) => {
|
|
93
|
+
const zeroesUpTo = (count) => groups.slice(0, count).every(group => group === 0);
|
|
94
|
+
if (!((zeroesUpTo(5) && groups[5] === 0xffff) || (zeroesUpTo(4) && groups[4] === 0xffff && groups[5] === 0))) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return dottedQuad(groups[6], groups[7]);
|
|
98
|
+
};
|
|
99
|
+
const isPrivateIPv4 = (address) => {
|
|
100
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
101
|
+
// Anything unparseable is refused rather than allowed, so a form not
|
|
102
|
+
// recognised here cannot become a way through.
|
|
103
|
+
if (a === undefined || b === undefined || c === undefined)
|
|
104
|
+
return true;
|
|
105
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(c))
|
|
106
|
+
return true;
|
|
107
|
+
if (a === 0 || a === 10 || a === 127)
|
|
108
|
+
return true;
|
|
109
|
+
if (a === 169 && b === 254)
|
|
110
|
+
return true;
|
|
111
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
112
|
+
return true;
|
|
113
|
+
if (a === 192 && b === 168)
|
|
114
|
+
return true;
|
|
115
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
116
|
+
return true;
|
|
117
|
+
// Not private, but not globally routable either. An operator who routes one
|
|
118
|
+
// of these internally would otherwise have it reachable from a preview.
|
|
119
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2))
|
|
120
|
+
return true;
|
|
121
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
122
|
+
return true;
|
|
123
|
+
if (a === 198 && b === 51 && c === 100)
|
|
124
|
+
return true;
|
|
125
|
+
if (a === 203 && b === 0 && c === 113)
|
|
126
|
+
return true;
|
|
127
|
+
return a >= 224;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Judged by value rather than by how the address is written. Both callers hand
|
|
131
|
+
* this canonical text today, and resting on that would make the guard wrong the
|
|
132
|
+
* moment one of them stops.
|
|
133
|
+
*/
|
|
134
|
+
const isPrivateIPv6 = (groups) => {
|
|
135
|
+
const mapped = mappedIPv4(groups);
|
|
136
|
+
if (mapped)
|
|
137
|
+
return isPrivateIPv4(mapped);
|
|
138
|
+
// NAT64 carries a v4 address the network translates to on the way out, so
|
|
139
|
+
// the embedded address is what decides. Only the /96 embedding can be read
|
|
140
|
+
// off the literal; any other length in the prefix is refused instead.
|
|
141
|
+
if (groups[0] === 0x0064 && groups[1] === 0xff9b) {
|
|
142
|
+
return groups.slice(2, 6).every(group => group === 0) ? isPrivateIPv4(dottedQuad(groups[6], groups[7])) : true;
|
|
143
|
+
}
|
|
144
|
+
// All of `::/96`, which covers the unspecified address, loopback and the
|
|
145
|
+
// deprecated v4-compatible forms.
|
|
146
|
+
if (groups.slice(0, 6).every(group => group === 0))
|
|
147
|
+
return true;
|
|
148
|
+
const first = groups[0];
|
|
149
|
+
// fc00::/7 unique-local, fe80::/10 link-local, fec0::/10 site-local and
|
|
150
|
+
// ff00::/8 multicast, which the v4 side already refuses as 224/4.
|
|
151
|
+
if ((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80)
|
|
152
|
+
return true;
|
|
153
|
+
return (first & 0xffc0) === 0xfec0 || (first & 0xff00) === 0xff00;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Addresses no outbound request from a link preview may reach: loopback, the
|
|
157
|
+
* private ranges, link-local and the cloud metadata address that sits inside
|
|
158
|
+
* link-local and is the usual target, plus the IPv6 equivalents.
|
|
159
|
+
*/
|
|
160
|
+
const isPrivateAddress = (address) => {
|
|
161
|
+
if (isIP(address) !== 6)
|
|
162
|
+
return isPrivateIPv4(address);
|
|
163
|
+
const groups = hextetsOf(address.toLowerCase());
|
|
164
|
+
return groups ? isPrivateIPv6(groups) : true;
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* A preview page is attacker-controlled, and the image URL it advertises is
|
|
168
|
+
* whatever it wants. Unchecked, that turns a link preview into a request the
|
|
169
|
+
* bot makes on the page's behalf, to an address the operator never chose.
|
|
170
|
+
*
|
|
171
|
+
* The host is resolved and judged before the request, because a public name
|
|
172
|
+
* pointing at a private address is the whole trick. Redirects are refused
|
|
173
|
+
* rather than followed, since each hop would need judging again and a
|
|
174
|
+
* thumbnail is not worth that.
|
|
175
|
+
*
|
|
176
|
+
* A name that resolves differently between this check and the connection is
|
|
177
|
+
* not closed off: doing that means pinning the address into the socket, which
|
|
178
|
+
* needs a custom agent this package does not carry.
|
|
179
|
+
*/
|
|
180
|
+
const publicAddressOf = async (target) => {
|
|
181
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
182
|
+
throw new Boom(`link preview: refusing to fetch ${target.protocol}`, { statusCode: 400 });
|
|
183
|
+
}
|
|
184
|
+
const host = target.hostname.replace(/^\[|\]$/g, '');
|
|
185
|
+
const resolved = isIP(host) ? [host] : (await lookup(host, { all: true })).map(({ address }) => address);
|
|
186
|
+
const [first] = resolved;
|
|
187
|
+
if (!first) {
|
|
188
|
+
throw new Boom(`link preview: ${host} did not resolve`, { statusCode: 400 });
|
|
189
|
+
}
|
|
190
|
+
// Every address a name answers with, not just the first: several records
|
|
191
|
+
// mean the one judged here need not be the one connected to.
|
|
192
|
+
const refused = resolved.find(address => isPrivateAddress(address));
|
|
193
|
+
if (refused) {
|
|
194
|
+
throw new Boom(`link preview: refusing to fetch a private address (${refused})`, { statusCode: 400 });
|
|
195
|
+
}
|
|
196
|
+
return first;
|
|
197
|
+
};
|
|
198
|
+
const assertPublicDestination = async (target) => {
|
|
199
|
+
await publicAddressOf(target);
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Fetched here rather than through `getHttpStream`, which forwards neither the
|
|
203
|
+
* timeout nor the proxy and validates no destination. The credentials in
|
|
204
|
+
* `headers` are for the page, so they are sent to the thumbnail only when it
|
|
205
|
+
* is the same origin; another host advertised by that page must not receive
|
|
206
|
+
* them.
|
|
207
|
+
*/
|
|
208
|
+
/** Exported under an underscore so the destination guard can be driven directly. */
|
|
209
|
+
export const _getCompressedJpegThumbnail = async (url, pageUrl, { thumbnailWidth, fetchOpts }) => {
|
|
210
|
+
if (fetchOpts.proxyUrl) {
|
|
211
|
+
// Silently connecting direct would defeat the reason a proxy was set.
|
|
212
|
+
throw new Boom('link preview thumbnail: proxyUrl is not applied to thumbnail fetches, so the thumbnail is skipped', {
|
|
213
|
+
statusCode: 501
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// One deadline for the whole operation, started before the lookup: a slow
|
|
217
|
+
// resolver is as good a stall as a slow server, and the timeout is
|
|
218
|
+
// documented as bounding the thumbnail, not just its connection.
|
|
219
|
+
const signal = AbortSignal.timeout(fetchOpts.timeout);
|
|
220
|
+
const target = new URL(url, pageUrl);
|
|
221
|
+
await Promise.race([
|
|
222
|
+
assertPublicDestination(target),
|
|
223
|
+
new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason)))
|
|
224
|
+
]);
|
|
225
|
+
const sameOrigin = target.origin === new URL(pageUrl).origin;
|
|
226
|
+
const response = await fetch(target, {
|
|
227
|
+
method: 'GET',
|
|
228
|
+
redirect: 'error',
|
|
229
|
+
signal,
|
|
230
|
+
headers: sameOrigin ? fetchOpts.headers : undefined
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
throw new Boom(`link preview thumbnail: ${target} answered ${response.status}`, { statusCode: response.status });
|
|
234
|
+
}
|
|
235
|
+
const declared = Number(response.headers.get('content-length'));
|
|
236
|
+
if (Number.isFinite(declared) && declared > MAX_THUMBNAIL_BYTES) {
|
|
237
|
+
throw new Boom(`link preview thumbnail: ${declared} bytes is larger than the ${MAX_THUMBNAIL_BYTES} allowed`, {
|
|
238
|
+
statusCode: 413
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// A declared length is a claim, so the body is counted as it arrives.
|
|
242
|
+
const chunks = [];
|
|
243
|
+
let read = 0;
|
|
244
|
+
for await (const chunk of response.body ? Readable.fromWeb(response.body) : []) {
|
|
245
|
+
const bytes = chunk;
|
|
246
|
+
read += bytes.length;
|
|
247
|
+
if (read > MAX_THUMBNAIL_BYTES) {
|
|
248
|
+
throw new Boom(`link preview thumbnail: body exceeded the ${MAX_THUMBNAIL_BYTES} bytes allowed`, {
|
|
249
|
+
statusCode: 413
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
chunks.push(bytes);
|
|
253
|
+
}
|
|
254
|
+
return await extractImageThumb(Buffer.concat(chunks), thumbnailWidth);
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Reads the first URL out of a piece of text and fetches what a link preview
|
|
258
|
+
* needs. Nothing here is protocol: it is an HTTP fetch, an OpenGraph parse and
|
|
259
|
+
* a thumbnail, which is why it belongs in this layer rather than the engine.
|
|
260
|
+
*
|
|
261
|
+
* Resolves to undefined for the two cases that mean "no preview": text with no
|
|
262
|
+
* link in it, and a page with no title. Everything else throws, including a
|
|
263
|
+
* timeout, because a swallowed failure is indistinguishable from a page that
|
|
264
|
+
* genuinely had nothing, and a caller retrying the first would give up on the
|
|
265
|
+
* second.
|
|
266
|
+
*
|
|
267
|
+
* The metadata parse comes from `link-preview-js`, an optional peer dependency
|
|
268
|
+
* this package already declares and had no reader for, so nothing new is
|
|
269
|
+
* pulled in: a consumer who does not want link previews does not install it
|
|
270
|
+
* and never calls this.
|
|
271
|
+
*/
|
|
272
|
+
export const getUrlInfo = async (text, opts = { thumbnailWidth: THUMBNAIL_WIDTH_PX, fetchOpts: { timeout: 3000 } }) => {
|
|
273
|
+
if (opts.fetchOpts.proxyUrl) {
|
|
274
|
+
// Refused rather than half-applied. The page fetch would resolve the
|
|
275
|
+
// host locally, which both fails where only the proxy can resolve and
|
|
276
|
+
// leaks the destination where the proxy was chosen for privacy, and the
|
|
277
|
+
// thumbnail would connect direct. Honouring it needs a proxy dispatcher,
|
|
278
|
+
// which is a dependency this package does not carry.
|
|
279
|
+
throw new Boom('getUrlInfo: proxyUrl is not supported, because neither the metadata fetch nor the thumbnail can be routed through a proxy here, and doing either directly would defeat the reason it was configured', { statusCode: 501 });
|
|
280
|
+
}
|
|
281
|
+
if (opts.uploadImage) {
|
|
282
|
+
throw new Boom('getUrlInfo: uploadImage is not supported, because an upload function here takes plaintext and returns the encrypted result, while this option is typed for one that takes an already-encrypted file. Leave it unset and the thumbnail is generated locally.', { statusCode: 501 });
|
|
283
|
+
}
|
|
284
|
+
// A designed branch rather than a swallowed parser error: text with no link
|
|
285
|
+
// in it has no preview, which is an answer, not a failure. Deciding it here
|
|
286
|
+
// means every error the parser does raise is a real one.
|
|
287
|
+
const previewLink = _firstLink(text);
|
|
288
|
+
if (!previewLink)
|
|
289
|
+
return undefined;
|
|
290
|
+
{
|
|
291
|
+
const { getLinkPreview } = (await import(__rewriteRelativeImportExtension('link-preview-js')));
|
|
292
|
+
// The whole call is raced, not each step inside it. The parser takes its
|
|
293
|
+
// timeout up front but runs the resolver hook before starting that timer,
|
|
294
|
+
// so bounding the lookup and the request separately still let them add
|
|
295
|
+
// up. Racing the call is the only bound the parser cannot walk past.
|
|
296
|
+
//
|
|
297
|
+
// The resolver shares the same expiry rather than getting a timer of its
|
|
298
|
+
// own, so a lookup that outlives the deadline fails the hop instead of
|
|
299
|
+
// letting the parser open a request nobody is waiting for. What it has
|
|
300
|
+
// already opened is left to the parser's own timeout, since it exposes
|
|
301
|
+
// no signal to cancel through.
|
|
302
|
+
const deadline = AbortSignal.timeout(opts.fetchOpts.timeout);
|
|
303
|
+
const expired = new Promise((_resolve, reject) => deadline.addEventListener('abort', () => reject(deadline.reason), { once: true }));
|
|
304
|
+
const info = await Promise.race([
|
|
305
|
+
getLinkPreview(previewLink, {
|
|
306
|
+
...opts.fetchOpts,
|
|
307
|
+
// `manual`, not `follow`: the redirect handler below only runs on
|
|
308
|
+
// manual, so following automatically would send every hop unchecked.
|
|
309
|
+
followRedirects: 'manual',
|
|
310
|
+
// Same site only, so a preview cannot become an open redirect
|
|
311
|
+
// follower. One hop, because that is what the parser offers under
|
|
312
|
+
// manual redirects: it consults this once and fetches once more.
|
|
313
|
+
handleRedirects: (baseURL, forwardedURL) => {
|
|
314
|
+
const from = new URL(baseURL);
|
|
315
|
+
const to = new URL(forwardedURL);
|
|
316
|
+
// Scheme and port too, not the host alone: the follow-up request
|
|
317
|
+
// carries the caller's headers, so a hop to http:// on the same
|
|
318
|
+
// name would put their credentials on the wire in clear, and one
|
|
319
|
+
// to another port would hand them to a different service.
|
|
320
|
+
if (to.protocol !== from.protocol || to.port !== from.port)
|
|
321
|
+
return false;
|
|
322
|
+
return (to.hostname === from.hostname ||
|
|
323
|
+
to.hostname === `www.${from.hostname}` ||
|
|
324
|
+
`www.${to.hostname}` === from.hostname);
|
|
325
|
+
},
|
|
326
|
+
// Resolves the host so the address, not the name, is judged. The
|
|
327
|
+
// parser rejects loopback on what this returns, and the private
|
|
328
|
+
// ranges are rejected here. A redirect brings it back.
|
|
329
|
+
resolveDNSHost: async (target) => await Promise.race([publicAddressOf(new URL(target)), expired]),
|
|
330
|
+
headers: opts.fetchOpts?.headers
|
|
331
|
+
}),
|
|
332
|
+
expired
|
|
333
|
+
]);
|
|
334
|
+
if (!info?.title)
|
|
335
|
+
return undefined;
|
|
336
|
+
const [image] = info.images ?? [];
|
|
337
|
+
const urlInfo = {
|
|
338
|
+
'canonical-url': info.url,
|
|
339
|
+
'matched-text': text,
|
|
340
|
+
title: info.title,
|
|
341
|
+
description: info.description,
|
|
342
|
+
originalThumbnailUrl: image
|
|
343
|
+
};
|
|
344
|
+
if (image) {
|
|
345
|
+
// A preview without its thumbnail is still a preview, so a thumbnail
|
|
346
|
+
// that fails to render is logged rather than losing the whole result.
|
|
347
|
+
try {
|
|
348
|
+
urlInfo.jpegThumbnail = Buffer.from((await _getCompressedJpegThumbnail(image, info.url, opts)).buffer);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
opts.logger?.debug({ err, url: previewLink }, 'error in generating thumbnail');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return urlInfo;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
//# sourceMappingURL=link-preview.js.map
|
package/lib/Utils/messages.d.ts
CHANGED
|
@@ -41,21 +41,34 @@ export declare const normalizeMessageContent: (content: WAMessageContent | null
|
|
|
41
41
|
* Eg. extracts the inner message from a disappearing message/view once message
|
|
42
42
|
*/
|
|
43
43
|
export declare const extractMessageContent: (content: WAMessageContent | undefined | null) => WAMessageContent | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Every field is optional, so upstream's `{ reuploadRequest, logger }` and this
|
|
46
|
+
* package's `{ waClient }` are both accepted.
|
|
47
|
+
*
|
|
48
|
+
* `reuploadRequest` and `logger` are declared for that compatibility and are
|
|
49
|
+
* not read: the engine handles the re-upload request, the CDN failover and the
|
|
50
|
+
* logging itself, so a caller's versions would have nothing to do.
|
|
51
|
+
*/
|
|
44
52
|
export type DownloadMediaMessageContext = {
|
|
45
|
-
reuploadRequest
|
|
46
|
-
logger
|
|
47
|
-
/** Bridge client for media download
|
|
48
|
-
|
|
49
|
-
waClient: Pick<WasmWhatsAppClient, 'downloadMedia' | 'downloadMediaStream'>;
|
|
53
|
+
reuploadRequest?: (msg: WAMessage) => Promise<WAMessage>;
|
|
54
|
+
logger?: ILogger;
|
|
55
|
+
/** Bridge client for media download. Falls back to the registered one. */
|
|
56
|
+
waClient?: Pick<WasmWhatsAppClient, 'downloadMedia' | 'downloadMediaStream'>;
|
|
50
57
|
};
|
|
51
58
|
/**
|
|
52
59
|
* Downloads the given message. Throws an error if it's not a media message.
|
|
53
60
|
*
|
|
54
61
|
* Uses the Rust bridge for download — provides CDN failover, automatic auth
|
|
55
62
|
* refresh on 401/404, HMAC-SHA256 integrity verification, and AES-256-CBC
|
|
56
|
-
* decryption
|
|
63
|
+
* decryption, so a bridge client has to reach it.
|
|
64
|
+
*
|
|
65
|
+
* `ctx` is optional, as upstream has it: without one the client registered by
|
|
66
|
+
* the most recent `makeWASocket` is used, the same fallback
|
|
67
|
+
* `downloadContentFromMessage` offers for standalone calls. A host juggling
|
|
68
|
+
* several sockets should pass `ctx` explicitly, because the registration points
|
|
69
|
+
* at whichever client was created last.
|
|
57
70
|
*/
|
|
58
|
-
export declare const downloadMediaMessage: <Type extends 'buffer' | 'stream'>(message: WAMessage, type: Type, options: MediaDownloadOptions, ctx
|
|
71
|
+
export declare const downloadMediaMessage: <Type extends 'buffer' | 'stream'>(message: WAMessage, type: Type, options: MediaDownloadOptions, ctx?: DownloadMediaMessageContext) => Promise<Type extends "buffer" ? Buffer<ArrayBufferLike> : Readable>;
|
|
59
72
|
export declare const _registerActiveBridgeClient: (client: WasmWhatsAppClient, logger?: ILogger) => void;
|
|
60
73
|
/**
|
|
61
74
|
* Drop the module-level pointer when `sock.end()` frees the client it points
|
package/lib/Utils/messages.js
CHANGED
|
@@ -669,9 +669,20 @@ export const extractMessageContent = (content) => {
|
|
|
669
669
|
*
|
|
670
670
|
* Uses the Rust bridge for download — provides CDN failover, automatic auth
|
|
671
671
|
* refresh on 401/404, HMAC-SHA256 integrity verification, and AES-256-CBC
|
|
672
|
-
* decryption
|
|
672
|
+
* decryption, so a bridge client has to reach it.
|
|
673
|
+
*
|
|
674
|
+
* `ctx` is optional, as upstream has it: without one the client registered by
|
|
675
|
+
* the most recent `makeWASocket` is used, the same fallback
|
|
676
|
+
* `downloadContentFromMessage` offers for standalone calls. A host juggling
|
|
677
|
+
* several sockets should pass `ctx` explicitly, because the registration points
|
|
678
|
+
* at whichever client was created last.
|
|
673
679
|
*/
|
|
674
680
|
export const downloadMediaMessage = async (message, type, options, ctx) => {
|
|
681
|
+
const waClient = ctx?.waClient ?? activeBridgeClient;
|
|
682
|
+
if (!waClient) {
|
|
683
|
+
throw new Boom('downloadMediaMessage: no bridge client available, and the download, its CDN failover and its decryption all happen in the engine. Pass `{ waClient: sock.waClient }`, use `sock.downloadMedia(message, type, options)`, or call after `makeWASocket()` has initialized.', { statusCode: 500 });
|
|
684
|
+
}
|
|
685
|
+
const withClient = { ...ctx, waClient };
|
|
675
686
|
return (await downloadMsg());
|
|
676
687
|
async function downloadMsg() {
|
|
677
688
|
const mContent = extractMessageContent(message.message);
|
|
@@ -703,11 +714,11 @@ export const downloadMediaMessage = async (message, type, options, ctx) => {
|
|
|
703
714
|
toBridgeMediaType(mediaType)
|
|
704
715
|
];
|
|
705
716
|
if (type === 'buffer') {
|
|
706
|
-
const data = await
|
|
717
|
+
const data = await withClient.waClient.downloadMedia(...args);
|
|
707
718
|
return Buffer.from(data);
|
|
708
719
|
}
|
|
709
720
|
// Stream mode: Web ReadableStream from Rust → Node.js Readable
|
|
710
|
-
const webStream =
|
|
721
|
+
const webStream = withClient.waClient.downloadMediaStream(...args);
|
|
711
722
|
return Readable.fromWeb(webStream);
|
|
712
723
|
}
|
|
713
724
|
};
|