aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/urlState.js
blob: a3a7e4887adda485a704b9fc5176970987874451 (plain)
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
/**
 * Shareable links: the whole look, packed into the URL fragment.
 *
 * A studio session is a lot of state, and "save a JSON file and send it" is a
 * poor answer to "how do I show you what I made" - one link you can paste into
 * chat is much better. So the state is JSON-encoded, deflated and
 * base64url-encoded into something short enough to sit in a `#` fragment.
 *
 * Three details make it survive the trip:
 *
 *  - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user
 *    loaded). No link can carry that, so `stripForUrl` replaces just that one
 *    field with `null` and keeps everything else - caption text, story panels,
 *    props. Nothing else is ever dropped.
 *
 *  - The payload starts with a version tag ('1' = raw deflate, '0' = plain
 *    bytes) so the decoder knows whether to inflate. A browser without
 *    `CompressionStream` falls back to the uncompressed form rather than
 *    failing to produce a link at all.
 *
 *  - base64url uses `-` and `_` and carries no `=` padding, so the fragment is
 *    safe to paste into chat, Markdown or HTML without being mangled or escaped.
 *
 * `decodeState` is deliberately total: any malformed, truncated or hostile input
 * returns `null` rather than throwing, because it is fed whatever came out of
 * the address bar.
 */

/** Above this length a `data:` background image is a photo, not a link. */
const MAX_INLINE_IMAGE = 2048;

/** Version tags. '1' is the deflated body, '0' the fallback plain body. */
const TAG_DEFLATE = '1';
const TAG_PLAIN = '0';

const BASE64URL = /^[A-Za-z0-9_-]+$/;

/**
 * A copy of `state` that is safe to put in a URL. Only the inline background
 * image is dropped (replaced with `null`), and only when it is a `data:` URL
 * long enough to blow up the link.
 */
export function stripForUrl(state) {
  // A JSON round-trip gives the copy for free and drops anything that could not
  // be serialised anyway, so the link and the live state cannot diverge.
  const copy = JSON.parse(JSON.stringify(state ?? null));
  if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy;

  const image = copy.view && copy.view.backgroundImage;
  if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) {
    copy.view.backgroundImage = null;
  }
  return copy;
}

/** True when the encoded fragment is bigger than a URL can comfortably hold. */
export function isTooLong(text, limit = 1800) {
  return typeof text === 'string' && text.length > limit;
}

/**
 * base64url, without padding. Chunked so a large payload does not blow the
 * argument limit of `String.fromCharCode`.
 */
function toBase64Url(bytes) {
  let binary = '';
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */
function fromBase64Url(text) {
  const base64 = text.replace(/-/g, '+').replace(/_/g, '/');
  const pad = (4 - (base64.length % 4)) % 4;
  const binary = atob(base64 + '='.repeat(pad));
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

/** Read a whole ReadableStream into one Uint8Array. */
async function drain(stream) {
  const chunks = [];
  let length = 0;
  for await (const chunk of stream) {
    const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
    chunks.push(bytes);
    length += bytes.length;
  }
  const out = new Uint8Array(length);
  let offset = 0;
  for (const bytes of chunks) {
    out.set(bytes, offset);
    offset += bytes.length;
  }
  return out;
}

async function deflateRaw(bytes) {
  const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'));
  return drain(stream);
}

async function inflateRaw(bytes) {
  const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
  return drain(stream);
}

/**
 * Serialise, deflate and base64url-encode a state. The result is a fragment
 * payload: it survives a URL, a chat message and an HTML attribute.
 *
 * @param {object} state the studio state
 * @returns {Promise<string>} a URL-safe string (tag + base64url)
 */
export async function encodeState(state) {
  const json = JSON.stringify(stripForUrl(state) ?? null);
  const bytes = new TextEncoder().encode(json);

  if (typeof CompressionStream === 'function') {
    try {
      return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes));
    } catch {
      // Fall through: an unusable compressing stream should not cost the link.
    }
  }
  return TAG_PLAIN + toBase64Url(bytes);
}

/**
 * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed
 * inflate or JSON that is not a plain object all come back as `null`.
 *
 * @param {string} text the fragment payload
 * @returns {Promise<{ state: object } | null>}
 */
export async function decodeState(text) {
  try {
    if (typeof text !== 'string' || text.length < 2) return null;

    const tag = text[0];
    if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null;

    const body = text.slice(1);
    if (!BASE64URL.test(body)) return null;

    let bytes = fromBase64Url(body);
    if (tag === TAG_DEFLATE) {
      if (typeof DecompressionStream !== 'function') return null;
      bytes = await inflateRaw(bytes);
    }

    const parsed = JSON.parse(new TextDecoder().decode(bytes));
    if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
    return { state: parsed };
  } catch {
    return null;
  }
}