All files / src/state shareLink.ts

90.55% Statements 115/127
94.44% Branches 51/54
92.85% Functions 13/14
90.55% Lines 115/127

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400                                                                              2x 2x 2x   2x                                       2x                   30x 30x     30x 30x 30x 30x 30x 30x   25x 25x 25x 25x 25x 25x 25x   30x 30x 30x 30x 30x 30x                     2x   25x 25x 25x 25x 25x 25x 25x 25x                   30x                     30x 30x 30x 30x                                           26x 26x 26x                                   31x 31x 31x 8x 8x 31x 31x                                                                             2x           31x               31x 31x 31x 31x 31x 31x 31x 31x     31x 31x 31x         31x 31x 2x                               31x 31x 3x     31x       31x 31x 31x 31x     25x 25x 25x 25x 25x         4x 4x             21x 25x     25x 25x     2x 82x 82x 35x 10x 10x 10x 10x 3x 3x     2x 10x 10x 10x                     2x 12x 12x     2x 2x 2x 2x                                                                 2x 12x 4x 1x 1x       1x 1x 1x       1x  
/**
 * A whole session in a link.
 *
 * §5.5 says the URL carries *what is being looked at* and `SessionConfig` says
 * the rest — eight tracks with their colours and limits, a set of ticked
 * chromosomes, forty bookmarks — belongs in a file. Both remain true. What was
 * missing is the case between them: **somebody wants to send a colleague what
 * they are looking at**, and neither answer serves it. A link carries none of
 * the work; a file has to be attached, downloaded and dropped, and by the time
 * it has been the moment has passed.
 *
 * ## Why the link holds the session rather than pointing at it
 *
 * A genuinely tiny URL needs a server to hold the state, and C1 says there is no
 * server. That constraint is not negotiable and it is not a limitation here: a
 * link that carries its own contents cannot expire, cannot be taken down, works
 * offline, works in the desktop build, and needs no account from anybody. What
 * it costs is length — a few hundred characters for a plain view, a few thousand
 * for a workspace — and length is the cheapest of those five things to give up.
 *
 * So the session is JSON, deflated, and base64url-encoded into the fragment.
 * **The fragment specifically**: a URL fragment is not sent to the server on
 * navigation, which for a page served from GitHub Pages means the contents of
 * somebody's session never reach GitHub. That is the same privacy property §11
 * claims for the data itself, extended to the description of it.
 *
 * ## And the shortener, which is a different promise
 *
 * A third-party shortener produces a genuinely short link and is offered
 * separately, never automatically, because it breaks two of the five properties
 * above: the link now depends on somebody else's service staying up, and the URL
 * — which names the bucket and the path of the file being viewed — leaves the
 * browser. Both are fine when a reader has decided they are fine. Neither is
 * something to do on their behalf, which is why {@link shortenable} refuses a
 * session over a local file outright: there is nothing on the other end for a
 * recipient to open, so the only effect of shortening it would be to post a
 * filename to a stranger.
 */
 
import { TEXT_INFLATE_MAX, inflateCapped, inflationCap, type InflationPolicy } from '../sources/untrusted'
import { isRefusal, type ConfigRefusal, type SessionConfig } from './SessionConfig'
import { buildWorkspaceFile, parseWorkspaceFile } from './workspace'
import type { MapNames } from '../ui/mapNames'
import { isTracksCellKey } from '../panels/deck'
 
/**
 * The fragment key a shared session travels under.
 *
 * Spelled out rather than abbreviated to `s`. A share link is read by people
 * before it is read by a browser — it is pasted into chat, into a paper, into a
 * mail — and `#session=…` says what the two kilobytes after it are, where `#s=`
 * looked like one of the view state's own short keys and invited exactly the
 * wrong guess about what could be edited by hand. It also keeps the two
 * fragment grammars visibly apart: {@link readShareHash} is asked before
 * `parseViewState` precisely because both are `key=value` pairs in the same
 * place, and a key nobody would coin for a view is a key that cannot collide
 * with one (`state/ViewState.ts` `KEY_ORDER` has no `session`).
 *
 * This is the only spelling written *and* the only one read: a link made by an
 * older build does not open here. That is a deliberate break rather than an
 * oversight — a reader silently accepting `#s=` would have to keep accepting it
 * forever, and the shared links in the wild are days old, not years.
 */
export const SHARE_KEY = 'session'
 
/**
 * Base64url, which is base64 with two characters swapped and the padding gone.
 *
 * `+` and `/` are both legal in a fragment and both are routinely
 * percent-encoded by something in the chain — a chat client, a mail client, a
 * wiki — which turns a working link into a broken one somewhere between the two
 * people. `-` and `_` are not.
 */
function toBase64Url(bytes: Uint8Array): string {
  let binary = ''
  // A chunk at a time: `String.fromCharCode(...bytes)` spreads the whole array
  // into an argument list, and a session of any size overflows the stack.
  const chunk = 0x8000
  for (let at = 0; at < bytes.length; at += chunk) {
    binary += String.fromCharCode(...bytes.subarray(at, at + chunk))
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
 
function fromBase64Url(text: string): Uint8Array {
  const padded = text.replace(/-/g, '+').replace(/_/g, '/')
  const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
  const out = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
  return out
}
 
async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
  const stream = new Blob([bytes as BlobPart])
    .stream()
    .pipeThrough(new CompressionStream('deflate-raw'))
  return new Uint8Array(await new Response(stream).arrayBuffer())
}
 
/**
 * How far a link's payload may inflate: 64 times its size, never under 16 MiB.
 *
 * A link is as untrusted as a file — easier to hand someone, in fact — and a
 * fragment of a megabyte or two, which is as long as a browser takes an address,
 * can stand for a gigabyte or two of deflate's output. A workspace is JSON,
 * which compresses five to twenty times; the cap holds a bomb to sixty-four
 * times its own size and refuses no link a workspace made.
 */
const SHARE_INFLATION: InflationPolicy = { ratio: 64, floor: 16 << 20, ceiling: TEXT_INFLATE_MAX }
 
async function inflate(bytes: Uint8Array): Promise<Uint8Array> {
  return inflateCapped(bytes, {
    format: 'link',
    field: 'workspace',
    encoding: 'deflate-raw',
    maxBytes: inflationCap(bytes.length, SHARE_INFLATION),
  })
}
 
/**
 * A session as the fragment payload.
 *
 * `deflate-raw` rather than `gzip`: the eighteen bytes of gzip header and
 * trailer are two dozen base64 characters, and nothing here needs a checksum —
 * a corrupted fragment fails to parse as JSON, which is the same outcome a CRC
 * mismatch would give and is reached without paying for one.
 */
export async function encodeShare(config: SessionConfig): Promise<string> {
  // A workspace of **one** tab, so the link and the file are the same format
  // even though they carry different amounts of it. The wrapper costs a few
  // dozen bytes after deflate and buys one parser instead of two.
  //
  // One tab and not the window, and that is not an aesthetic call: `lengthAdvice`
  // below already warns past about eight thousand characters, and a single
  // session with eight tracks is a few thousand — a link carrying six tabs would
  // open by telling the reader not to use it. §5.5's rule says the same thing
  // from the other side: a link is *what is being looked at*, and there is one of
  // those on screen. The file is what carries a window.
  const file = buildWorkspaceFile([forSharing(config)], 0, new Date())
  const json = new TextEncoder().encode(JSON.stringify(file))
  return toBase64Url(await deflate(json))
}
 
/**
 * Whether a recipient could address this URI at all.
 *
 * The one question every redaction in this file turns on, named once rather
 * than spelled as a regex at each site: a `https:`, `http:`, `s3:` or `gs:`
 * address means the same thing in anybody's browser, and everything else is a
 * bare filename off the sender's own disk. {@link sharedMapNames} asks it of a
 * map's key; {@link forSharing} asks it of a lane's; {@link shortenable} asks it
 * of the session's own file. Three copies of one predicate is three places for
 * it to drift. Drift one way is a private name leaving the machine; the other
 * way is what happened — the map names kept a copy of their own that predated
 * `gs:`, so a link dropped the reader's name for every map read over `gs://`
 * and the recipient saw file names where the sender had written words.
 *
 * `gs:` joins the list because `sources/rewrite.ts` gave it a destination
 * (SPECS §24.5): a Google Cloud Storage object now resolves to a URL that opens
 * in any browser, so a link carrying one is a link the recipient can follow. It
 * is listed here rather than left to the rewrite because a URI reaches this file
 * as text, and the question is about the text.
 */
function openableElsewhere(uri: string): boolean {
  return /^(https?|s3|gs):\/\//i.test(uri)
}
 
/**
 * The names a **link** may carry: the ones for files a recipient could open.
 *
 * A key is a file's address, and for a local file that address is a bare
 * filename off somebody's own disk. {@link forSharing} already strips the two
 * other fields that carry one — the local second file and a deck's local
 * companions — for a reason that applies here word for word: it is a private
 * name, and posting it to a stranger is what that function exists to prevent.
 *
 * The test is {@link openableElsewhere}, the one the session's own URI is
 * asked, so what travels is exactly what a recipient can address. Here beside
 * it rather than in `ui/mapNames.ts` with the record it reads, because that is
 * where it kept a copy of the test which drifted. Dropping a name costs a label
 * and nothing else, which is the rule that decides every one of these: a
 * private name is stripped where the structure survives without it.
 */
function sharedMapNames(names: MapNames): MapNames {
  const out: MapNames = {}
  for (const [path, name] of Object.entries(names)) {
    if (openableElsewhere(path)) out[path] = name
  }
  return out
}
 
/**
 * A session with the parts a link may not carry taken out.
 *
 * Applied **inside** {@link encodeShare} rather than at its call site, and that
 * placement is the point: a second caller cannot forget it. Three fields go, and
 * all three are already *claimed* to be absent from links by comments elsewhere
 * in this codebase — `SessionConfig.compare` says "a shared link still cannot",
 * and `panels/deck.ts` says spelling a companion's name into one "would post a
 * private filename to a stranger — which `state/shareLink.ts` already refuses to
 * do". Neither was true. One function makes both honest.
 *
 * - **`compare`** and **`deck.locals`** are bare local filenames. A recipient
 *   cannot open them under any resolution, and worse than useless: the grant
 *   store is keyed on the bare name and its guard compares only the name, so a
 *   recipient who has ever granted their own `rep2.cool` would open *their* file
 *   in the sender's slot with every label agreeing (`state/handles.ts`).
 * - **`source`** is the size and modification time of a file on somebody's own
 *   machine. It exists to catch a wrong file locally; in a link it is metadata
 *   about a private disk that the recipient can do nothing with.
 *
 * **`deck.looks`** is the one field that is neither kept nor dropped whole: a
 * look is keyed on the file it describes, so the ones naming local companions go
 * with `locals` and the ones naming remote companions travel. Same rule, applied
 * per member because the field has members of both kinds. **`mapNames`** and
 * **`lanes`** are keyed on a file the same way and are filtered the same way.
 * `lanes` was the one that was missed: it is not named in the object below and
 * so rode through on the `...config` spread, which meant a link could strip the
 * companion and still spell its filename into the fragment — the sender's whole
 * redaction undone by the one field nobody had written a line about.
 *
 * A **local track is kept**, and the rule that decides all four is the one
 * `resolveConfig` states: a private name is stripped only where the structure
 * survives without it. A dropped companion costs a label — `state.maps` still
 * carries `~`, so the slot, the count and the order remain — while a dropped
 * track costs the row, and a recipient silently receiving seven where eight were
 * sent is exactly what that placeholder exists to prevent.
 */
export function forSharing(config: SessionConfig): SessionConfig {
  // `?? {}` because this takes whatever it is handed. Everything `parseConfig`
  // and `buildConfig` produce has a `deck`, but a link is encoded from an object
  // that has passed through other hands, and a share button that threw on a
  // config missing one field would be a worse failure than the one this function
  // exists to prevent.
  const deck = config.deck ?? {}
  // The looks of the companions a recipient can actually open, and only those.
  // A look is keyed on the file it describes (`panels/deck.ts`'s `DeckLook`), so
  // one naming a local companion carries the same bare filename `locals` is
  // stripped for — and `locals` is what says which names those are. A remote
  // companion's clip is the opposite case and worth sending: the recipient opens
  // the same URL, and a deck of four replicates arriving on four different
  // scales is the figure not arriving.
  const local = new Set((deck.locals ?? []).map((held) => held.uri))
  const looks = (deck.looks ?? []).filter((look) => !local.has(look.uri))
  const shared: SessionConfig = {
    ...config,
    deck: {
      ...(deck.starred === undefined ? {} : { starred: deck.starred }),
      ...(deck.columns === undefined ? {} : { columns: deck.columns }),
      ...(looks.length === 0 ? {} : { looks }),
      // Arrangement, like the columns: where the session's own cell stood and
      // whether it was hidden names no file of anybody's (§16.9).
      ...(deck.lead === undefined ? {} : { lead: deck.lead }),
    },
  }
  // The names of the maps a recipient can actually open, and only those: a name
  // is keyed on its map's address, and for a local file that address is a bare
  // filename off the sender's own disk. Same rule as `looks` above, applied per
  // member because this field too has members of both kinds.
  const names = sharedMapNames(config.mapNames ?? {})
  if (Object.keys(names).length === 0) delete shared.mapNames
  else shared.mapNames = names
  // The placements a recipient can act on, and only those. A lane is keyed on
  // the file the track was drawn over, so a track placed on a local companion
  // carries the same bare filename `locals`, `looks` and `mapNames` are stripped
  // for — and it buys the recipient nothing, because the companion it names went
  // with `locals` and the slot arrives vacant.
  //
  // Filtered by the **address** rather than by membership of `deck.locals`,
  // which is the near miss worth spelling out: a look is keyed on `slot.ref.uri`,
  // the same string `DeckLocal.uri` records, but a lane is keyed on
  // `slot.panel?.uri ?? slot.ref.uri` (`panels/PanelDeck.ts`) — so a local
  // companion opened at one rung of a collection is stored here as
  // `rep2.mcool::/resolutions/1000` while `locals` holds `rep2.mcool`, and a
  // name-set test would wave it through.
  // A tracks cell's lanes travel too: their key is the cell's seat (`tracksCellKey`), which names
  // no file of anybody's, and the `tracks` it seats is in `maps` for the recipient to open.
  const lanes = (config.lanes ?? []).filter((lane) => openableElsewhere(lane.uri) || isTracksCellKey(lane.uri))
  if (lanes.length === 0) delete shared.lanes
  else shared.lanes = lanes
  // Where the sender's local files are on the sender's disk (§20.6): a fact
  // about one machine, and nothing a recipient can open.
  delete shared.folders
  // Deleted rather than set to `undefined`: a key holding nothing is a third
  // state, it survives `JSON.stringify` as an absence anyway, and every reader
  // downstream treats absence as the answer.
  delete shared.compare
  delete shared.source
  return shared
}
 
/** The inverse. `null` for anything that is not one, rather than a throw. */
export async function decodeShare(payload: string): Promise<SessionConfig | ConfigRefusal | null> {
  let json: string
  try {
    json = new TextDecoder().decode(await inflate(fromBase64Url(payload)))
  } catch {
    // A truncated link — pasted across two lines by a mail client, most often —
    // reaches here, and the right answer is the picker rather than an error
    // screen about base64. `null` is *this* failure and only this one: the
    // payload did not survive the journey.
    return null
  }
  // Everything past the inflate arrived intact, so a refusal from here is about
  // the *workspace* and is worth passing on rather than flattening into "cut
  // short in transit". A link from a newer build is the case that matters: it
  // travelled perfectly and this build cannot read it, and telling the
  // recipient their link is broken sends them back to the sender for a fresh
  // copy of a link that will fail the same way.
  const read = parseWorkspaceFile(json)
  if (isRefusal(read)) return read
  // One tab is what a link carries, and `parseWorkspaceFile` has already refused
  // a file with none — so this is the tab that was shared.
  return read.tabs[read.active] ?? read.tabs[0]
}
 
/** The payload out of a location hash, or `null` when it carries none. */
export function readShareHash(hash: string): string | null {
  const text = hash.startsWith('#') ? hash.slice(1) : hash
  if (text === '') return null
  for (const part of text.split('&')) {
    const at = part.indexOf('=')
    if (at < 0) continue
    if (part.slice(0, at) === SHARE_KEY) return part.slice(at + 1)
  }
  return null
}
 
/** The whole link, from a payload and the page it is for. */
export function shareUrl(base: string, payload: string): string {
  const [withoutHash] = base.split('#')
  return `${withoutHash}#${SHARE_KEY}=${payload}`
}
 
/**
 * Whether this session is one a third-party shortener should be offered for.
 *
 * A session over a local file names a file on one machine. The long link is
 * still worth having — the recipient gets the tracks, the marks and the
 * bookmarks and is asked for the bytes, which is exactly what a reload already
 * does (§8.6) — but shortening it would send a stranger a filename in exchange
 * for nothing at all.
 */
export function shortenable(config: SessionConfig): boolean {
  return openableElsewhere(config.state.uri)
}
 
/** The service, named in the interface rather than hidden behind a verb. */
export const SHORTENER = {
  name: 'is.gd',
  endpoint: 'https://is.gd/create.php',
} as const
 
/**
 * Ask the shortener for a short link.
 *
 * Two failures are expected and are reported rather than swallowed: the service
 * being unreachable, and the browser refusing the response for want of a CORS
 * header. Neither is recoverable here and neither should lose the reader their
 * long link, so the caller keeps it on screen throughout.
 */
export async function shorten(url: string): Promise<string> {
  const endpoint = `${SHORTENER.endpoint}?format=simple&url=${encodeURIComponent(url)}`
  const response = await fetch(endpoint, { method: 'GET' })
  if (!response.ok) {
    throw new Error(`${SHORTENER.name} refused the link (HTTP ${response.status}).`)
  }
  const short = (await response.text()).trim()
  if (!/^https?:\/\//i.test(short)) {
    throw new Error(`${SHORTENER.name} answered with something that is not a link.`)
  }
  return short
}
 
/**
 * How long the link is, and whether that is a problem yet.
 *
 * There is no limit in any browser that a session approaches — Chrome and
 * Firefox both handle fragments in the megabytes — but a link is rarely used in
 * a browser alone. It is pasted into Slack, into a mail client, into a wiki, and
 * some of those wrap or truncate past a couple of thousand characters. Saying
 * the number is more use than guessing on the reader's behalf: they know where
 * they are about to paste it and this does not.
 */
export function lengthAdvice(length: number): string | null {
  if (length <= 2000) return null
  if (length <= 8000) {
    return (
      `This link is ${length.toLocaleString()} characters. Every browser handles that; some ` +
      'chat and mail clients wrap or truncate past about two thousand, so paste it as a link ' +
      'rather than as text where you can.'
    )
  }
  return (
    `This link is ${length.toLocaleString()} characters, which is long enough that something ` +
    'between here and the recipient will probably break it. Saving the workspace as a file and ' +
    'sending that is the reliable form of the same thing.'
  )
}