-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsentry.mjs
More file actions
141 lines (131 loc) · 4.82 KB
/
Copy pathsentry.mjs
File metadata and controls
141 lines (131 loc) · 4.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// Low-level Sentry / OverDrive HTTP client.
//
// Two hosts matter (both currently resolve to the same edge IP):
// - sentry-read.svc.overdrive.com : headless/mobile API (Bearer JWT auth). Used for
// chip/auth/sync/open. This is the host odmpy uses.
// - dewey-<buid>.listen.libbyapp.com : per-loan audiobook host (openbook manifest + MP3s).
//
// Auth is a Bearer identity JWT obtained from POST /chip. The JWT embeds a `chip`
// claim; crucially `chip.cards` must be non-null for `open` to succeed (see auth.mjs).
//
// NOTE on TLS: on some networks sentry-read.svc.overdrive.com is served by an edge whose
// certificate CN is *.odrsre.overdrive.com (name mismatch). `insecureTLS` disables
// verification for that case only. Leave it off unless you hit ERR_TLS_CERT_ALTNAME_INVALID.
import https from 'node:https';
import { DEFAULT_TIMEOUT_MS } from './http.mjs';
export const READ_HOST = 'sentry-read.svc.overdrive.com';
export const GATEWAY_HOST = 'sentry.libbyapp.com';
// Current live Dewey web-client version. Read from https://libbyapp.com/ ("version: '...'").
// The version is baked into the chip at mint time via the `c=d:<version>` param; a stale
// value causes `403 client_upgrade_required` at open. Bump this if OverDrive updates.
export const CLIENT_VERSION = '22.1.1';
// Default User-Agent mirrors the desktop web client.
const DEFAULT_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
export class SentryError extends Error {
constructor(message, { status, body, result } = {}) {
super(message);
this.name = 'SentryError';
this.status = status;
this.body = body;
this.result = result; // OverDrive's `result` string, e.g. "missing_chip", "whoa"
}
}
/**
* A thin request helper bound to one host, with a shared keep-alive agent so a whole
* flow can travel over a single socket (the browser's chip binding is happier that way).
*/
export class SentryClient {
constructor({
host = READ_HOST,
insecureTLS = false,
userAgent = DEFAULT_UA,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
this.host = host;
this.userAgent = userAgent;
this.timeoutMs = timeoutMs;
this.agent = new https.Agent({
keepAlive: true,
maxSockets: 1,
rejectUnauthorized: !insecureTLS,
});
}
/**
* Perform an HTTPS request. Returns { status, headers, text, json }.
* @param {string} method
* @param {string} path path beginning with "/"
* @param {object} opts { bearer, body, headers, host, raw }
*/
request(method, path, opts = {}) {
const { bearer, body, headers = {}, host = this.host, raw = false } = opts;
const data =
body === undefined ? null : typeof body === 'string' ? body : JSON.stringify(body);
return new Promise((resolve, reject) => {
const req = https.request(
{
host,
path,
method,
agent: this.agent,
timeout: this.timeoutMs,
headers: {
Accept: 'application/json',
'User-Agent': this.userAgent,
Origin: 'https://libbyapp.com',
...(data != null
? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
}
: {}),
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...headers,
},
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
const text = buf.toString('utf8');
let json;
try {
json = JSON.parse(text);
} catch {
json = undefined;
}
resolve({ status: res.statusCode, headers: res.headers, text, json, buffer: buf });
});
},
);
req.on('timeout', () =>
req.destroy(new SentryError(`${method} ${path} timed out after ${this.timeoutMs}ms`)),
);
req.on('error', reject);
if (data != null) req.write(data);
req.end();
});
}
/** Request that throws on non-2xx and surfaces OverDrive's `result` string. */
async requestOk(method, path, opts = {}) {
const res = await this.request(method, path, opts);
if (res.status < 200 || res.status >= 300) {
const result = res.json?.result;
throw new SentryError(
`${method} ${path} -> ${res.status}${result ? ` (${result})` : ''}`,
{ status: res.status, body: res.text, result },
);
}
return res;
}
}
/** Decode a JWT payload (no signature verification — we only read claims). */
export function decodeJwt(jwt) {
try {
return JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
} catch {
return null;
}
}