@37signals/basecamp 0.18.0 → 0.20.0
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 -2
- package/dist/client.d.ts +6 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -7
- package/dist/client.js.map +1 -1
- package/dist/download.js +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +42 -13
- package/dist/errors.js.map +1 -1
- package/dist/generated/metadata.d.ts +1 -2
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +47 -1
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +48 -3
- package/dist/generated/openapi-stripped.json +816 -60
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +7 -4
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +8 -5
- package/dist/generated/person-id-sites.d.ts +2 -0
- package/dist/generated/person-id-sites.d.ts.map +1 -0
- package/dist/generated/person-id-sites.js +689 -0
- package/dist/generated/person-id-sites.js.map +1 -0
- package/dist/generated/person-id-sites.ts +690 -0
- package/dist/generated/schema.d.ts +643 -12
- package/dist/generated/services/event-feed.d.ts +92 -0
- package/dist/generated/services/event-feed.d.ts.map +1 -0
- package/dist/generated/services/event-feed.js +80 -0
- package/dist/generated/services/event-feed.js.map +1 -0
- package/dist/generated/services/event-feed.ts +153 -0
- package/dist/generated/services/gauges.d.ts +7 -3
- package/dist/generated/services/gauges.d.ts.map +1 -1
- package/dist/generated/services/gauges.js +4 -1
- package/dist/generated/services/gauges.js.map +1 -1
- package/dist/generated/services/gauges.ts +10 -3
- package/dist/generated/services/index.d.ts +1 -1
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +1 -1
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +1 -1
- package/dist/generated/services/lineup.d.ts +13 -0
- package/dist/generated/services/lineup.d.ts.map +1 -1
- package/dist/generated/services/lineup.js +18 -0
- package/dist/generated/services/lineup.js.map +1 -1
- package/dist/generated/services/lineup.ts +26 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/person-id.d.ts +133 -0
- package/dist/person-id.d.ts.map +1 -0
- package/dist/person-id.js +163 -0
- package/dist/person-id.js.map +1 -0
- package/dist/retry.d.ts +2 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +21 -11
- package/dist/retry.js.map +1 -1
- package/dist/services/base.d.ts +35 -0
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +387 -55
- package/dist/services/base.js.map +1 -1
- package/dist/services/comments-extensions.d.ts +89 -0
- package/dist/services/comments-extensions.d.ts.map +1 -0
- package/dist/services/comments-extensions.js +166 -0
- package/dist/services/comments-extensions.js.map +1 -0
- package/dist/services/mentions.d.ts +255 -0
- package/dist/services/mentions.d.ts.map +1 -0
- package/dist/services/mentions.js +1431 -0
- package/dist/services/mentions.js.map +1 -0
- package/dist/services/merge-safe.d.ts +13 -5
- package/dist/services/merge-safe.d.ts.map +1 -1
- package/dist/services/merge-safe.js +71 -13
- package/dist/services/merge-safe.js.map +1 -1
- package/dist/services/recordings-extensions.d.ts +354 -0
- package/dist/services/recordings-extensions.d.ts.map +1 -0
- package/dist/services/recordings-extensions.js +1940 -0
- package/dist/services/recordings-extensions.js.map +1 -0
- package/package.json +4 -4
- package/src/generated/metadata.ts +48 -3
- package/src/generated/openapi-stripped.json +816 -60
- package/src/generated/path-mapping.ts +8 -5
- package/src/generated/person-id-sites.ts +690 -0
- package/src/generated/schema.d.ts +643 -12
- package/src/generated/services/event-feed.ts +153 -0
- package/src/generated/services/gauges.ts +10 -3
- package/src/generated/services/index.ts +1 -1
- package/src/generated/services/lineup.ts +26 -0
- package/dist/generated/services/automation.d.ts +0 -25
- package/dist/generated/services/automation.d.ts.map +0 -1
- package/dist/generated/services/automation.js +0 -33
- package/dist/generated/services/automation.js.map +0 -1
- package/dist/generated/services/automation.ts +0 -49
- package/src/generated/services/automation.ts +0 -49
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention helpers over Basecamp rich text.
|
|
3
|
+
*
|
|
4
|
+
* A mention in Basecamp rich text is a `<bc-attachment>` whose `sgid` attribute
|
|
5
|
+
* is the mentioned person's `attachable_sgid` (doc/api/sections/rich_text.md,
|
|
6
|
+
* "Inserting a mention"). BC3 renders the same tag back with
|
|
7
|
+
* `content-type="application/vnd.basecamp.mention"` and an avatar figure inside
|
|
8
|
+
* it, but the sgid is the only part of the markup that names the person on both
|
|
9
|
+
* the write and the read side, so every helper here works from it. The module
|
|
10
|
+
* exports four, not the two this list carried while it grew:
|
|
11
|
+
*
|
|
12
|
+
* - {@link mentionedPersonIds} reads the person ids a rich text names, by
|
|
13
|
+
* decoding the sgid of every `<bc-attachment>` and keeping the ones that
|
|
14
|
+
* point at a Person.
|
|
15
|
+
* - {@link personIdFromSGID} is the decode under it, for a single sgid.
|
|
16
|
+
* - {@link mentionMarkup} writes the tag for a person, from their
|
|
17
|
+
* `attachable_sgid`.
|
|
18
|
+
* - {@link withMentions} places those tags inside a rich text's first block,
|
|
19
|
+
* skipping a person the content already mentions by that exact sgid.
|
|
20
|
+
*
|
|
21
|
+
* {@link unescapeEntities} and {@link namedEntityNames} are exported for the
|
|
22
|
+
* differential tests and are not part of the SDK's surface.
|
|
23
|
+
*
|
|
24
|
+
* An `attachable_sgid` is a Rails SignedGlobalID: a base64 payload, then `--`,
|
|
25
|
+
* then an HMAC only BC3 can verify. The payload is an envelope carrying the
|
|
26
|
+
* global id — `gid://bc3/Person/1049715915` — as a string, and that string is
|
|
27
|
+
* what these helpers read. They do not (and cannot) verify the signature; what
|
|
28
|
+
* they decode is the same person id BC3 renders into the mention's avatar, read
|
|
29
|
+
* off content the API already served, and a caller that needs the id verified
|
|
30
|
+
* reads the person back through `people.get`.
|
|
31
|
+
*
|
|
32
|
+
* That sets a trust boundary between the two sides. READING —
|
|
33
|
+
* {@link mentionedPersonIds}, {@link personIdFromSGID} — describes what a text
|
|
34
|
+
* says it mentions, and unsigned is fine for description: the ids are reported,
|
|
35
|
+
* not acted on as proof. WRITING — {@link withMentions},
|
|
36
|
+
* `CommentsService.expandMentions` — never treats an unsigned id as proof that
|
|
37
|
+
* a valid mention already exists: a forged or stale sgid in caller-supplied
|
|
38
|
+
* content naming the right id would otherwise make the writer skip the
|
|
39
|
+
* authoritative people read and post a tag Basecamp will not honour, so the
|
|
40
|
+
* person is silently not mentioned. `CommentsService.expandMentions` therefore
|
|
41
|
+
* resolves every requested person through `people.get` and deduplicates only
|
|
42
|
+
* against the exact `attachable_sgid` string that read returned. The pure
|
|
43
|
+
* helpers beneath it — {@link withMentions}, {@link mentionMarkup} — take
|
|
44
|
+
* Person values the caller built and can only check that an sgid is
|
|
45
|
+
* well-formed and names the person it is given, never that it is authentic:
|
|
46
|
+
* hand them people the API returned, not people assembled from content. Do not
|
|
47
|
+
* reuse the read-side helpers to decide whether a write can be skipped.
|
|
48
|
+
*
|
|
49
|
+
* The markup is read as BC3 serves it: a sanitized tree of the tags
|
|
50
|
+
* doc/api/sections/rich_text.md allows, which has no raw-text elements. The tag
|
|
51
|
+
* walk skips comments and quoted attribute values but does not model `<script>`
|
|
52
|
+
* or `<style>` content, which BC3 strips on write; a caller reading mentions
|
|
53
|
+
* out of content it authored itself should not put a `bc-attachment` inside
|
|
54
|
+
* such an element and expect it ignored.
|
|
55
|
+
*
|
|
56
|
+
* The envelope is decoded structurally, never searched as bytes, so a Person
|
|
57
|
+
* gid that merely appears inside some other value — a Document gid built from
|
|
58
|
+
* one, a purpose string that looks like one — is not a mention, and the
|
|
59
|
+
* envelope's purpose must be `attachable`, the one BC3 accepts in rich text.
|
|
60
|
+
* Three envelopes are read: Rails' current Marshal layout
|
|
61
|
+
* `{"_rails" => {"data" => gid, "pur" => purpose}}`, the older Marshal layout
|
|
62
|
+
* `{"gid" => gid, "purpose" => …, "expires_at" => …}`, and the JSON spelling of
|
|
63
|
+
* either, which Rails' JSON message serializer emits.
|
|
64
|
+
*/
|
|
65
|
+
import { Errors } from "../errors.js";
|
|
66
|
+
import { personIdNumber, scanPersonId } from "../person-id.js";
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Reading mentions
|
|
69
|
+
// =============================================================================
|
|
70
|
+
/**
|
|
71
|
+
* The ids of the people a rich text mentions: the Person named by the sgid of
|
|
72
|
+
* each `<bc-attachment>`, in document order, with repeats removed. Attachments
|
|
73
|
+
* that are not mentions — files, images, embeds — are skipped, as is any sgid
|
|
74
|
+
* that does not decode to a Person.
|
|
75
|
+
*
|
|
76
|
+
* This is the read side: a description of what the text says, from sgids whose
|
|
77
|
+
* signatures cannot be checked here. Report it; do not treat an id in it as
|
|
78
|
+
* proof that a valid mention exists (see the trust boundary above).
|
|
79
|
+
*
|
|
80
|
+
* Every `<bc-attachment>` in the text counts, including one inside a
|
|
81
|
+
* `<blockquote>`: BC3 notifies quoted mentions too, so the read matches what
|
|
82
|
+
* the server does with the write.
|
|
83
|
+
*
|
|
84
|
+
* An id a `number` cannot carry — a valid `int64` past
|
|
85
|
+
* `Number.MAX_SAFE_INTEGER` — is SKIPPED, like any other sgid this cannot
|
|
86
|
+
* resolve to a person.
|
|
87
|
+
*
|
|
88
|
+
* That is a real under-report, and it was briefly a throw for that reason: the
|
|
89
|
+
* count is the whole answer here, and dropping an id silently makes a short
|
|
90
|
+
* list indistinguishable from a genuinely shorter text, on a value that feeds
|
|
91
|
+
* `mentioned_person_ids` and decides who gets notified. Measured against the
|
|
92
|
+
* reference, a text naming `7, 9007199254740991, 9007199254740992,
|
|
93
|
+
* 9007199254740993, 9223372036854775807, 0009223372036854775807` gives Go 5 ids
|
|
94
|
+
* and gives this 2.
|
|
95
|
+
*
|
|
96
|
+
* THE THROW WAS WORSE, AND NOT BY A LITTLE. This function's input is rich text
|
|
97
|
+
* off the wire, and `recordingsExtensions.projectRecording` calls it unguarded
|
|
98
|
+
* on server-returned `content`. The sgid's signature is never verified here —
|
|
99
|
+
* `globalIDFromSGID` takes a bare unsigned envelope — so the id inside it is
|
|
100
|
+
* written by whoever wrote the comment. A throw therefore handed any author of
|
|
101
|
+
* a `<bc-attachment>` a way to fail `recordings.summarize()` outright: one
|
|
102
|
+
* crafted mention, and every other mention, the content, the title and the
|
|
103
|
+
* creator all go with it. Refusing to name one person is a smaller loss than
|
|
104
|
+
* refusing to read the recording, and the choice is not the SDK's to make on
|
|
105
|
+
* the caller's behalf when an adversary picks the input.
|
|
106
|
+
*
|
|
107
|
+
* It is also the reference's own failure mode: `MentionedPersonIDs`
|
|
108
|
+
* (`go/pkg/basecamp/mentions.go:82-92`) `continue`s on every sgid
|
|
109
|
+
* `PersonIDFromSGID` declines, and never fails the text. Go simply has fewer
|
|
110
|
+
* ids it must decline, because an `int64` fits its return type.
|
|
111
|
+
*
|
|
112
|
+
* `person-id.ts` argues the same trade the same way for the normalizer — one
|
|
113
|
+
* unrepresentable id must not discard every other record in the body — and this
|
|
114
|
+
* site is the one where the input is untrusted by construction, so it is the
|
|
115
|
+
* last place that argument should have been abandoned. The residual is recorded
|
|
116
|
+
* in SPEC §10 as an availability trade-off rather than hidden.
|
|
117
|
+
*
|
|
118
|
+
* A caller that must not under-report reads {@link readMentions} instead, which
|
|
119
|
+
* returns the same list alongside the ids it could not name. That is the
|
|
120
|
+
* distinguishable signal the skip would otherwise lack, and
|
|
121
|
+
* `recordings.summarize()` surfaces it as `unnameable_mention_ids`.
|
|
122
|
+
*
|
|
123
|
+
*/
|
|
124
|
+
export function mentionedPersonIds(richText) {
|
|
125
|
+
return readMentions(richText).ids;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The mentions a rich text names, and the ones it names that cannot be reported
|
|
129
|
+
* as numbers.
|
|
130
|
+
*
|
|
131
|
+
* {@link mentionedPersonIds} is `readMentions(text).ids`. This is the same walk
|
|
132
|
+
* with the skipped ids kept rather than dropped, so a caller that must not
|
|
133
|
+
* under-report can tell "this text mentions two people" from "this text mentions
|
|
134
|
+
* two people I can name and one I cannot".
|
|
135
|
+
*
|
|
136
|
+
* `unnameable` holds each id as its CANONICAL DECIMAL STRING — no leading zeros,
|
|
137
|
+
* what `strconv.ParseInt` would return — in document order, deduplicated on that
|
|
138
|
+
* value the way the reference deduplicates on the `int64`. So `0009223372036854775807`
|
|
139
|
+
* and `9223372036854775807` are one entry, and `ids.length + unnameable.length`
|
|
140
|
+
* is the count the reference gives. A string because that is the whole point:
|
|
141
|
+
* these are exactly the ids no `number` can carry, so reporting them as numbers
|
|
142
|
+
* would round them into a neighbouring person — the failure this refuses to make.
|
|
143
|
+
* They are real ids, not malformed ones: an sgid that does not decode, or names
|
|
144
|
+
* something other than a Person, or carries a magnitude past `int64`, is not a
|
|
145
|
+
* mention in the reference either and appears in neither list.
|
|
146
|
+
*
|
|
147
|
+
* Nothing here verifies the sgid's signature, so an id in `unnameable` is as
|
|
148
|
+
* untrusted as one in `ids` — see the trust boundary above.
|
|
149
|
+
*/
|
|
150
|
+
export function readMentions(richText) {
|
|
151
|
+
const ids = [];
|
|
152
|
+
const unnameable = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
const seenUnnameable = new Set();
|
|
155
|
+
for (const sgid of bcAttachmentSGIDs(richText)) {
|
|
156
|
+
const mentioned = parsePersonSGID(sgid);
|
|
157
|
+
if (mentioned.kind === "none")
|
|
158
|
+
continue;
|
|
159
|
+
// An id a `number` cannot carry is skipped, not thrown: see above. The
|
|
160
|
+
// input is attacker-written, so failing the whole text here would let one
|
|
161
|
+
// crafted mention take down the recording read that calls this. It is
|
|
162
|
+
// reported here instead, which is what keeps the skip from being silent.
|
|
163
|
+
if (mentioned.kind === "unrepresentable") {
|
|
164
|
+
if (!seenUnnameable.has(mentioned.id)) {
|
|
165
|
+
seenUnnameable.add(mentioned.id);
|
|
166
|
+
unnameable.push(mentioned.id);
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (seen.has(mentioned.id))
|
|
171
|
+
continue;
|
|
172
|
+
seen.add(mentioned.id);
|
|
173
|
+
ids.push(mentioned.id);
|
|
174
|
+
}
|
|
175
|
+
return { ids, unnameable };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A GlobalID URL, split into authority and path.
|
|
179
|
+
*
|
|
180
|
+
* The scheme is matched case-insensitively, as a URL parser matches it. The
|
|
181
|
+
* class is deliberately permissive — what may appear in an authority is decided
|
|
182
|
+
* by {@link isParsableHost}, which models the rules `net/url` actually applies
|
|
183
|
+
* rather than approximating them with a character class.
|
|
184
|
+
*/
|
|
185
|
+
const GID_URL = /^gid:\/\/([^/?#]*)(\/[^?#]*)?(?:[?#][\s\S]*)?$/i;
|
|
186
|
+
/**
|
|
187
|
+
* `url.Parse` refuses a URL containing any control byte, anywhere in the string
|
|
188
|
+
* — `stringContainsCTLByte`, checked before parsing — where a character class
|
|
189
|
+
* on the authority alone would let one through in the query.
|
|
190
|
+
*/
|
|
191
|
+
// oxlint-disable-next-line no-control-regex
|
|
192
|
+
const CONTROL_BYTE = /[\u0000-\u001f\u007f]/;
|
|
193
|
+
/**
|
|
194
|
+
* Whether an authority is one `url.Parse` would accept.
|
|
195
|
+
*
|
|
196
|
+
* Measured against `net/url` rather than inferred, because the earlier version
|
|
197
|
+
* got its central rule backwards: it refused every percent-escape in a host on
|
|
198
|
+
* the strength of a claim that `net/url` does. It does not.
|
|
199
|
+
* `unescape(…, encodeHost)` refuses an escape only when the first hex digit is
|
|
200
|
+
* below 8 — an escape of an ASCII byte — and exempts `%25`, so `b%C3%A9c3` is a
|
|
201
|
+
* host Go accepts and decodes.
|
|
202
|
+
*
|
|
203
|
+
* What it models: userinfo split at the last `@` and held to `validUserinfo`; a
|
|
204
|
+
* host that may not be empty; the optional-port rule; the escape rule above;
|
|
205
|
+
* and — since the bracket sweep — the literal itself, through
|
|
206
|
+
* {@link isIPv6Literal}. This paragraph used to say the opposite, that a
|
|
207
|
+
* bracketed authority was accepted on the port rule alone; it stayed behind
|
|
208
|
+
* when the check landed, which is the more dangerous half of a stale comment:
|
|
209
|
+
* a reader deciding what to trust here would have read a wider contract than
|
|
210
|
+
* the code keeps.
|
|
211
|
+
*
|
|
212
|
+
* An unbracketed authority with SEVERAL colons — `bc3:80:90` — is accepted,
|
|
213
|
+
* and deliberately: `url.Parse` accepts it too (measured; host `bc3:80:90`),
|
|
214
|
+
* because `validOptionalPort` only ever sees the text after the LAST colon.
|
|
215
|
+
* `[::1]:80:90` is refused on both sides by the same rule read from the last
|
|
216
|
+
* bracket.
|
|
217
|
+
*/
|
|
218
|
+
function isParsableHost(authority) {
|
|
219
|
+
// Userinfo comes off first, as `url.Parse` splits it at the LAST "@" before
|
|
220
|
+
// looking at the host at all — otherwise the colon in "user:pass@bc3" reads
|
|
221
|
+
// as a port and the gid is refused, where Go accepts it.
|
|
222
|
+
const at = authority.lastIndexOf("@");
|
|
223
|
+
if (at >= 0 && !isValidUserinfo(authority.slice(0, at)))
|
|
224
|
+
return false;
|
|
225
|
+
const host = at < 0 ? authority : authority.slice(at + 1);
|
|
226
|
+
// An empty host, which is what "gid://user@/Person/1" parses to. `url.Parse`
|
|
227
|
+
// ACCEPTS that — the refusal is `u.Host == ""` in Go's own PersonIDFromSGID,
|
|
228
|
+
// not in net/url, and the distinction matters because a reader re-deriving
|
|
229
|
+
// this from net/url alone would delete the check. Without it the read side
|
|
230
|
+
// INVENTS a mention Go does not report.
|
|
231
|
+
if (host === "")
|
|
232
|
+
return false;
|
|
233
|
+
if (host.startsWith("[")) {
|
|
234
|
+
// `parseHost` takes the LAST "]", checks what follows it as a port, and
|
|
235
|
+
// then validates the literal itself. Accepting any bracketed authority on
|
|
236
|
+
// the port rule alone — which this did — let "[a]", "[]", "[1.2.3.4]" and
|
|
237
|
+
// "[ab]]" all name a person Go refuses: 29 of 48 bracket shapes, every one
|
|
238
|
+
// in the invent direction.
|
|
239
|
+
const close = host.lastIndexOf("]");
|
|
240
|
+
if (close < 0 || !isOptionalPort(host.slice(close + 1)))
|
|
241
|
+
return false;
|
|
242
|
+
return isIPv6Literal(host.slice(1, close));
|
|
243
|
+
}
|
|
244
|
+
if (FORBIDDEN_IN_HOST.test(host))
|
|
245
|
+
return false;
|
|
246
|
+
const colon = host.lastIndexOf(":");
|
|
247
|
+
if (colon >= 0 && !isOptionalPort(host.slice(colon)))
|
|
248
|
+
return false;
|
|
249
|
+
return hasParsableHostEscapes(colon < 0 ? host : host.slice(0, colon));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether a bracketed authority holds something `netip.ParseAddr` accepts.
|
|
253
|
+
*
|
|
254
|
+
* IPv6 only: a bare dotted quad in brackets is refused, and the embedded-IPv4
|
|
255
|
+
* tail is the only place dots may appear. An RFC 6874 zone is spelled `%25`
|
|
256
|
+
* followed by a non-empty zone, and the address before it must still parse —
|
|
257
|
+
* `[a%25eth0]` is refused, as is `[fe80::1%25]` and a zone carrying a malformed
|
|
258
|
+
* escape. Derived by sweeping 48 bracket shapes through Go rather than from the
|
|
259
|
+
* RFC, because the interesting rules are `netip`'s rather than the grammar's.
|
|
260
|
+
*/
|
|
261
|
+
function isIPv6Literal(inner) {
|
|
262
|
+
const zoneAt = inner.indexOf("%25");
|
|
263
|
+
if (zoneAt >= 0) {
|
|
264
|
+
if (!isParsableZone(inner.slice(zoneAt + 3)))
|
|
265
|
+
return false;
|
|
266
|
+
return isIPv6Address(inner.slice(0, zoneAt));
|
|
267
|
+
}
|
|
268
|
+
return isIPv6Address(inner);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether an RFC 6874 zone is one `unescape(…, encodeZone)` accepts.
|
|
272
|
+
*
|
|
273
|
+
* `hasValidEscapes` is the wrong rule here, and that was the defect: `unescape`
|
|
274
|
+
* requires different things per mode, and the zone gets the other one. Swept
|
|
275
|
+
* from Go rather than read off `shouldEscape`: every printable ASCII planted in
|
|
276
|
+
* a zone, and all 256 two-digit escapes.
|
|
277
|
+
*
|
|
278
|
+
* Literals: everything is allowed except the nine below — the host's forbidden
|
|
279
|
+
* set plus `@`. Non-ASCII is allowed (é, CJK, emoji and NBSP all parse); DEL is
|
|
280
|
+
* refused, but as a control byte it never reaches here.
|
|
281
|
+
*
|
|
282
|
+
* Escapes: `%XX` must be two hex digits AND decode into the 85-byte set below —
|
|
283
|
+
* `%20` and `%25` among them, and nothing above U+007F, so 171 of the 256 are
|
|
284
|
+
* refused.
|
|
285
|
+
*/
|
|
286
|
+
const FORBIDDEN_IN_ZONE = /[ @[\\^`{|}]/;
|
|
287
|
+
const ESCAPABLE_IN_ZONE = /^[A-Za-z0-9 !"$%&'()*+,\-.:;<=>[\]_~]$/;
|
|
288
|
+
function isParsableZone(zone) {
|
|
289
|
+
if (zone === "")
|
|
290
|
+
return false;
|
|
291
|
+
for (let i = 0; i < zone.length;) {
|
|
292
|
+
if (zone.charCodeAt(i) === 0x25 /* % */) {
|
|
293
|
+
const high = hexDigit(zone.charCodeAt(i + 1), true);
|
|
294
|
+
const low = hexDigit(zone.charCodeAt(i + 2), true);
|
|
295
|
+
if (high < 0 || low < 0)
|
|
296
|
+
return false;
|
|
297
|
+
if (!ESCAPABLE_IN_ZONE.test(String.fromCharCode(high * 16 + low)))
|
|
298
|
+
return false;
|
|
299
|
+
i += 3;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (FORBIDDEN_IN_ZONE.test(zone[i]))
|
|
303
|
+
return false;
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
function isIPv6Address(text) {
|
|
309
|
+
const compressAt = text.indexOf("::");
|
|
310
|
+
const compressed = compressAt >= 0;
|
|
311
|
+
if (compressed && text.indexOf("::", compressAt + 1) >= 0)
|
|
312
|
+
return false;
|
|
313
|
+
const head = compressed ? text.slice(0, compressAt) : text;
|
|
314
|
+
const tail = compressed ? text.slice(compressAt + 2) : "";
|
|
315
|
+
const parts = [
|
|
316
|
+
...(head === "" ? [] : head.split(":")),
|
|
317
|
+
...(tail === "" ? [] : tail.split(":")),
|
|
318
|
+
];
|
|
319
|
+
// A dotted quad is legal only at the END OF THE ADDRESS. The flattened list
|
|
320
|
+
// is head ++ tail, so its last element is the address's last only when the
|
|
321
|
+
// address does not END with the compression: with a trailing "::" the last
|
|
322
|
+
// element of `parts` sits in FRONT of it, and "1.2.3.4::" is not an address.
|
|
323
|
+
const quadIndex = !compressed || tail !== "" ? parts.length - 1 : -1;
|
|
324
|
+
let groups = 0;
|
|
325
|
+
for (let i = 0; i < parts.length; i++) {
|
|
326
|
+
const part = parts[i];
|
|
327
|
+
if (i === quadIndex && part.includes(".")) {
|
|
328
|
+
if (!isIPv4Address(part))
|
|
329
|
+
return false;
|
|
330
|
+
groups += 2;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!/^[0-9A-Fa-f]{1,4}$/.test(part))
|
|
334
|
+
return false;
|
|
335
|
+
groups += 1;
|
|
336
|
+
}
|
|
337
|
+
// "::" must stand for at least one group; without it every group is spelled.
|
|
338
|
+
return compressed ? groups < 8 : groups === 8;
|
|
339
|
+
}
|
|
340
|
+
function isIPv4Address(text) {
|
|
341
|
+
const octets = text.split(".");
|
|
342
|
+
if (octets.length !== 4)
|
|
343
|
+
return false;
|
|
344
|
+
// No leading zeros: `netip.ParseAddr` refuses "192.168.001.1".
|
|
345
|
+
return octets.every((octet) => /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/.test(octet));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The characters `url.Parse` refuses in an unbracketed host, swept rather than
|
|
349
|
+
* derived: every printable ASCII was planted mid-host and the ones Go rejected
|
|
350
|
+
* are exactly these. `/`, `?` and `#` are already outside the authority group,
|
|
351
|
+
* and `:` is left to the port rule. Note what is NOT here — `"`, `<`, `>` and
|
|
352
|
+
* `]` are all accepted by Go, and an earlier version of this file refused them.
|
|
353
|
+
*/
|
|
354
|
+
const FORBIDDEN_IN_HOST = /[ [\\^`{|}]/;
|
|
355
|
+
/** Whether every `%` in a string introduces two hex digits, as `unescape` requires. */
|
|
356
|
+
function hasValidEscapes(text) {
|
|
357
|
+
for (let i = text.indexOf("%"); i >= 0; i = text.indexOf("%", i + 1)) {
|
|
358
|
+
if (hexDigit(text.charCodeAt(i + 1), true) < 0)
|
|
359
|
+
return false;
|
|
360
|
+
if (hexDigit(text.charCodeAt(i + 2), true) < 0)
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The escapes `unescape(…, encodeHost)` accepts: two hex digits, with the first
|
|
367
|
+
* at least 8 — an escape of a non-ASCII byte — and `%25` exempt.
|
|
368
|
+
*/
|
|
369
|
+
function hasParsableHostEscapes(host) {
|
|
370
|
+
for (let i = host.indexOf("%"); i >= 0; i = host.indexOf("%", i + 1)) {
|
|
371
|
+
const high = hexDigit(host.charCodeAt(i + 1), true);
|
|
372
|
+
const low = hexDigit(host.charCodeAt(i + 2), true);
|
|
373
|
+
if (high < 0 || low < 0)
|
|
374
|
+
return false;
|
|
375
|
+
if (high < 8 && !(high === 2 && low === 5))
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
/** Go's `validUserinfo`: the unreserved set, the sub-delims, and ":~%@". */
|
|
381
|
+
function isValidUserinfo(userinfo) {
|
|
382
|
+
if (!/^[A-Za-z0-9\-._:~!$&'()*+,;=%@]*$/.test(userinfo))
|
|
383
|
+
return false;
|
|
384
|
+
// An escape in userinfo is unescaped too, and a malformed one is an error.
|
|
385
|
+
for (let i = userinfo.indexOf("%"); i >= 0; i = userinfo.indexOf("%", i + 1)) {
|
|
386
|
+
if (hexDigit(userinfo.charCodeAt(i + 1), true) < 0)
|
|
387
|
+
return false;
|
|
388
|
+
if (hexDigit(userinfo.charCodeAt(i + 2), true) < 0)
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The path with its percent-escapes resolved, or `undefined` when one of them
|
|
395
|
+
* is malformed.
|
|
396
|
+
*
|
|
397
|
+
* `url.Parse` decodes the path and refuses an escape that is not two hex
|
|
398
|
+
* digits, so `gid://bc3/Pe%72son/1` names a Person in Go and `…/1%zz` is a
|
|
399
|
+
* parse error. Decoding here rather than refusing every escape is what keeps
|
|
400
|
+
* the two agreeing — and it is the opposite of the rule for the HOST, where Go
|
|
401
|
+
* refuses escapes outright. The escapes resolve to BYTES which are then read as
|
|
402
|
+
* UTF-8, as Go reads them, so a multi-byte character spelled in escapes comes
|
|
403
|
+
* back as itself rather than as its bytes.
|
|
404
|
+
*/
|
|
405
|
+
function percentDecodePath(path) {
|
|
406
|
+
if (!path.includes("%"))
|
|
407
|
+
return path;
|
|
408
|
+
const encoder = new TextEncoder();
|
|
409
|
+
const bytes = [];
|
|
410
|
+
for (let i = 0; i < path.length;) {
|
|
411
|
+
const code = path.charCodeAt(i);
|
|
412
|
+
if (code !== 0x25 /* % */) {
|
|
413
|
+
// Encode the whole unescaped RUN at once. Per code unit, a surrogate pair
|
|
414
|
+
// becomes two lone halves — six replacement bytes instead of the
|
|
415
|
+
// character's four — which nothing downstream reads today and would be a
|
|
416
|
+
// real defect the moment something did.
|
|
417
|
+
let run = i;
|
|
418
|
+
while (run < path.length && path.charCodeAt(run) !== 0x25)
|
|
419
|
+
run++;
|
|
420
|
+
for (const byte of encoder.encode(path.slice(i, run)))
|
|
421
|
+
bytes.push(byte);
|
|
422
|
+
i = run;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const high = hexDigit(path.charCodeAt(i + 1), true);
|
|
426
|
+
const low = hexDigit(path.charCodeAt(i + 2), true);
|
|
427
|
+
if (high < 0 || low < 0)
|
|
428
|
+
return undefined;
|
|
429
|
+
bytes.push(high * 16 + low);
|
|
430
|
+
i += 3;
|
|
431
|
+
}
|
|
432
|
+
return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
|
|
433
|
+
}
|
|
434
|
+
function isOptionalPort(port) {
|
|
435
|
+
return port === "" || (port.startsWith(":") && /^[0-9]*$/.test(port.slice(1)));
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The Person id an `attachable_sgid` names, or `undefined` when the sgid does
|
|
439
|
+
* not decode, or names something other than a Person (a file attachment's sgid
|
|
440
|
+
* names an `ActiveStorage::Blob`).
|
|
441
|
+
*
|
|
442
|
+
* This reads the id out of the sgid's payload; it does not verify the sgid's
|
|
443
|
+
* signature, which only BC3 can. It is a read-side helper: never use its answer
|
|
444
|
+
* to decide that a write may skip the authoritative people read (see the trust
|
|
445
|
+
* boundary in the module comment).
|
|
446
|
+
*/
|
|
447
|
+
export function personIdFromSGID(sgid) {
|
|
448
|
+
const parsed = parsePersonSGID(sgid);
|
|
449
|
+
return parsed.kind === "person" ? parsed.id : undefined;
|
|
450
|
+
}
|
|
451
|
+
const NOT_A_MENTION = { kind: "none" };
|
|
452
|
+
function parsePersonSGID(sgid) {
|
|
453
|
+
const gid = globalIDFromSGID(sgid);
|
|
454
|
+
if (gid === undefined)
|
|
455
|
+
return NOT_A_MENTION;
|
|
456
|
+
// A GlobalID is `gid://<app>/<Model>/<id>`, optionally with a query — BC3
|
|
457
|
+
// mints `gid://bc3/Person/1049715915?expires_in=`. The query and fragment are
|
|
458
|
+
// stripped the way a URL parser strips them, and the path must then be
|
|
459
|
+
// exactly `/<Model>/<id>`: no more, no less.
|
|
460
|
+
// `Parse` cuts the fragment off first and only then refuses control bytes, so
|
|
461
|
+
// a control byte in the fragment is fine and one in the query is not — and
|
|
462
|
+
// the fragment IS unescaped afterwards while the query is left raw, so a
|
|
463
|
+
// malformed escape is an error there and not there. "Anywhere in the string"
|
|
464
|
+
// was wrong in both halves.
|
|
465
|
+
const hash = gid.indexOf("#");
|
|
466
|
+
const beforeFragment = hash < 0 ? gid : gid.slice(0, hash);
|
|
467
|
+
if (CONTROL_BYTE.test(beforeFragment))
|
|
468
|
+
return NOT_A_MENTION;
|
|
469
|
+
if (hash >= 0 && !hasValidEscapes(gid.slice(hash + 1)))
|
|
470
|
+
return NOT_A_MENTION;
|
|
471
|
+
const parsed = GID_URL.exec(gid);
|
|
472
|
+
if (parsed === null)
|
|
473
|
+
return NOT_A_MENTION;
|
|
474
|
+
if (!isParsableHost(parsed[1] ?? ""))
|
|
475
|
+
return NOT_A_MENTION;
|
|
476
|
+
const path = percentDecodePath(parsed[2] ?? "");
|
|
477
|
+
if (path === undefined)
|
|
478
|
+
return NOT_A_MENTION;
|
|
479
|
+
const separator = path.indexOf("/", 1);
|
|
480
|
+
if (separator < 0)
|
|
481
|
+
return NOT_A_MENTION;
|
|
482
|
+
const model = path.slice(1, separator);
|
|
483
|
+
const rawId = path.slice(separator + 1);
|
|
484
|
+
if (model !== "Person" || rawId === "" || !/^[0-9]+$/.test(rawId))
|
|
485
|
+
return NOT_A_MENTION;
|
|
486
|
+
// Go parses the walked digits with `strconv.ParseInt(rawID, 10, 64)` and then
|
|
487
|
+
// refuses `id <= 0` (`go/pkg/basecamp/mentions.go:257-259`), so `scanPersonId`
|
|
488
|
+
// stands in for ParseInt here — NOT as the rule (the digit walk above is the
|
|
489
|
+
// rule, and it is the half that differs from the other two sites), but as the
|
|
490
|
+
// same int64 bound Go applies after it. `Number(rawId)` was the old spelling
|
|
491
|
+
// and gave every one of these answers too; it just could not tell a magnitude
|
|
492
|
+
// past int64, which is not a mention in Go either, from a real person past
|
|
493
|
+
// 2^53, which is.
|
|
494
|
+
const scan = scanPersonId(rawId);
|
|
495
|
+
if (scan.kind !== "value" || scan.value <= 0n)
|
|
496
|
+
return NOT_A_MENTION;
|
|
497
|
+
const id = personIdNumber(scan.value);
|
|
498
|
+
// A BC3 person id is well inside the safe range; one that is not cannot be
|
|
499
|
+
// reported as a number without rounding it into a different person.
|
|
500
|
+
if (id === undefined)
|
|
501
|
+
return { kind: "unrepresentable", id: scan.value.toString() };
|
|
502
|
+
return { kind: "person", id };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The `sgid` attribute of every `<bc-attachment>` in the text, in document
|
|
506
|
+
* order.
|
|
507
|
+
*
|
|
508
|
+
* It walks the markup as a stream of tags rather than pattern-matching for one
|
|
509
|
+
* tag name, so a `<bc-attachment>` inside an HTML comment or inside another
|
|
510
|
+
* element's quoted attribute is not an element; and it tokenizes each tag's
|
|
511
|
+
* attributes rather than pattern-matching them, so a `>` inside a quoted value
|
|
512
|
+
* does not end the tag, an `sgid=` inside another attribute's value is not an
|
|
513
|
+
* attribute, either quote style works, attribute order and case are free, the
|
|
514
|
+
* first `sgid` attribute wins as in HTML, and the character references an sgid
|
|
515
|
+
* can carry are decoded — a narrower set than a browser's, for the reason
|
|
516
|
+
* {@link NAMED_ENTITIES} gives.
|
|
517
|
+
*/
|
|
518
|
+
function bcAttachmentSGIDs(text) {
|
|
519
|
+
const sgids = [];
|
|
520
|
+
let pos = 0;
|
|
521
|
+
while (pos < text.length) {
|
|
522
|
+
const open = text.indexOf("<", pos);
|
|
523
|
+
if (open < 0)
|
|
524
|
+
break;
|
|
525
|
+
pos = open + 1;
|
|
526
|
+
if (text.startsWith("!--", pos)) {
|
|
527
|
+
const stop = text.indexOf("-->", pos);
|
|
528
|
+
if (stop < 0)
|
|
529
|
+
return sgids; // an unterminated comment swallows the rest
|
|
530
|
+
pos = stop + 3;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const lead = text.charCodeAt(pos);
|
|
534
|
+
if (lead === CH_BANG || lead === CH_QUESTION || lead === CH_SLASH) {
|
|
535
|
+
const stop = text.indexOf(">", pos);
|
|
536
|
+
if (stop < 0)
|
|
537
|
+
return sgids;
|
|
538
|
+
pos = stop + 1;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
let nameEnd = pos;
|
|
542
|
+
while (nameEnd < text.length && isTagNameChar(text.charCodeAt(nameEnd)))
|
|
543
|
+
nameEnd++;
|
|
544
|
+
if (nameEnd === pos)
|
|
545
|
+
continue; // a bare "<" in text
|
|
546
|
+
const attrs = parseAttributes(text, nameEnd);
|
|
547
|
+
if (!attrs.closed)
|
|
548
|
+
return sgids; // an unterminated tag: nothing after it is markup
|
|
549
|
+
if (equalsFolded(text.slice(pos, nameEnd), "bc-attachment") && attrs.sgid !== "") {
|
|
550
|
+
sgids.push(attrs.sgid);
|
|
551
|
+
}
|
|
552
|
+
pos = attrs.end;
|
|
553
|
+
}
|
|
554
|
+
return sgids;
|
|
555
|
+
}
|
|
556
|
+
const CH_TAB = 0x09;
|
|
557
|
+
const CH_NEWLINE = 0x0a;
|
|
558
|
+
const CH_FORM_FEED = 0x0c;
|
|
559
|
+
const CH_RETURN = 0x0d;
|
|
560
|
+
const CH_SPACE = 0x20;
|
|
561
|
+
const CH_BANG = 0x21;
|
|
562
|
+
const CH_DOUBLE_QUOTE = 0x22;
|
|
563
|
+
const CH_SINGLE_QUOTE = 0x27;
|
|
564
|
+
const CH_SLASH = 0x2f;
|
|
565
|
+
const CH_LT = 0x3c;
|
|
566
|
+
const CH_EQUALS = 0x3d;
|
|
567
|
+
const CH_GT = 0x3e;
|
|
568
|
+
const CH_QUESTION = 0x3f;
|
|
569
|
+
function isSpace(code) {
|
|
570
|
+
return (code === CH_SPACE ||
|
|
571
|
+
code === CH_TAB ||
|
|
572
|
+
code === CH_NEWLINE ||
|
|
573
|
+
code === CH_RETURN ||
|
|
574
|
+
code === CH_FORM_FEED);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* What may follow `<` in a tag name. The whole name is consumed, punctuation
|
|
578
|
+
* included, so `<bc-attachment:preview` or `<bc-attachment_x` is its own name
|
|
579
|
+
* and never compares equal to `bc-attachment`.
|
|
580
|
+
*/
|
|
581
|
+
function isTagNameChar(code) {
|
|
582
|
+
return (!isSpace(code) &&
|
|
583
|
+
code !== CH_SLASH &&
|
|
584
|
+
code !== CH_GT &&
|
|
585
|
+
code !== CH_LT &&
|
|
586
|
+
code !== CH_EQUALS &&
|
|
587
|
+
code !== CH_DOUBLE_QUOTE &&
|
|
588
|
+
code !== CH_SINGLE_QUOTE);
|
|
589
|
+
}
|
|
590
|
+
function isTagNameEnd(code) {
|
|
591
|
+
return isSpace(code) || code === CH_SLASH || code === CH_GT;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Walks the attributes of an opening tag from `start` (just after the tag name)
|
|
595
|
+
* to its closing `>`. The first `sgid` attribute wins, present-but-empty
|
|
596
|
+
* included, as HTML resolves a repeated attribute.
|
|
597
|
+
*/
|
|
598
|
+
function parseAttributes(text, start) {
|
|
599
|
+
let pos = start;
|
|
600
|
+
let sgid = "";
|
|
601
|
+
let sgidSeen = false;
|
|
602
|
+
while (pos < text.length) {
|
|
603
|
+
while (pos < text.length && (isSpace(text.charCodeAt(pos)) || text.charCodeAt(pos) === CH_SLASH)) {
|
|
604
|
+
pos++;
|
|
605
|
+
}
|
|
606
|
+
if (pos >= text.length)
|
|
607
|
+
return { sgid, end: pos, closed: false };
|
|
608
|
+
if (text.charCodeAt(pos) === CH_GT)
|
|
609
|
+
return { sgid, end: pos + 1, closed: true };
|
|
610
|
+
const nameStart = pos;
|
|
611
|
+
while (pos < text.length) {
|
|
612
|
+
const code = text.charCodeAt(pos);
|
|
613
|
+
if (isSpace(code) || code === CH_EQUALS || code === CH_GT || code === CH_SLASH)
|
|
614
|
+
break;
|
|
615
|
+
pos++;
|
|
616
|
+
}
|
|
617
|
+
const name = text.slice(nameStart, pos);
|
|
618
|
+
while (pos < text.length && isSpace(text.charCodeAt(pos)))
|
|
619
|
+
pos++;
|
|
620
|
+
let value = "";
|
|
621
|
+
if (pos < text.length && text.charCodeAt(pos) === CH_EQUALS) {
|
|
622
|
+
pos++;
|
|
623
|
+
while (pos < text.length && isSpace(text.charCodeAt(pos)))
|
|
624
|
+
pos++;
|
|
625
|
+
const quote = pos < text.length ? text.charCodeAt(pos) : -1;
|
|
626
|
+
if (quote === CH_DOUBLE_QUOTE || quote === CH_SINGLE_QUOTE) {
|
|
627
|
+
pos++;
|
|
628
|
+
const closing = text.indexOf(String.fromCharCode(quote), pos);
|
|
629
|
+
if (closing < 0)
|
|
630
|
+
return { sgid, end: text.length, closed: false };
|
|
631
|
+
value = text.slice(pos, closing);
|
|
632
|
+
pos = closing + 1;
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
const valueStart = pos;
|
|
636
|
+
while (pos < text.length) {
|
|
637
|
+
const code = text.charCodeAt(pos);
|
|
638
|
+
if (isSpace(code) || code === CH_GT)
|
|
639
|
+
break;
|
|
640
|
+
pos++;
|
|
641
|
+
}
|
|
642
|
+
value = text.slice(valueStart, pos);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (name === "") {
|
|
646
|
+
// A stray "=" or quote where a name should be: step over it.
|
|
647
|
+
pos++;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (!sgidSeen && equalsFolded(name, "sgid")) {
|
|
651
|
+
sgidSeen = true;
|
|
652
|
+
sgid = unescapeEntities(value);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return { sgid, end: pos, closed: false };
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The named character references that can change what an sgid decodes to.
|
|
659
|
+
*
|
|
660
|
+
* NOT the full HTML5 table Go's `html.UnescapeString` carries. That table has
|
|
661
|
+
* 2231 names; this is the 23 of them whose expansion consists entirely of
|
|
662
|
+
* characters that can matter here — base64 alphabet, padding, or Go whitespace
|
|
663
|
+
* (which {@link trimGoSpace} strips at either end). The subset was extracted
|
|
664
|
+
* from Go's own `html/entity.go` by filtering on that property, not chosen by
|
|
665
|
+
* eye.
|
|
666
|
+
*
|
|
667
|
+
* Every other name expands to something no base64 payload can contain, so Go
|
|
668
|
+
* expands it and refuses the sgid while this leaves it literal and refuses the
|
|
669
|
+
* sgid: different text, same verdict. The keys carry their terminating `;`
|
|
670
|
+
* exactly as Go's table does, which is also why a name outside this subset can
|
|
671
|
+
* never be shadowed by one inside it — `sol;` is not a prefix of `solb;`. The
|
|
672
|
+
* one name Go lists twice, with and without the semicolon, is listed twice
|
|
673
|
+
* here.
|
|
674
|
+
*
|
|
675
|
+
* Two rows come from Go's SECOND table, `entity2`, whose values are rune PAIRS:
|
|
676
|
+
* `fj` is "fj" — two base64 letters — and `  ` is two spaces. A
|
|
677
|
+
* subset derived by reading only the single-rune table misses them, which is
|
|
678
|
+
* why the enumeration asks `html.UnescapeString` for every value rather than
|
|
679
|
+
* parsing the table's literals.
|
|
680
|
+
*/
|
|
681
|
+
const NAMED_ENTITIES = new Map([
|
|
682
|
+
["AMP;", "&"],
|
|
683
|
+
["AMP", "&"],
|
|
684
|
+
["GT;", ">"],
|
|
685
|
+
["GT", ">"],
|
|
686
|
+
["LT;", "<"],
|
|
687
|
+
["LT", "<"],
|
|
688
|
+
["MediumSpace;", "\u205f"],
|
|
689
|
+
["NewLine;", "\n"],
|
|
690
|
+
["NonBreakingSpace;", "\u00a0"],
|
|
691
|
+
["QUOT;", '"'],
|
|
692
|
+
["QUOT", '"'],
|
|
693
|
+
["Tab;", "\t"],
|
|
694
|
+
["ThickSpace;", "\u205f\u200a"],
|
|
695
|
+
["ThinSpace;", "\u2009"],
|
|
696
|
+
["UnderBar;", "_"],
|
|
697
|
+
["VeryThinSpace;", "\u200a"],
|
|
698
|
+
["amp;", "&"],
|
|
699
|
+
["amp", "&"],
|
|
700
|
+
["apos;", "'"],
|
|
701
|
+
["bne;", "=\u20e5"],
|
|
702
|
+
["emsp13;", "\u2004"],
|
|
703
|
+
["emsp14;", "\u2005"],
|
|
704
|
+
["emsp;", "\u2003"],
|
|
705
|
+
["ensp;", "\u2002"],
|
|
706
|
+
["equals;", "="],
|
|
707
|
+
["fjlig;", "fj"],
|
|
708
|
+
["gt;", ">"],
|
|
709
|
+
["gt", ">"],
|
|
710
|
+
["hairsp;", "\u200a"],
|
|
711
|
+
["lowbar;", "_"],
|
|
712
|
+
["lt;", "<"],
|
|
713
|
+
["lt", "<"],
|
|
714
|
+
["nbsp;", "\u00a0"],
|
|
715
|
+
["nbsp", "\u00a0"],
|
|
716
|
+
["numsp;", "\u2007"],
|
|
717
|
+
["plus;", "+"],
|
|
718
|
+
["puncsp;", "\u2008"],
|
|
719
|
+
["quot;", '"'],
|
|
720
|
+
["quot", '"'],
|
|
721
|
+
["sol;", "/"],
|
|
722
|
+
["thinsp;", "\u2009"],
|
|
723
|
+
]);
|
|
724
|
+
/**
|
|
725
|
+
* The names in {@link NAMED_ENTITIES}, exposed so a test can hold the table to
|
|
726
|
+
* the shape {@link unescapeEntity}'s search bound assumes: `[A-Za-z0-9]+` with
|
|
727
|
+
* an optional `;`. A future row that broke that — a name with a hyphen, say —
|
|
728
|
+
* would silently stop being findable rather than fail to compile.
|
|
729
|
+
*/
|
|
730
|
+
export function namedEntityNames() {
|
|
731
|
+
return [...NAMED_ENTITIES.keys()];
|
|
732
|
+
}
|
|
733
|
+
/** What a character reference's name may be made of, in Go's table and here. */
|
|
734
|
+
function isEntityNameChar(code) {
|
|
735
|
+
return ((code >= 0x30 && code <= 0x39) ||
|
|
736
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
737
|
+
(code >= 0x61 && code <= 0x7a));
|
|
738
|
+
}
|
|
739
|
+
/** The longest key in {@link NAMED_ENTITIES}, so the scan knows where to start. */
|
|
740
|
+
const LONGEST_ENTITY_NAME = Math.max(...[...NAMED_ENTITIES.keys()].map((name) => name.length));
|
|
741
|
+
/**
|
|
742
|
+
* Windows-1252 for U+0080-U+009F, which is what Go substitutes for a numeric
|
|
743
|
+
* reference in that range rather than emitting the C1 control.
|
|
744
|
+
*/
|
|
745
|
+
const WINDOWS_1252 = [
|
|
746
|
+
0x20ac, 0x0081, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160,
|
|
747
|
+
0x2039, 0x0152, 0x008d, 0x017d, 0x008f, 0x0090, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022,
|
|
748
|
+
0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x009d, 0x017e, 0x0178,
|
|
749
|
+
];
|
|
750
|
+
const REPLACEMENT = "\ufffd";
|
|
751
|
+
/**
|
|
752
|
+
* Decodes the character reference at `start` (where the text has `&`), Go's
|
|
753
|
+
* way, returning the replacement text and the index just past what it consumed.
|
|
754
|
+
*
|
|
755
|
+
* The boundary rules are read off `html.UnescapeString` by measurement rather
|
|
756
|
+
* than from its source, because they are not guessable: a decimal reference
|
|
757
|
+
* needs two digits when no `;` follows it and one when it does (`B` is
|
|
758
|
+
* literal, `AB` is `"AB"`, `B` is a tab then `B`); a hex reference needs
|
|
759
|
+
* only one digit either way; `&#x;` with no digits at all is U+FFFD while
|
|
760
|
+
* `&#;` is literal; and a trailing `;` is consumed when present but never
|
|
761
|
+
* required except in those two cases.
|
|
762
|
+
*/
|
|
763
|
+
function unescapeEntity(text, start) {
|
|
764
|
+
const literal = () => ["&", start + 1];
|
|
765
|
+
if (text.charCodeAt(start + 1) !== 0x23 /* # */) {
|
|
766
|
+
// Bound the search by the name characters actually PRESENT before trying
|
|
767
|
+
// any of them. Every key in the table is `[A-Za-z0-9]+` with an optional
|
|
768
|
+
// `;` (pinned by a test), so nothing longer than that run — plus the
|
|
769
|
+
// semicolon, if one follows it — can match.
|
|
770
|
+
//
|
|
771
|
+
// Searching every length down from the longest key instead is correct but
|
|
772
|
+
// costs a slice and a lookup per length for each `&`, whatever follows it:
|
|
773
|
+
// a run of ampersands is the worst case, it is reachable from any attribute
|
|
774
|
+
// an author writes, and it is invisible in an optimised test run. Measured
|
|
775
|
+
// on 400k ampersands: 245ms before, and the shape stays linear either way,
|
|
776
|
+
// so wall clock alone would not have shown it — the constant is the defect.
|
|
777
|
+
let end = start + 1;
|
|
778
|
+
while (end < text.length && isEntityNameChar(text.charCodeAt(end)))
|
|
779
|
+
end++;
|
|
780
|
+
const nameLength = end - (start + 1);
|
|
781
|
+
if (nameLength === 0)
|
|
782
|
+
return literal();
|
|
783
|
+
const semicolon = end < text.length && text.charCodeAt(end) === 0x3b ? 1 : 0;
|
|
784
|
+
for (let length = Math.min(nameLength + semicolon, LONGEST_ENTITY_NAME); length > 0; length--) {
|
|
785
|
+
const replacement = NAMED_ENTITIES.get(text.slice(start + 1, start + 1 + length));
|
|
786
|
+
if (replacement !== undefined)
|
|
787
|
+
return [replacement, start + 1 + length];
|
|
788
|
+
}
|
|
789
|
+
return literal();
|
|
790
|
+
}
|
|
791
|
+
let pos = start + 2;
|
|
792
|
+
const lead = text.charCodeAt(pos);
|
|
793
|
+
const hex = lead === 0x78 || lead === 0x58; /* x X */
|
|
794
|
+
if (hex)
|
|
795
|
+
pos++;
|
|
796
|
+
const digitsStart = pos;
|
|
797
|
+
// Go accumulates into a `rune`, which is an int32 and WRAPS on overflow, and
|
|
798
|
+
// range-checks the wrapped result — so "�" is "A", 65 having gone
|
|
799
|
+
// once round. `| 0` is that same wrap. There is deliberately no cap on the
|
|
800
|
+
// digit count: capping one truncates a zero-padded reference, and
|
|
801
|
+
// "A" is an ordinary way to write "A", not a hostile input.
|
|
802
|
+
let value = 0;
|
|
803
|
+
while (pos < text.length) {
|
|
804
|
+
const digit = hexDigit(text.charCodeAt(pos), hex);
|
|
805
|
+
if (digit < 0)
|
|
806
|
+
break;
|
|
807
|
+
value = (value * (hex ? 16 : 10) + digit) | 0;
|
|
808
|
+
pos++;
|
|
809
|
+
}
|
|
810
|
+
const digits = pos - digitsStart;
|
|
811
|
+
const terminated = pos < text.length && text.charCodeAt(pos) === 0x3b; /* ; */
|
|
812
|
+
if (digits === 0) {
|
|
813
|
+
// `&#x;` is the one zero-digit form Go accepts, as the value zero.
|
|
814
|
+
if (!hex || !terminated)
|
|
815
|
+
return literal();
|
|
816
|
+
}
|
|
817
|
+
else if (!hex && digits === 1 && !terminated) {
|
|
818
|
+
return literal();
|
|
819
|
+
}
|
|
820
|
+
if (terminated)
|
|
821
|
+
pos++;
|
|
822
|
+
if (value >= 0x80 && value <= 0x9f)
|
|
823
|
+
return [String.fromCodePoint(WINDOWS_1252[value - 0x80]), pos];
|
|
824
|
+
// `<= 0`, not `=== 0`: the int32 wrap above can land on a NEGATIVE value, and
|
|
825
|
+
// Go's `utf8.EncodeRune` reads the rune as a `uint32` and writes U+FFFD for
|
|
826
|
+
// anything past MaxRune. Guarding only zero let a negative through to
|
|
827
|
+
// `String.fromCodePoint`, which throws a RangeError — an untyped throw out of
|
|
828
|
+
// a public read helper, on content a server can serve.
|
|
829
|
+
if (value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) {
|
|
830
|
+
return [REPLACEMENT, pos];
|
|
831
|
+
}
|
|
832
|
+
return [String.fromCodePoint(value), pos];
|
|
833
|
+
}
|
|
834
|
+
function hexDigit(code, hex) {
|
|
835
|
+
if (code >= 0x30 && code <= 0x39)
|
|
836
|
+
return code - 0x30;
|
|
837
|
+
if (!hex)
|
|
838
|
+
return -1;
|
|
839
|
+
if (code >= 0x61 && code <= 0x66)
|
|
840
|
+
return code - 0x61 + 10;
|
|
841
|
+
if (code >= 0x41 && code <= 0x46)
|
|
842
|
+
return code - 0x41 + 10;
|
|
843
|
+
return -1;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Decodes the character references an HTML attribute value may carry.
|
|
847
|
+
*
|
|
848
|
+
* Exported for the test that pins the scanner against Go's. It is NOT part of
|
|
849
|
+
* the package surface — `src/index.ts` does not re-export it — and it is
|
|
850
|
+
* exported at all because a test asserting only "this names nobody" is
|
|
851
|
+
* satisfied by any failure that also names nobody, including a decoder that
|
|
852
|
+
* does nothing. Pinning the decoded string is what makes the row fail under the
|
|
853
|
+
* regression it is written to catch.
|
|
854
|
+
*/
|
|
855
|
+
export function unescapeEntities(value) {
|
|
856
|
+
if (!value.includes("&"))
|
|
857
|
+
return value;
|
|
858
|
+
let out = "";
|
|
859
|
+
let pos = 0;
|
|
860
|
+
for (;;) {
|
|
861
|
+
const amp = value.indexOf("&", pos);
|
|
862
|
+
if (amp < 0)
|
|
863
|
+
return out + value.slice(pos);
|
|
864
|
+
out += value.slice(pos, amp);
|
|
865
|
+
const [replacement, next] = unescapeEntity(value, amp);
|
|
866
|
+
out += replacement;
|
|
867
|
+
pos = next;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// =============================================================================
|
|
871
|
+
// SignedGlobalID envelopes
|
|
872
|
+
// =============================================================================
|
|
873
|
+
/**
|
|
874
|
+
* The SignedGlobalID purpose BC3 mints attachable sgids with
|
|
875
|
+
* (doc/api/sections/rich_text.md: `attachable_sgid`). Pinned by the purpose
|
|
876
|
+
* cases in the mention tests, so a rename upstream breaks a test here rather
|
|
877
|
+
* than silently turning every mention invisible.
|
|
878
|
+
*/
|
|
879
|
+
const SGID_PURPOSE_ATTACHABLE = "attachable";
|
|
880
|
+
/**
|
|
881
|
+
* Bounds the decoded sgid payload. A Person sgid's payload is under 200 bytes;
|
|
882
|
+
* the cap keeps a hostile one from costing more than its own size to reject.
|
|
883
|
+
*/
|
|
884
|
+
const MAX_SGID_PAYLOAD_BYTES = 4096;
|
|
885
|
+
/**
|
|
886
|
+
* The same bound on the base64 form (4/3 of the payload, plus padding), checked
|
|
887
|
+
* before anything is allocated.
|
|
888
|
+
*/
|
|
889
|
+
const MAX_SGID_ENCODED_BYTES = Math.floor(MAX_SGID_PAYLOAD_BYTES / 3) * 4 + 4;
|
|
890
|
+
/**
|
|
891
|
+
* Go's `unicode.IsSpace` set, which is what `strings.TrimSpace` trims.
|
|
892
|
+
*
|
|
893
|
+
* Neither a subset nor a superset of what `String.prototype.trim` strips, so
|
|
894
|
+
* neither is usable as-is: JS omits U+0085 (NEL), which Go trims, and JS strips
|
|
895
|
+
* U+FEFF (BOM), which Go does not — because the BOM is a format character, not
|
|
896
|
+
* White_Space. Left to `trim()`, an sgid padded with NEL loses its mention here
|
|
897
|
+
* and keeps it in Go, and one padded with a BOM gains a mention here that Go
|
|
898
|
+
* refuses. Both directions matter: one vanishes a real mention, the other
|
|
899
|
+
* invents one.
|
|
900
|
+
*/
|
|
901
|
+
const GO_SPACE = "\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
|
|
902
|
+
const GO_TRIM = new RegExp(`^[${GO_SPACE}]+|[${GO_SPACE}]+$`, "gu");
|
|
903
|
+
function trimGoSpace(value) {
|
|
904
|
+
return value.replace(GO_TRIM, "");
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* The global id string an sgid's envelope carries.
|
|
908
|
+
*
|
|
909
|
+
* A signed sgid is `<payload>--<digest>`, and `-` is a base64url character, so
|
|
910
|
+
* the payload itself may contain `--`. The separator is therefore the LAST one,
|
|
911
|
+
* as Rails' own verifier reads it; the whole value is tried as a bare payload
|
|
912
|
+
* when that fails, which is what an unsigned envelope — one that happens to
|
|
913
|
+
* contain `--` included — needs.
|
|
914
|
+
*/
|
|
915
|
+
function globalIDFromSGID(sgid) {
|
|
916
|
+
const value = trimGoSpace(sgid);
|
|
917
|
+
const separator = value.lastIndexOf("--");
|
|
918
|
+
if (separator > 0) {
|
|
919
|
+
const gid = envelopeGID(value.slice(0, separator));
|
|
920
|
+
if (gid !== undefined)
|
|
921
|
+
return gid;
|
|
922
|
+
}
|
|
923
|
+
return envelopeGID(value);
|
|
924
|
+
}
|
|
925
|
+
/** Decodes one base64 payload and returns the gid its envelope carries. */
|
|
926
|
+
function envelopeGID(payload) {
|
|
927
|
+
// The bound is applied to the encoded form first, so an oversized sgid costs
|
|
928
|
+
// nothing to refuse — no normalization, no decode buffer.
|
|
929
|
+
if (payload === "" || payload.length > MAX_SGID_ENCODED_BYTES)
|
|
930
|
+
return undefined;
|
|
931
|
+
const raw = decodeBase64(payload);
|
|
932
|
+
if (raw === undefined || raw.length === 0 || raw.length > MAX_SGID_PAYLOAD_BYTES)
|
|
933
|
+
return undefined;
|
|
934
|
+
let envelope;
|
|
935
|
+
if (raw.length >= 2 && raw[0] === 0x04 && raw[1] === 0x08) {
|
|
936
|
+
envelope = unmarshalRuby(raw.subarray(2));
|
|
937
|
+
if (envelope === MARSHAL_FAILED)
|
|
938
|
+
return undefined;
|
|
939
|
+
}
|
|
940
|
+
else if (raw[0] === 0x7b /* { */) {
|
|
941
|
+
try {
|
|
942
|
+
// Non-fatal: Go's JSON decoder substitutes U+FFFD for invalid UTF-8
|
|
943
|
+
// inside a string rather than refusing the document, so an envelope with
|
|
944
|
+
// bad bytes in a field other than the gid decodes on both sides.
|
|
945
|
+
envelope = JSON.parse(new TextDecoder("utf-8").decode(raw));
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
return undefined;
|
|
953
|
+
}
|
|
954
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope))
|
|
955
|
+
return undefined;
|
|
956
|
+
const top = envelope;
|
|
957
|
+
// A SignedGlobalID is bound to a purpose, and only an "attachable" one may be
|
|
958
|
+
// placed in rich text: BC3 refuses any other, so a Person sgid minted for
|
|
959
|
+
// bookmarking or reading is not a mention however valid its gid. Both layouts
|
|
960
|
+
// carry the purpose; an envelope without one is not a Rails envelope.
|
|
961
|
+
//
|
|
962
|
+
// Current layout: {"_rails" => {"data" => gid, "pur" => purpose}}.
|
|
963
|
+
const rails = top["_rails"];
|
|
964
|
+
if (typeof rails === "object" && rails !== null && !Array.isArray(rails)) {
|
|
965
|
+
const inner = rails;
|
|
966
|
+
if (inner["pur"] !== SGID_PURPOSE_ATTACHABLE)
|
|
967
|
+
return undefined;
|
|
968
|
+
const gid = inner["data"];
|
|
969
|
+
return typeof gid === "string" && gid !== "" ? gid : undefined;
|
|
970
|
+
}
|
|
971
|
+
// Older layout: {"gid" => gid, "purpose" => …, "expires_at" => …}.
|
|
972
|
+
if (top["purpose"] !== SGID_PURPOSE_ATTACHABLE)
|
|
973
|
+
return undefined;
|
|
974
|
+
const gid = top["gid"];
|
|
975
|
+
return typeof gid === "string" && gid !== "" ? gid : undefined;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Decodes a base64 payload in either alphabet.
|
|
979
|
+
*
|
|
980
|
+
* Rails' MessageVerifier emits base64url (current) or standard base64, and both
|
|
981
|
+
* decode through the standard alphabet once the two symbols are mapped;
|
|
982
|
+
* stripping the padding lets a truncated-but-valid payload through, and
|
|
983
|
+
* `atob` accepts the unpadded form.
|
|
984
|
+
*/
|
|
985
|
+
function decodeBase64(payload) {
|
|
986
|
+
// The order of these two is Go's, and it is observable. Go right-trims `=`
|
|
987
|
+
// off the string as written and only then decodes, and its decoder ignores CR
|
|
988
|
+
// and LF as it goes — so "MA==" decodes and "MA==\n" does NOT, the trim
|
|
989
|
+
// having stopped at the newline and left an `=` the raw alphabet refuses.
|
|
990
|
+
// Stripping the newlines first would quietly decode that one.
|
|
991
|
+
//
|
|
992
|
+
// The alphabet check afterwards is the other half of the parity: `atob`
|
|
993
|
+
// ignores every ASCII whitespace character, where Go's decoder ignores only
|
|
994
|
+
// CR and LF, so a space or a tab inside an sgid would decode here and be
|
|
995
|
+
// refused there.
|
|
996
|
+
const normalized = payload
|
|
997
|
+
.replace(/-/g, "+")
|
|
998
|
+
.replace(/_/g, "/")
|
|
999
|
+
.replace(/=+$/, "")
|
|
1000
|
+
.replace(/[\r\n]/g, "");
|
|
1001
|
+
if (!/^[A-Za-z0-9+/]*$/.test(normalized))
|
|
1002
|
+
return undefined;
|
|
1003
|
+
let binary;
|
|
1004
|
+
try {
|
|
1005
|
+
binary = atob(normalized);
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
1010
|
+
const bytes = new Uint8Array(binary.length);
|
|
1011
|
+
for (let i = 0; i < binary.length; i++)
|
|
1012
|
+
bytes[i] = binary.charCodeAt(i) & 0xff;
|
|
1013
|
+
return bytes;
|
|
1014
|
+
}
|
|
1015
|
+
// =============================================================================
|
|
1016
|
+
// Ruby Marshal (the subset a SignedGlobalID payload uses)
|
|
1017
|
+
// =============================================================================
|
|
1018
|
+
/**
|
|
1019
|
+
* The sentinel a failed Marshal decode returns.
|
|
1020
|
+
*
|
|
1021
|
+
* A unique object rather than `undefined` or a thrown error: `nil` is a value
|
|
1022
|
+
* the format can legitimately carry, so "failed" needs a token no payload can
|
|
1023
|
+
* produce, and the caller then treats the sgid as undecodable rather than
|
|
1024
|
+
* guessing.
|
|
1025
|
+
*/
|
|
1026
|
+
const MARSHAL_FAILED = Symbol("marshal failed");
|
|
1027
|
+
/** Bounds nesting in a payload; an envelope is two deep. */
|
|
1028
|
+
const RUBY_MARSHAL_MAX_DEPTH = 32;
|
|
1029
|
+
/**
|
|
1030
|
+
* Decodes the subset of Ruby's Marshal 4.8 format a SignedGlobalID payload uses
|
|
1031
|
+
* — nil, booleans, fixnums, strings (with their encoding ivars), symbols and
|
|
1032
|
+
* symbol links, arrays and hashes — into plain JS values. Anything else returns
|
|
1033
|
+
* {@link MARSHAL_FAILED}.
|
|
1034
|
+
*/
|
|
1035
|
+
function unmarshalRuby(data) {
|
|
1036
|
+
const reader = new RubyMarshalReader(data);
|
|
1037
|
+
const value = reader.value(0);
|
|
1038
|
+
if (value === MARSHAL_FAILED)
|
|
1039
|
+
return MARSHAL_FAILED;
|
|
1040
|
+
// A Marshal dump is exactly one value; bytes after it are corruption.
|
|
1041
|
+
if (reader.pos !== data.length)
|
|
1042
|
+
return MARSHAL_FAILED;
|
|
1043
|
+
return value;
|
|
1044
|
+
}
|
|
1045
|
+
class RubyMarshalReader {
|
|
1046
|
+
#data;
|
|
1047
|
+
#symbols = [];
|
|
1048
|
+
#decoder = new TextDecoder("utf-8", { fatal: false });
|
|
1049
|
+
pos = 0;
|
|
1050
|
+
constructor(data) {
|
|
1051
|
+
this.#data = data;
|
|
1052
|
+
}
|
|
1053
|
+
#byte() {
|
|
1054
|
+
if (this.pos >= this.#data.length)
|
|
1055
|
+
return MARSHAL_FAILED;
|
|
1056
|
+
return this.#data[this.pos++];
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Takes the next `n` bytes. The bound is checked against what remains, never
|
|
1060
|
+
* by adding `n` to the position: every length reaches here through
|
|
1061
|
+
* {@link #count}, which already rejected anything past the remaining bytes.
|
|
1062
|
+
*/
|
|
1063
|
+
#bytes(n) {
|
|
1064
|
+
if (n < 0 || n > this.#data.length - this.pos)
|
|
1065
|
+
return MARSHAL_FAILED;
|
|
1066
|
+
const slice = this.#data.subarray(this.pos, this.pos + n);
|
|
1067
|
+
this.pos += n;
|
|
1068
|
+
return slice;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Reads Marshal's packed integer: 0 is 0; 1..4 and -1..-4 are a byte count
|
|
1072
|
+
* for a little-endian value; anything else is the value itself offset by 5.
|
|
1073
|
+
*
|
|
1074
|
+
* Every width the format allows is at most four bytes, so both branches stay
|
|
1075
|
+
* inside the double's exact-integer range and no bitwise 32-bit truncation is
|
|
1076
|
+
* involved.
|
|
1077
|
+
*/
|
|
1078
|
+
#int() {
|
|
1079
|
+
const lead = this.#byte();
|
|
1080
|
+
if (lead === MARSHAL_FAILED)
|
|
1081
|
+
return MARSHAL_FAILED;
|
|
1082
|
+
// The lead byte is a signed int8; widen it into its signed meaning.
|
|
1083
|
+
const c = lead > 127 ? lead - 256 : lead;
|
|
1084
|
+
if (c === 0)
|
|
1085
|
+
return 0;
|
|
1086
|
+
if (c > 4)
|
|
1087
|
+
return c - 5;
|
|
1088
|
+
if (c < -4)
|
|
1089
|
+
return c + 5;
|
|
1090
|
+
const width = c > 0 ? c : -c;
|
|
1091
|
+
const raw = this.#bytes(width);
|
|
1092
|
+
if (raw === MARSHAL_FAILED)
|
|
1093
|
+
return MARSHAL_FAILED;
|
|
1094
|
+
let unsigned = 0;
|
|
1095
|
+
for (let i = 0; i < raw.length; i++)
|
|
1096
|
+
unsigned += raw[i] * 2 ** (8 * i);
|
|
1097
|
+
// A negative packed integer is the same little-endian run sign-extended
|
|
1098
|
+
// with 0xff bytes, which is exactly `unsigned - 256**width`.
|
|
1099
|
+
return c > 0 ? unsigned : unsigned - 2 ** (8 * width);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Reads a length or count — string and symbol bytes, array elements, hash
|
|
1103
|
+
* pairs, ivar pairs — and rejects one that cannot be honest: negative, or
|
|
1104
|
+
* more than the bytes left (every element takes at least one byte).
|
|
1105
|
+
* Allocation follows what actually decodes, so a hostile count costs its own
|
|
1106
|
+
* bytes to refuse, never the capacity it claims.
|
|
1107
|
+
*/
|
|
1108
|
+
#count() {
|
|
1109
|
+
const n = this.#int();
|
|
1110
|
+
if (n === MARSHAL_FAILED)
|
|
1111
|
+
return MARSHAL_FAILED;
|
|
1112
|
+
if (n < 0 || n > this.#data.length - this.pos)
|
|
1113
|
+
return MARSHAL_FAILED;
|
|
1114
|
+
return n;
|
|
1115
|
+
}
|
|
1116
|
+
#string() {
|
|
1117
|
+
const n = this.#count();
|
|
1118
|
+
if (n === MARSHAL_FAILED)
|
|
1119
|
+
return MARSHAL_FAILED;
|
|
1120
|
+
const raw = this.#bytes(n);
|
|
1121
|
+
if (raw === MARSHAL_FAILED)
|
|
1122
|
+
return MARSHAL_FAILED;
|
|
1123
|
+
return this.#decoder.decode(raw);
|
|
1124
|
+
}
|
|
1125
|
+
value(depth) {
|
|
1126
|
+
if (depth > RUBY_MARSHAL_MAX_DEPTH)
|
|
1127
|
+
return MARSHAL_FAILED;
|
|
1128
|
+
const type = this.#byte();
|
|
1129
|
+
if (type === MARSHAL_FAILED)
|
|
1130
|
+
return MARSHAL_FAILED;
|
|
1131
|
+
switch (type) {
|
|
1132
|
+
case 0x30: // '0'
|
|
1133
|
+
return null;
|
|
1134
|
+
case 0x54: // 'T'
|
|
1135
|
+
return true;
|
|
1136
|
+
case 0x46: // 'F'
|
|
1137
|
+
return false;
|
|
1138
|
+
case 0x69: // 'i'
|
|
1139
|
+
return this.#int();
|
|
1140
|
+
case 0x22: // '"'
|
|
1141
|
+
return this.#string();
|
|
1142
|
+
case 0x3a: {
|
|
1143
|
+
// ':' — a symbol, which later occurrences link back to by index.
|
|
1144
|
+
const symbol = this.#string();
|
|
1145
|
+
if (symbol === MARSHAL_FAILED)
|
|
1146
|
+
return MARSHAL_FAILED;
|
|
1147
|
+
this.#symbols.push(symbol);
|
|
1148
|
+
return symbol;
|
|
1149
|
+
}
|
|
1150
|
+
case 0x3b: {
|
|
1151
|
+
// ';' — a symbol link.
|
|
1152
|
+
const index = this.#int();
|
|
1153
|
+
if (index === MARSHAL_FAILED)
|
|
1154
|
+
return MARSHAL_FAILED;
|
|
1155
|
+
if (index < 0 || index >= this.#symbols.length)
|
|
1156
|
+
return MARSHAL_FAILED;
|
|
1157
|
+
return this.#symbols[index];
|
|
1158
|
+
}
|
|
1159
|
+
case 0x49: {
|
|
1160
|
+
// 'I' — an object followed by its instance variables (a String's encoding).
|
|
1161
|
+
const inner = this.value(depth + 1);
|
|
1162
|
+
if (inner === MARSHAL_FAILED)
|
|
1163
|
+
return MARSHAL_FAILED;
|
|
1164
|
+
const n = this.#count();
|
|
1165
|
+
if (n === MARSHAL_FAILED)
|
|
1166
|
+
return MARSHAL_FAILED;
|
|
1167
|
+
for (let i = 0; i < n; i++) {
|
|
1168
|
+
if (this.value(depth + 1) === MARSHAL_FAILED)
|
|
1169
|
+
return MARSHAL_FAILED; // ivar name
|
|
1170
|
+
if (this.value(depth + 1) === MARSHAL_FAILED)
|
|
1171
|
+
return MARSHAL_FAILED; // ivar value
|
|
1172
|
+
}
|
|
1173
|
+
return inner;
|
|
1174
|
+
}
|
|
1175
|
+
case 0x5b: {
|
|
1176
|
+
// '[' — an array.
|
|
1177
|
+
const n = this.#count();
|
|
1178
|
+
if (n === MARSHAL_FAILED)
|
|
1179
|
+
return MARSHAL_FAILED;
|
|
1180
|
+
const out = [];
|
|
1181
|
+
for (let i = 0; i < n; i++) {
|
|
1182
|
+
const element = this.value(depth + 1);
|
|
1183
|
+
if (element === MARSHAL_FAILED)
|
|
1184
|
+
return MARSHAL_FAILED;
|
|
1185
|
+
out.push(element);
|
|
1186
|
+
}
|
|
1187
|
+
return out;
|
|
1188
|
+
}
|
|
1189
|
+
case 0x7b: {
|
|
1190
|
+
// '{' — a hash.
|
|
1191
|
+
const n = this.#count();
|
|
1192
|
+
if (n === MARSHAL_FAILED)
|
|
1193
|
+
return MARSHAL_FAILED;
|
|
1194
|
+
const out = Object.create(null);
|
|
1195
|
+
for (let i = 0; i < n; i++) {
|
|
1196
|
+
const key = this.value(depth + 1);
|
|
1197
|
+
if (key === MARSHAL_FAILED)
|
|
1198
|
+
return MARSHAL_FAILED;
|
|
1199
|
+
const value = this.value(depth + 1);
|
|
1200
|
+
if (value === MARSHAL_FAILED)
|
|
1201
|
+
return MARSHAL_FAILED;
|
|
1202
|
+
if (typeof key !== "string")
|
|
1203
|
+
return MARSHAL_FAILED;
|
|
1204
|
+
out[key] = value;
|
|
1205
|
+
}
|
|
1206
|
+
return out;
|
|
1207
|
+
}
|
|
1208
|
+
default:
|
|
1209
|
+
return MARSHAL_FAILED;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// =============================================================================
|
|
1214
|
+
// Writing mentions
|
|
1215
|
+
// =============================================================================
|
|
1216
|
+
/**
|
|
1217
|
+
* The `<bc-attachment>` that mentions a person, rendered from their
|
|
1218
|
+
* `attachable_sgid` — the write-side form in doc/api/sections/rich_text.md,
|
|
1219
|
+
* which BC3 expands into the avatar figure on read.
|
|
1220
|
+
*
|
|
1221
|
+
* It throws when the person carries no `attachable_sgid`, which is the case for
|
|
1222
|
+
* a Person projection that came from somewhere other than a people read (a
|
|
1223
|
+
* webhook payload, say), and when the sgid does not name the person it is
|
|
1224
|
+
* given. That is all it can check: it cannot verify the signature, so the
|
|
1225
|
+
* Person must come from the API — a `people.get`, a recording's creator or
|
|
1226
|
+
* assignees — not be assembled from an sgid found in content.
|
|
1227
|
+
*
|
|
1228
|
+
* @throws {BasecampError} `usage` when the person cannot be mentioned.
|
|
1229
|
+
*/
|
|
1230
|
+
export function mentionMarkup(person) {
|
|
1231
|
+
if (person === null || typeof person !== "object") {
|
|
1232
|
+
throw Errors.usage("cannot mention a person that is not a person object");
|
|
1233
|
+
}
|
|
1234
|
+
const sgid = person.attachable_sgid;
|
|
1235
|
+
// A STRING. `attachable_sgid` is typed as one, but the value arrives off a
|
|
1236
|
+
// response, and a truthy non-string (a number, an object) passed the presence
|
|
1237
|
+
// check, survived the markup regex — `test` stringifies — and reached the
|
|
1238
|
+
// parser, whose first character read threw a raw TypeError out of a helper
|
|
1239
|
+
// documented to raise a usage error. Go cannot reach this state: its field
|
|
1240
|
+
// decodes as a string or the read fails.
|
|
1241
|
+
if (sgid !== undefined && sgid !== null && typeof sgid !== "string") {
|
|
1242
|
+
throw Errors.usage(`person ${person.id} has an attachable_sgid that is not a string`, "read the person through people.get to obtain one");
|
|
1243
|
+
}
|
|
1244
|
+
if (!sgid) {
|
|
1245
|
+
throw Errors.usage(`person ${person.id} has no attachable_sgid to mention`, "read the person through people.get to obtain one");
|
|
1246
|
+
}
|
|
1247
|
+
if (/["'<>&]/.test(sgid)) {
|
|
1248
|
+
throw Errors.usage(`person ${person.id} has a malformed attachable_sgid`);
|
|
1249
|
+
}
|
|
1250
|
+
// The tag mentions whoever the sgid names. Refuse to write one that names
|
|
1251
|
+
// someone else — or a file — under this person's id, and refuse one that
|
|
1252
|
+
// names nobody at all. The two halves are separate on purpose: comparing only
|
|
1253
|
+
// "names a different person" would pass an undecodable sgid for a person
|
|
1254
|
+
// projection carrying no id, since neither side would be a number.
|
|
1255
|
+
const named = personIdFromSGID(sgid);
|
|
1256
|
+
if (named === undefined || named !== personIdValue(person.id)) {
|
|
1257
|
+
throw Errors.usage(`person ${person.id}'s attachable_sgid does not name that person`, "read the person through people.get to obtain their own");
|
|
1258
|
+
}
|
|
1259
|
+
return `<bc-attachment sgid="${sgid}"></bc-attachment>`;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* The id Go reads off a person the API served, for comparing against the one an
|
|
1263
|
+
* sgid names.
|
|
1264
|
+
*
|
|
1265
|
+
* `Person.Id` is the single `FlexibleInt64` in the generated model, so a person
|
|
1266
|
+
* id that arrives as a JSON STRING of digits *is* that number in Go — with or
|
|
1267
|
+
* without `personable_type`, which is the gap this exists to close. The SDK's
|
|
1268
|
+
* own `normalizePersonIds` rewrites a string id only when `personable_type` is
|
|
1269
|
+
* present, so a body without it reaches here still carrying `"1049715914"`, and
|
|
1270
|
+
* comparing that to the number the sgid names refused a person Go mentions.
|
|
1271
|
+
*
|
|
1272
|
+
* Measured against the reference, not inferred: `"1049715914"` and `"+7"` are
|
|
1273
|
+
* their numbers (`strconv.ParseInt` takes the sign), `"basecamp"`, `" 7"` and
|
|
1274
|
+
* `"7.0"` are all 0 — Go's non-numeric sentinel, which is what this returns for
|
|
1275
|
+
* them because it is what Go reads. No sgid can match it: `PersonIDFromSGID`
|
|
1276
|
+
* refuses a non-positive id, so every sentinel is a refusal by way of a zero
|
|
1277
|
+
* that nothing equals. `"9223372036854775808"` is the one string that fails Go's decode
|
|
1278
|
+
* outright and takes the people read with it; nothing at this layer can fail a
|
|
1279
|
+
* read that already succeeded, so it reads as unreadable and the mention is
|
|
1280
|
+
* refused — a refusal either way, by a different route.
|
|
1281
|
+
*
|
|
1282
|
+
* The grammar itself is {@link scanPersonId}, shared with the pre-decode
|
|
1283
|
+
* normalizer in `base.ts` so the two cannot answer differently about the same
|
|
1284
|
+
* string. Reading `[+-]?\d+` off a regex first and only then checking the
|
|
1285
|
+
* magnitude was the remaining defect here: it puts the whole-string test BEFORE
|
|
1286
|
+
* the magnitude check, where Go's scan interleaves them. `"18446744073709551616x"`
|
|
1287
|
+
* therefore read `0` — the SYSTEM ACTOR — where Go, which overflows u64 before
|
|
1288
|
+
* it ever reaches the `x`, raises a range error and fails the read. Its
|
|
1289
|
+
* neighbour `"18446744073709551615x"` really is a syntax error and really does
|
|
1290
|
+
* read `0`. One digit apart, opposite verdicts; only a scan in Go's order gets
|
|
1291
|
+
* both.
|
|
1292
|
+
*
|
|
1293
|
+
* Exported for `tests/services/mentions.test.ts`, which pins the corpus against
|
|
1294
|
+
* this function rather than against `mentionMarkup`: the two refusals are
|
|
1295
|
+
* INDISTINGUISHABLE through the public path, since `0` and `undefined` both
|
|
1296
|
+
* refuse the mention, so a test that could only see the verdict could not see
|
|
1297
|
+
* the defect above. It is not re-exported from `index.ts` and is not public API.
|
|
1298
|
+
*/
|
|
1299
|
+
export function personIdValue(value) {
|
|
1300
|
+
if (typeof value === "number")
|
|
1301
|
+
return value;
|
|
1302
|
+
if (typeof value !== "string")
|
|
1303
|
+
return undefined;
|
|
1304
|
+
const scan = scanPersonId(value);
|
|
1305
|
+
// Go's sentinel zero for a syntax refusal; the number for a value this
|
|
1306
|
+
// platform can hold; "unreadable" for a range refusal AND for a value past
|
|
1307
|
+
// 2^53, which is the judgment call argued at personIdNumber.
|
|
1308
|
+
if (scan.kind === "syntax")
|
|
1309
|
+
return 0;
|
|
1310
|
+
if (scan.kind === "range")
|
|
1311
|
+
return undefined;
|
|
1312
|
+
return personIdNumber(scan.value);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Whether `text` equals an ASCII `target` under Go's `strings.EqualFold`.
|
|
1316
|
+
*
|
|
1317
|
+
* `toLowerCase()` is not that rule, and the gap is a dropped mention rather
|
|
1318
|
+
* than an invented one: `EqualFold("ſgid", "sgid")` is TRUE — U+017F LATIN
|
|
1319
|
+
* SMALL LETTER LONG S shares an orbit with `s` — so BC3 markup written with one
|
|
1320
|
+
* IS a mention the reference reports, and lowercasing left it out.
|
|
1321
|
+
*
|
|
1322
|
+
* The table is measured, not recalled: sweeping every rune in Unicode against
|
|
1323
|
+
* the 26 ASCII letters and `-` gives exactly two that fold onto an ASCII letter
|
|
1324
|
+
* from outside ASCII, U+017F onto `s` and U+212A KELVIN SIGN onto `k`. Both are
|
|
1325
|
+
* single UTF-16 units, so a unit-by-unit comparison is exact here, and the
|
|
1326
|
+
* target being ASCII is what keeps this from needing full case folding.
|
|
1327
|
+
*
|
|
1328
|
+
* This is the HTML half of the port's folding. The `gid` scheme is compared
|
|
1329
|
+
* ASCII-only, because `url.Parse` has already excluded everything else by the
|
|
1330
|
+
* time a scheme is read — one file, two rules, and carrying either answer to
|
|
1331
|
+
* the other site is wrong.
|
|
1332
|
+
*
|
|
1333
|
+
* Two of the three call sites cannot observe the difference: neither
|
|
1334
|
+
* `bc-attachment` nor `<p`/`<div` contains an `s` or a `k`, so `toLowerCase()`
|
|
1335
|
+
* is exactly equivalent there and a revert of either reads as behaviour-neutral
|
|
1336
|
+
* in the tests. They use this anyway, so the rule lives in one place and the
|
|
1337
|
+
* next literal to gain an `s` does not have to be noticed.
|
|
1338
|
+
*
|
|
1339
|
+
* And it is deliberately no WIDER than the orbit table. "Same when upper-cased"
|
|
1340
|
+
* would accept `sgıd` — `"ı".toUpperCase()` is "I" — which `EqualFold` refuses;
|
|
1341
|
+
* that pair is pinned, because a too-wide fold invents mentions exactly where a
|
|
1342
|
+
* too-narrow one drops them.
|
|
1343
|
+
*/
|
|
1344
|
+
const FOLDS_ONTO_ASCII = new Map([
|
|
1345
|
+
[0x017f, 0x73 /* s */],
|
|
1346
|
+
[0x212a, 0x6b /* k */],
|
|
1347
|
+
]);
|
|
1348
|
+
function equalsFolded(text, target) {
|
|
1349
|
+
if (text.length !== target.length)
|
|
1350
|
+
return false;
|
|
1351
|
+
for (let i = 0; i < target.length; i++) {
|
|
1352
|
+
const want = target.charCodeAt(i);
|
|
1353
|
+
const code = text.charCodeAt(i);
|
|
1354
|
+
if (code === want)
|
|
1355
|
+
continue;
|
|
1356
|
+
// ASCII case, then the two orbits that reach ASCII from outside it.
|
|
1357
|
+
if (code >= 0x41 && code <= 0x5a && code + 0x20 === want)
|
|
1358
|
+
continue;
|
|
1359
|
+
if (FOLDS_ONTO_ASCII.get(code) === want)
|
|
1360
|
+
continue;
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* The index just past the opening `<p …>` or `<div …>` tag a rich text starts
|
|
1367
|
+
* with, or `-1` when it starts with anything else, so mentions can be placed
|
|
1368
|
+
* inside the first block rather than as a bare prefix in front of it. The tag's
|
|
1369
|
+
* attributes are scanned quote-aware: a `>` inside an attribute value does not
|
|
1370
|
+
* end it.
|
|
1371
|
+
*/
|
|
1372
|
+
function leadingBlockEnd(content) {
|
|
1373
|
+
let i = 0;
|
|
1374
|
+
while (i < content.length && isSpace(content.charCodeAt(i)))
|
|
1375
|
+
i++;
|
|
1376
|
+
for (const name of ["<p", "<div"]) {
|
|
1377
|
+
if (content.length < i + name.length)
|
|
1378
|
+
continue;
|
|
1379
|
+
if (!equalsFolded(content.slice(i, i + name.length), name))
|
|
1380
|
+
continue;
|
|
1381
|
+
const after = i + name.length;
|
|
1382
|
+
if (after < content.length && !isTagNameEnd(content.charCodeAt(after)))
|
|
1383
|
+
continue;
|
|
1384
|
+
const attrs = parseAttributes(content, after);
|
|
1385
|
+
return attrs.closed ? attrs.end : -1;
|
|
1386
|
+
}
|
|
1387
|
+
return -1;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Content that mentions each of the given people, for posting as a comment or a
|
|
1391
|
+
* Campfire line.
|
|
1392
|
+
*
|
|
1393
|
+
* A person whose exact `attachable_sgid` the content already carries is left
|
|
1394
|
+
* alone, so passing the same person twice — or a person the author already
|
|
1395
|
+
* mentioned with that sgid — never duplicates the mention; the rest are added
|
|
1396
|
+
* at the start of the content, inside its first `<p>` or `<div>` when it opens
|
|
1397
|
+
* with one, so they render on the first line rather than as a block of their
|
|
1398
|
+
* own.
|
|
1399
|
+
*
|
|
1400
|
+
* This is the write side, and it deduplicates on the sgid string alone, never
|
|
1401
|
+
* on the person id an existing tag's sgid decodes to: that id is unsigned, and
|
|
1402
|
+
* a forged or stale tag naming the right person must not stand in for the real
|
|
1403
|
+
* mention (see the trust boundary in the module comment). Every person needs
|
|
1404
|
+
* their own `attachable_sgid`, and it must be one the API returned: this helper
|
|
1405
|
+
* can check that an sgid is well-formed and names the person, not that it is
|
|
1406
|
+
* authentic (see {@link mentionMarkup}). The account-bound
|
|
1407
|
+
* `CommentsService.expandMentions` resolves ids to people first and is the
|
|
1408
|
+
* entry point that carries that guarantee.
|
|
1409
|
+
*
|
|
1410
|
+
* @throws {BasecampError} `usage` when a person cannot be mentioned.
|
|
1411
|
+
*/
|
|
1412
|
+
export function withMentions(content, people) {
|
|
1413
|
+
const present = new Set(bcAttachmentSGIDs(content));
|
|
1414
|
+
const tags = [];
|
|
1415
|
+
for (const person of people) {
|
|
1416
|
+
// Every person is validated, duplicates included: a caller passing an
|
|
1417
|
+
// unmentionable person twice hears about it either way.
|
|
1418
|
+
const tag = mentionMarkup(person);
|
|
1419
|
+
const sgid = person.attachable_sgid;
|
|
1420
|
+
if (present.has(sgid))
|
|
1421
|
+
continue;
|
|
1422
|
+
present.add(sgid);
|
|
1423
|
+
tags.push(tag);
|
|
1424
|
+
}
|
|
1425
|
+
if (tags.length === 0)
|
|
1426
|
+
return content;
|
|
1427
|
+
const prefix = tags.join(" ") + " ";
|
|
1428
|
+
const end = leadingBlockEnd(content);
|
|
1429
|
+
return end >= 0 ? content.slice(0, end) + prefix + content.slice(end) : prefix + content;
|
|
1430
|
+
}
|
|
1431
|
+
//# sourceMappingURL=mentions.js.map
|