oxedyne/daimond/www/js/chunks.js
50.2 KiB, 1 run
created by r2519314175:1347, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — content-addressed chunk transport (chunks.js) |
| 3 | ------------------------------------------------------------ |
| 4 | The large half of a user's work. Cross-device sync ships a |
| 5 | small encrypted manifest through /api/sync; a file too large to |
| 6 | sit inside that parcel is offloaded here instead, split into |
| 7 | content-addressed chunks the gateway holds but cannot read. |
| 8 | |
| 9 | A file is read from disk in slices and each slice is sealed on its |
| 10 | own with the account's AES-GCM key, then addressed by the SHA-256 |
| 11 | of its ciphertext — the one hash WebCrypto and the Rust gateway |
| 12 | both compute, so the gateway can verify an upload without ever |
| 13 | opening it. To recover the file, each piece is fetched, decrypted |
| 14 | and written straight out. |
| 15 | |
| 16 | NOTHING HOLDS A WHOLE FILE, on either path. That is what allows a |
| 17 | file far larger than the tab could carry, and what allows a file |
| 18 | that is not text at all: there is no string step anywhere here. |
| 19 | Peak memory is one chunk plus one upload batch. |
| 20 | |
| 21 | STABLE ADDRESSES. A seal draws a fresh IV each call, so encrypting |
| 22 | the same bytes twice yields different ciphertext and a different |
| 23 | address. Without help, changing one byte of a large file would |
| 24 | re-upload all of it. So a map from plaintext chunk hash to stored |
| 25 | address lets an unchanged chunk keep its address; only what really |
| 26 | changed is sealed again. The map is a cache, checked against the |
| 27 | gateway before use, so a stale entry costs one upload and never a |
| 28 | missing file. |
| 29 | |
| 30 | TIERS. A commit tags each file free or paid. This matters at one |
| 31 | moment and it is the worst one: at the end of grace the gateway |
| 32 | evicts the paid tier and keeps the free one, so tagging everything |
| 33 | paid would lose a lapsed account its whole store rather than its |
| 34 | overflow. The plan comes from cloud.js, most recently used first, |
| 35 | drawn against the allowance the gateway reports. |
| 36 | |
| 37 | THE SWEEP FLOOR. A commit declares the live set and the gateway |
| 38 | deletes everything it does not name, which is the most destructive |
| 39 | thing the gateway does on this file's say-so — and it used to obey |
| 40 | without question. It no longer will: a sweep that would take more |
| 41 | than half the account's chunks deletes NOTHING and comes back |
| 42 | `sweep_held_back`/`sweep_held`/`sweep_token`. See `commit` for what |
| 43 | this client does with that, and why it does not simply say yes. |
| 44 | |
| 45 | AND WHAT IT LEAVES STANDING IS NOW SOMETHING A PERSON CAN ANSWER. The |
| 46 | notice in the rail's status strip is a button: it asks, and then re-sends |
| 47 | the parked commit with its token. The parked commit is written to localStorage, |
| 48 | because only the gateway can mint that token and a reload used to throw |
| 49 | it away — leaving chunks nobody refers to in a store nobody sweeps, on |
| 50 | an account that is billed for them. |
| 51 | ============================================================ */ |
| 52 | (function () { |
| 53 | 'use strict'; |
| 54 | |
| 55 | var PATH = '/api/chunk'; |
| 56 | var HAVE_BATCH = 64; // Upload at most this many pieces per request. |
| 57 | |
| 58 | function log(/* ...args */) { |
| 59 | try { if (window.console && console.debug) console.debug.apply(console, ['[chunks]'].concat([].slice.call(arguments))); } |
| 60 | catch (e) { /* ignore */ } |
| 61 | } |
| 62 | |
| 63 | // ── Byte helpers ─────────────────────────────────────────── |
| 64 | |
| 65 | /// Unpadded base64url of a byte array, matching the gateway's |
| 66 | /// `util::b64url_encode` (URL_SAFE_NO_PAD). |
| 67 | function b64urlEncode(bytes) { |
| 68 | // In blocks, not byte by byte: a chunk can be megabytes now, and appending |
| 69 | // a character at a time to build the binary string is the slowest part of |
| 70 | // an upload. The block size stays well under the argument limit of |
| 71 | // `apply`, which is what a single call would otherwise hit. |
| 72 | var bin = '', CH = 0x8000; |
| 73 | for (var i = 0; i < bytes.length; i += CH) { |
| 74 | bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH)); |
| 75 | } |
| 76 | return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 77 | } |
| 78 | |
| 79 | /// Bytes from an unpadded base64url string. |
| 80 | function b64urlDecode(s) { |
| 81 | var t = s.replace(/-/g, '+').replace(/_/g, '/'); |
| 82 | while (t.length % 4) t += '='; |
| 83 | var bin = atob(t), out = new Uint8Array(bin.length); |
| 84 | for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 85 | return out; |
| 86 | } |
| 87 | |
| 88 | /// Lowercase-hex SHA-256 of a byte array, the content address. |
| 89 | async function sha256Hex(bytes) { |
| 90 | var d = await crypto.subtle.digest('SHA-256', bytes); |
| 91 | var b = new Uint8Array(d), s = ''; |
| 92 | for (var i = 0; i < b.length; i++) { |
| 93 | s += (b[i] >>> 4).toString(16); |
| 94 | s += (b[i] & 15).toString(16); |
| 95 | } |
| 96 | return s; |
| 97 | } |
| 98 | |
| 99 | // ── Transport ────────────────────────────────────────────── |
| 100 | // Like sync.js: a private wrapper returning {status, json}; a chunk op's |
| 101 | // 4xx is an outcome to read, not an error to throw. |
| 102 | // |
| 103 | // THROUGH `DaimondGateway.gwFetch`, WHICH IS THE ONE COPY OF THE 401 RULE. |
| 104 | // This file called `fetch` directly and did nothing whatever about a lapsed |
| 105 | // session — the sixth copy of a wrapper that five siblings each answered a |
| 106 | // 401 in, and the worst of the six to have left out. The gateway's session |
| 107 | // lives an hour and nothing renewed it, so an hour into a sitting every op |
| 108 | // here was refused: `missing()` reports EVERY address as missing, so the next |
| 109 | // sync re-encrypts and re-uploads the whole corpus; `putChunks()` throws |
| 110 | // `chunk put failed: 401`; and the commit that lets the gateway sweep never |
| 111 | // lands, so the account's garbage is never collected. None of it was said |
| 112 | // anywhere. |
| 113 | // |
| 114 | // SAFE TO REPEAT. `handle_impl` (gateway/src/handlers/chunk.rs) takes the |
| 115 | // session in its FIRST statement — before the method check, before the body |
| 116 | // is parsed, before `op` is even read — so a 401 is proof that nothing |
| 117 | // happened: nothing stored, nothing swept, no index recorded. The second |
| 118 | // attempt cannot duplicate a side effect the first never had, and the body is |
| 119 | // a string, so the options object is reused as given. |
| 120 | // |
| 121 | // LATE-BOUND, NEVER CAPTURED. `DaimondGateway` is looked up on the global at |
| 122 | // every call rather than held in a local at load. index.html loads gateway.js |
| 123 | // at 790 and this file at 791, so the order holds today; a property lookup |
| 124 | // means it goes on holding whatever that order becomes, and it is what lets |
| 125 | // the renewal be replaced without every caller keeping a reference to the old |
| 126 | // one. `clientApi()` is read the same way: this file used to carry its own |
| 127 | // copy of the number, and two constants that have to match are two constants |
| 128 | // that will eventually not. |
| 129 | async function call(body) { |
| 130 | var r = await DaimondGateway.gwFetch(PATH, { |
| 131 | method: 'POST', |
| 132 | credentials: 'same-origin', |
| 133 | headers: { |
| 134 | 'content-type': 'application/json', |
| 135 | 'x-daimond-api': String(DaimondGateway.clientApi()), |
| 136 | }, |
| 137 | body: JSON.stringify(body), |
| 138 | }); |
| 139 | var j = null; |
| 140 | try { j = await r.json(); } catch (e) { j = null; } |
| 141 | return { status: r.status, json: j }; |
| 142 | } |
| 143 | |
| 144 | /// Of the given addresses, those the gateway does not hold. |
| 145 | async function missing(addrs) { |
| 146 | if (!addrs.length) return []; |
| 147 | var res = await call({ op: 'have', addrs: addrs }); |
| 148 | if (res.status !== 200 || !res.json || !Array.isArray(res.json.missing)) return addrs.slice(); |
| 149 | return res.json.missing; |
| 150 | } |
| 151 | |
| 152 | /// Upload a batch of {addr, blob} pieces. |
| 153 | /// |
| 154 | /// THE GATEWAY'S OWN SENTENCE IS KEPT. This threw `chunk put failed: 507` and |
| 155 | /// dropped `res.json.error` on the floor, which is the difference between a |
| 156 | /// person being told "This account has reached its cloud storage limit. Delete |
| 157 | /// something, or ask for more room." and being shown a number they cannot look |
| 158 | /// up. The gateway composes four such sentences on this one route — two 507s |
| 159 | /// (the account's ceiling and the store's), a 503 and a 413 — and each of them |
| 160 | /// names the remedy, which a status code by definition cannot. Same shape as |
| 161 | /// every other refusal in this app: `(j && j.error) || ('HTTP ' + status)`, |
| 162 | /// see gateway.js, tools.js, mail.js and web.js. |
| 163 | /// |
| 164 | /// AND IT IS SAID, not merely thrown. `collectChunked` in daimond.js catches |
| 165 | /// every offload failure and discards it ("retry next sync, index unharmed"), |
| 166 | /// so an exception carrying a perfect sentence still reaches nobody. This file |
| 167 | /// already owns a chip and an event for standing facts; a refused upload is |
| 168 | /// one, and the most consequential kind, because the user's work has stopped |
| 169 | /// travelling. `standRefused` is what puts it in front of them. |
| 170 | async function putChunks(chunks) { |
| 171 | for (var i = 0; i < chunks.length; i += HAVE_BATCH) { |
| 172 | var slice = chunks.slice(i, i + HAVE_BATCH); |
| 173 | var res = await call({ op: 'put', chunks: slice }); |
| 174 | if (res.status !== 200 || !res.json || !res.json.ok) { |
| 175 | var msg = (res.json && (res.json.error || res.json.message)) |
| 176 | || ('HTTP ' + res.status); |
| 177 | standRefused(msg, res.status); |
| 178 | var e = new Error(msg); |
| 179 | e.status = res.status; // for a caller that wants to branch on it. |
| 180 | throw e; |
| 181 | } |
| 182 | } |
| 183 | // A batch that landed is proof the refusal has lifted: the ceiling was |
| 184 | // raised, or something was deleted, or the store stopped being busy. |
| 185 | // Nothing else clears it, because nothing else knows. |
| 186 | clearRefused(); |
| 187 | } |
| 188 | |
| 189 | /// Fetch one chunk's ciphertext bytes by address, or null if the gateway no |
| 190 | /// longer holds it (evicted overflow, say). |
| 191 | async function getChunk(addr) { |
| 192 | var res = await call({ op: 'get', addr: addr }); |
| 193 | if (res.status !== 200 || !res.json || !res.json.present || !res.json.blob) return null; |
| 194 | return b64urlDecode(res.json.blob); |
| 195 | } |
| 196 | |
| 197 | // ── The chunk map ────────────────────────────────────────── |
| 198 | // Plaintext chunk hash -> the content address its ciphertext was stored |
| 199 | // under. Because a seal draws a fresh IV every time, encrypting the same |
| 200 | // bytes twice yields different ciphertext and a different address; without |
| 201 | // this map, changing one byte of a large file would re-upload all of it. |
| 202 | // With it, only the chunks that actually changed are re-encrypted. |
| 203 | // |
| 204 | // It is a CACHE, never a source of truth: an address is used only after the |
| 205 | // gateway confirms it still holds it, so a stale entry costs one re-upload |
| 206 | // and never a missing file. |
| 207 | // Entries are `plaintextHash -> [address, ciphertextSize]`. The size is kept |
| 208 | // because a reused chunk is never re-encrypted, so its length would otherwise |
| 209 | // be unknown — and the manifest's sizes are what the gateway bills against. |
| 210 | var MAP_KEY = 'daimond-chunk-map'; |
| 211 | var MAP_MAX = 5000; // entries; roughly 600 KB of localStorage, which is shared. |
| 212 | |
| 213 | function readMap() { |
| 214 | try { return JSON.parse(localStorage.getItem(MAP_KEY) || '{}') || {}; } |
| 215 | catch (e) { return {}; } |
| 216 | } |
| 217 | function writeMap(m) { |
| 218 | var keys = Object.keys(m); |
| 219 | if (keys.length > MAP_MAX) { |
| 220 | // No access times here, so drop the oldest insertions -- object key |
| 221 | // order. Losing an entry costs a re-upload, nothing worse. |
| 222 | var trimmed = {}; |
| 223 | keys.slice(keys.length - MAP_MAX).forEach(function (k) { trimmed[k] = m[k]; }); |
| 224 | m = trimmed; |
| 225 | } |
| 226 | try { localStorage.setItem(MAP_KEY, JSON.stringify(m)); } catch (e) { /* quota: rebuilt next time */ } |
| 227 | } |
| 228 | |
| 229 | // ── Surviving a passphrase change ────────────────────────── |
| 230 | // |
| 231 | // THIS FILE SEALS AT REST, NOT IN FLIGHT, so it takes part. `offloadFile` |
| 232 | // wraps each chunk with `DaimondIdentity.wrapBytes` and the ciphertext then |
| 233 | // LIVES ON THE GATEWAY, addressed by its own hash, for as long as the file is |
| 234 | // in the cloud store. A passphrase change re-derives the key, and every chunk |
| 235 | // already up there is sealed under the old one for ever. |
| 236 | // |
| 237 | // What this participant can do, and what it cannot, stated plainly because the |
| 238 | // gap is the interesting part: |
| 239 | // |
| 240 | // - IT CANNOT RE-WRAP THEM. That would mean downloading, decrypting and |
| 241 | // re-uploading the whole corpus, which can be gigabytes, over a dialog the |
| 242 | // user is waiting on — and it could not finish the job anyway, since a |
| 243 | // file this device does not hold locally cannot be re-sealed from here at |
| 244 | // all. |
| 245 | // - IT MUST NOT LET THE OLD CIPHERTEXT BE REUSED. The map from plaintext |
| 246 | // chunk hash to stored address is what makes an unchanged chunk keep its |
| 247 | // address, and the gateway still holds every one of them — so without this, |
| 248 | // a re-offload would cheerfully point the new manifest at chunks nothing |
| 249 | // can open, and the file would stay unreadable no matter how many times it |
| 250 | // was synced. The map goes. |
| 251 | // - AND THE FILES MUST ACTUALLY BE OFFLOADED AGAIN, or dropping the map |
| 252 | // changes nothing: `collectChunked` in daimond.js skips a file whose |
| 253 | // manifest matches its size and mtime, which is every file that has not |
| 254 | // been edited. So the drop is recorded, and that one skip is suspended for |
| 255 | // the next sync round only. The price of a passphrase change is one |
| 256 | // re-upload of the large files this device holds. It is paid once. |
| 257 | // |
| 258 | // What remains lost is honest to say: a large file that no device still holds |
| 259 | // locally cannot be recovered after a passphrase change, because the only copy |
| 260 | // is sealed under a key nobody has any more. |
| 261 | |
| 262 | /// Set for as long as a re-offload is owed. In localStorage, not a variable, |
| 263 | /// because the change and the sync that answers it are usually separated by a |
| 264 | /// reload. |
| 265 | var STALE_KEY = 'daimond-chunk-stale'; |
| 266 | |
| 267 | /// Is a re-offload owed after a passphrase change? |
| 268 | function staleSinceRekey() { |
| 269 | try { return localStorage.getItem(STALE_KEY) === '1'; } catch (e) { return false; } |
| 270 | } |
| 271 | |
| 272 | /// The re-offload has been made. Called by the round that made it, never by |
| 273 | /// the round that owed it. |
| 274 | function clearStale() { |
| 275 | try { localStorage.removeItem(STALE_KEY); } catch (e) { /* private mode */ } |
| 276 | } |
| 277 | |
| 278 | /// Forget every address sealed under the passphrase that has just gone. |
| 279 | /// |
| 280 | /// Nothing is read out beforehand: the map holds no secret, only hashes, and |
| 281 | /// the plaintext is the file on disk. |
| 282 | function forgetMapAfterRekey() { |
| 283 | try { localStorage.removeItem(MAP_KEY); } catch (e) { /* private mode: nothing was cached */ } |
| 284 | try { localStorage.setItem(STALE_KEY, '1'); } catch (e) { /* quota: one skipped re-offload */ } |
| 285 | log('passphrase changed: chunk map dropped, large files will be offloaded again'); |
| 286 | return { failed: [] }; |
| 287 | } |
| 288 | |
| 289 | if (window.DaimondRekey) { |
| 290 | DaimondRekey.register({ |
| 291 | name: 'chunks', |
| 292 | reseal: forgetMapAfterRekey, |
| 293 | }); |
| 294 | } |
| 295 | |
| 296 | // ── Offload ──────────────────────────────────────────────── |
| 297 | |
| 298 | /// The chunk size for a file of `size` bytes. |
| 299 | /// |
| 300 | /// Small chunks localise an edit; large ones keep the manifest short, and the |
| 301 | /// manifest travels inside the sync blob, which has its own ceiling. A |
| 302 | /// gigabyte at 256 KiB would be four thousand entries. The largest must stay |
| 303 | /// under what the gateway accepts for one chunk. |
| 304 | function chunkSizeFor(size) { |
| 305 | if (size <= 64 * 1024 * 1024) return 256 * 1024; |
| 306 | if (size <= 512 * 1024 * 1024) return 1024 * 1024; |
| 307 | return 4 * 1024 * 1024; |
| 308 | } |
| 309 | |
| 310 | /// How many ciphertext bytes to gather before sending a batch. Bounds both |
| 311 | /// the request and what is held in memory at once. |
| 312 | var UPLOAD_BATCH_BYTES = 4 * 1024 * 1024; |
| 313 | |
| 314 | /// Offload one file from disk, a piece at a time, and return its manifest. |
| 315 | /// |
| 316 | /// `file` is a File (from an OPFS handle), so the bytes are read in slices |
| 317 | /// and NOTHING here ever holds the whole thing: peak memory is one chunk plus |
| 318 | /// one upload batch. That is what lets a file be far larger than the tab |
| 319 | /// could otherwise carry, and what lets it be binary — there is no text step |
| 320 | /// anywhere in this path. |
| 321 | /// |
| 322 | /// Two passes over the file. The first hashes each plaintext chunk, which |
| 323 | /// both fingerprints the file and finds the chunks already in the store. The |
| 324 | /// second encrypts and uploads only what is genuinely missing. |
| 325 | async function offloadFile(path, file) { |
| 326 | var size = file.size; |
| 327 | var CH = chunkSizeFor(size); |
| 328 | var n = Math.max(1, Math.ceil(size / CH)); |
| 329 | var map = readMap(); |
| 330 | |
| 331 | // Pass one: fingerprint every chunk. |
| 332 | var phash = [], i, off, len; |
| 333 | for (i = 0; i < n; i++) { |
| 334 | off = i * CH; |
| 335 | len = Math.min(CH, size - off); |
| 336 | var slice = new Uint8Array(await file.slice(off, off + Math.max(0, len)).arrayBuffer()); |
| 337 | phash.push(await sha256Hex(slice)); |
| 338 | } |
| 339 | // The file's identity is the hash of its chunk hashes -- computed without |
| 340 | // ever holding the file, which a plain hash of the contents could not be. |
| 341 | var key = await sha256Hex(new TextEncoder().encode(phash.join(''))); |
| 342 | |
| 343 | // Which of the addresses we think we already have does the gateway still |
| 344 | // hold? Anything it has swept must be re-encrypted and sent again. |
| 345 | var known = []; |
| 346 | phash.forEach(function (h) { if (map[h]) known.push(map[h][0]); }); |
| 347 | var gone = {}; |
| 348 | (await missing(known)).forEach(function (a) { gone[a] = 1; }); |
| 349 | |
| 350 | // Pass two: encrypt and upload only what is missing. |
| 351 | var chunks = [], batch = [], batchBytes = 0, reused = 0; |
| 352 | for (i = 0; i < n; i++) { |
| 353 | var have = map[phash[i]]; |
| 354 | if (have && !gone[have[0]]) { |
| 355 | chunks.push({ addr: have[0], size: have[1] | 0 }); |
| 356 | reused++; |
| 357 | continue; |
| 358 | } |
| 359 | off = i * CH; |
| 360 | len = Math.min(CH, size - off); |
| 361 | var plain = new Uint8Array(await file.slice(off, off + Math.max(0, len)).arrayBuffer()); |
| 362 | var ct = await DaimondIdentity.wrapBytes(plain); |
| 363 | var addr = await sha256Hex(ct); |
| 364 | map[phash[i]] = [addr, ct.length]; |
| 365 | chunks.push({ addr: addr, size: ct.length }); |
| 366 | batch.push({ addr: addr, blob: b64urlEncode(ct) }); |
| 367 | batchBytes += ct.length; |
| 368 | if (batchBytes >= UPLOAD_BATCH_BYTES) { await putChunks(batch); batch = []; batchBytes = 0; } |
| 369 | } |
| 370 | if (batch.length) await putChunks(batch); |
| 371 | writeMap(map); |
| 372 | |
| 373 | log('offloaded', path, n, 'chunks,', reused, 'reused'); |
| 374 | return { |
| 375 | v: 2, |
| 376 | size: size, // plaintext bytes, so a reader knows the file. |
| 377 | key: key, |
| 378 | chunks: chunks, |
| 379 | }; |
| 380 | } |
| 381 | |
| 382 | /// Offload an in-memory byte array and return its manifest, the same shape |
| 383 | /// and the same stable-address machinery `offloadFile` gives a file on disk. |
| 384 | /// |
| 385 | /// A Blob answers everything `offloadFile` asks of its argument — `.size`, |
| 386 | /// `.slice(a, b)` returning something with `.arrayBuffer()` — so the whole |
| 387 | /// two-pass body is reused verbatim: the plaintext-hash→address map still |
| 388 | /// makes an unchanged payload keep its addresses, which is what lets a |
| 389 | /// Diamond or a transcript that has not moved produce a byte-identical |
| 390 | /// manifest between two collects. The one thing a Blob has not got is a name, |
| 391 | /// so `label` is passed only for the log line. |
| 392 | /// |
| 393 | /// Peak memory is the bytes plus one chunk plus one upload batch. The caller |
| 394 | /// is expected to hand this ONE item's bytes at a time and let them go, so a |
| 395 | /// store of many Diamonds is never in memory at once — that is the whole |
| 396 | /// reason the payload moves out of the inline parcel. |
| 397 | async function offloadBytes(label, bytes) { |
| 398 | return await offloadFile(label, new Blob([bytes])); |
| 399 | } |
| 400 | |
| 401 | // ── Materialise ──────────────────────────────────────────── |
| 402 | |
| 403 | /// Recover the bytes an `offloadBytes` manifest names: each chunk is fetched, |
| 404 | /// unsealed on its own and appended, and the joined array returned — or null |
| 405 | /// if any piece is no longer held, so the caller leaves the item absent |
| 406 | /// rather than acting on a truncated one. |
| 407 | /// |
| 408 | /// The seal-per-chunk twin of `materialiseV1`, which recovers the original |
| 409 | /// whole-file scheme where one seal covered the lot. Only ONE item's chunks |
| 410 | /// are held here, so this is affordable for a single Diamond or transcript |
| 411 | /// and is never asked for the whole store at once. |
| 412 | async function materialiseBytes(manifest) { |
| 413 | if (!manifest || !Array.isArray(manifest.chunks)) return null; |
| 414 | var parts = [], total = 0; |
| 415 | for (var i = 0; i < manifest.chunks.length; i++) { |
| 416 | var bytes = await getChunk(manifest.chunks[i].addr); |
| 417 | if (bytes === null) { log('materialiseBytes: missing chunk', manifest.chunks[i].addr); return null; } |
| 418 | var plain; |
| 419 | try { plain = await DaimondIdentity.unwrapBytes(bytes); } |
| 420 | catch (e) { log('materialiseBytes: unwrap failed'); return null; } |
| 421 | parts.push(plain); total += plain.length; |
| 422 | } |
| 423 | var out = new Uint8Array(total), at = 0; |
| 424 | parts.forEach(function (p) { out.set(p, at); at += p.length; }); |
| 425 | return out; |
| 426 | } |
| 427 | |
| 428 | /// Stream a file back from its manifest, handing each decrypted piece to |
| 429 | /// `write` in order. Returns true on success; false if any piece is no longer |
| 430 | /// held, so the caller can leave the file absent rather than write a |
| 431 | /// truncated one. |
| 432 | /// |
| 433 | /// Nothing accumulates: one chunk is in memory at a time, and the caller |
| 434 | /// writes it straight to disk. |
| 435 | async function materialiseStream(manifest, write) { |
| 436 | if (!manifest || !Array.isArray(manifest.chunks)) return false; |
| 437 | for (var i = 0; i < manifest.chunks.length; i++) { |
| 438 | var bytes = await getChunk(manifest.chunks[i].addr); |
| 439 | if (bytes === null) { log('materialise: missing chunk', manifest.chunks[i].addr); return false; } |
| 440 | var plain; |
| 441 | try { plain = await DaimondIdentity.unwrapBytes(bytes); } |
| 442 | catch (e) { log('materialise: unwrap failed'); return false; } |
| 443 | await write(plain); |
| 444 | } |
| 445 | return true; |
| 446 | } |
| 447 | |
| 448 | /// Recover a file sealed by the ORIGINAL whole-file scheme: one seal over the |
| 449 | /// entire text, base64, then split. Kept because accounts hold files stored |
| 450 | /// that way; nothing writes this shape any more. |
| 451 | async function materialiseV1(manifest) { |
| 452 | if (!manifest || !Array.isArray(manifest.chunks)) return null; |
| 453 | var total = 0, parts = []; |
| 454 | for (var i = 0; i < manifest.chunks.length; i++) { |
| 455 | var bytes = await getChunk(manifest.chunks[i].addr); |
| 456 | if (bytes === null) { log('materialise: missing chunk', manifest.chunks[i].addr); return null; } |
| 457 | parts.push(bytes); total += bytes.length; |
| 458 | } |
| 459 | var joined = new Uint8Array(total), at = 0; |
| 460 | parts.forEach(function (p) { joined.set(p, at); at += p.length; }); |
| 461 | var W = new TextDecoder().decode(joined); |
| 462 | try { return await DaimondIdentity.unwrap(W); } |
| 463 | catch (e) { log('materialise: unwrap failed'); return null; } |
| 464 | } |
| 465 | |
| 466 | // ── A sweep the gateway would not carry out ──────────────── |
| 467 | // |
| 468 | // `sweep_chunks` had no floor: it deleted whatever a commit did not name, on |
| 469 | // one request, and a client bug did exactly that to a real account. It now |
| 470 | // refuses any sweep that would take more than half the chunks an account |
| 471 | // holds. Nothing is deleted, the commit still succeeds and the index is still |
| 472 | // recorded — the reply simply carries `sweep_held_back`, `sweep_held` and a |
| 473 | // `sweep_token`, and repeating the IDENTICAL commit with that token carries |
| 474 | // the deletion out. The token is a digest of the account and the sorted |
| 475 | // doomed addresses, so if either set moves by one chunk in between it no |
| 476 | // longer matches and the sweep is held back again. |
| 477 | // |
| 478 | // SHOULD THIS CLIENT SIMPLY SAY YES? Not unconditionally, and not for the |
| 479 | // reason it is tempting to give. A second request does NOT make this client |
| 480 | // think again: `manifests` is `DaimondCloud.index()`, a merged structure read |
| 481 | // out of localStorage, so re-deriving it a moment later yields the same |
| 482 | // answer it yielded the first time. The only genuinely independent opinion in |
| 483 | // the interlock is the gateway's own re-read of what it holds, and that |
| 484 | // catches one thing — another device having uploaded in between. So an |
| 485 | // unconditional yes really would reduce the floor to a formality. |
| 486 | // |
| 487 | // Nor can it be a permanent no. A wholesale rewrite of one large file |
| 488 | // re-chunks all of it, so a legitimate sweep over half the account is |
| 489 | // ORDINARY rather than exceptional; and the storage ceilings are computed |
| 490 | // from the committed index rather than from the chunks actually held, so |
| 491 | // held-back chunks are charged to no cap at all. Never confirming means |
| 492 | // garbage that grows without bound and is billed to nobody. |
| 493 | // |
| 494 | // WHAT IS CHECKED, AND WHAT IT PROTECTS. Nothing this client can compute |
| 495 | // distinguishes a large deletion that is right from a narrow index that is |
| 496 | // wrong — the shape is identical, which is exactly why the floor lives on the |
| 497 | // server. So the conditions below do not try to; each rules out one way the |
| 498 | // question could be being asked by something that is not in a position to ask |
| 499 | // it, and everything else is reported rather than decided: |
| 500 | // |
| 501 | // 1. The device may still declare a live set AT THE MOMENT OF CONFIRMING. |
| 502 | // `syncMayCommitChunks()` is false whenever the workspace is not |
| 503 | // syncable, and such a device holds only its own view of the index. sync |
| 504 | // checks it before the first commit; this checks it again immediately |
| 505 | // before a destructive second request, which is the window where it can |
| 506 | // change. |
| 507 | // 2. The index names something. An index naming NOTHING is the sharpest |
| 508 | // form of a client that knows nothing declaring the account empty, and |
| 509 | // it is what a workspace that failed to enumerate produces. This client |
| 510 | // never confirms one, whatever else is true. |
| 511 | // 3. The gateway's arithmetic and this client's agree: every chunk the |
| 512 | // account holds is either one this commit named live or one it named |
| 513 | // doomed (`sweep_held - sweep_held_back === entries.length`). Where the |
| 514 | // two parties cannot agree on the size of the corpus, this client does |
| 515 | // not insist on the largest deletion the gateway will accept. |
| 516 | // |
| 517 | // Once per commit, never a loop, and a sweep that is not collected is left |
| 518 | // standing on the chip rather than swallowed. |
| 519 | var heldSweep = null; // {body, n, m, token, why, at} while a deletion stands uncollected. |
| 520 | var confirmed = 0; // Large sweeps this page has carried out, for the verifier. |
| 521 | var refusal = null; // {msg, status, at} while the gateway is turning uploads away. |
| 522 | var persisted = false; // Did the standing deletion actually reach localStorage? |
| 523 | |
| 524 | /// What the app says, in the reader's language, falling back to English while |
| 525 | /// a key has no entry anywhere. |
| 526 | /// |
| 527 | /// The twin of `tOr` in daimond.js and of `t` in legal.js: `DaimondI18n.t` |
| 528 | /// answers with the KEY when the table has no entry, which would put |
| 529 | /// `chunks.sweep_confirm_ok` on a button. The keys added with the control |
| 530 | /// below are new and the locale tables are another lane's file, so each |
| 531 | /// carries the English it means and the tables can catch up without this file |
| 532 | /// ever showing a bare key. |
| 533 | function t(k, fallback, v) { |
| 534 | var i18n = window.DaimondI18n; |
| 535 | if (i18n && i18n.has && i18n.has(k)) return i18n.t(k, v); |
| 536 | return fallback == null ? k : fallback; |
| 537 | } |
| 538 | |
| 539 | // ── A standing deletion outlives the page that found it ──── |
| 540 | // |
| 541 | // `heldSweep` used to be a module variable and nothing else, which made the |
| 542 | // whole feature a thing you had to be looking at to use: reload, and the body |
| 543 | // and the token were gone. That matters more than losing a notice, because |
| 544 | // THIS CLIENT CANNOT MINT A TOKEN. Only the gateway does, only in answer to a |
| 545 | // commit, and only over the doomed set as it stood at that moment. So a |
| 546 | // dropped token is not re-derivable here at all; it comes back when the next |
| 547 | // commit round is held back in the same way, and on a device where sync is |
| 548 | // not running — no Pro, a safe start, an unmerged workspace — that round never |
| 549 | // comes. Meanwhile the chunks nobody refers to sit in the account's store, and |
| 550 | // the storage ceilings are computed from the committed index rather than from |
| 551 | // what is held, so they are charged to no cap and swept by nothing. |
| 552 | // |
| 553 | // WHAT IS WRITTEN. The commit body — content addresses, ciphertext sizes and a |
| 554 | // one-letter tier — plus the token and the two counts. Hashes and numbers: the |
| 555 | // same class of thing the chunk map beside it already keeps, and nothing that |
| 556 | // says what any file is or contains. |
| 557 | // |
| 558 | // WHAT IT DOES NOT PROMISE. A restored token is only as good as the account it |
| 559 | // was minted against. If another device has uploaded or committed since, the |
| 560 | // gateway's digest no longer matches and the sweep is simply held back again |
| 561 | // with a fresh token, which `standHeld` records. That is the interlock working, |
| 562 | // not a failure, and it is why a stale entry is safe to keep rather than |
| 563 | // something that has to be expired on a timer. |
| 564 | var HELD_KEY = 'daimond-chunk-held'; |
| 565 | /// Roughly two thousand addresses. Above this the entry stays in memory only: |
| 566 | /// localStorage is five megabytes for the whole origin and shared with the |
| 567 | /// chunk map, the chats' spill and everything else, and evicting somebody's |
| 568 | /// work to remember a deletion would be the wrong trade. `state().persisted` |
| 569 | /// says which of the two happened rather than leaving it to be guessed. |
| 570 | var HELD_MAX_BYTES = 256 * 1024; |
| 571 | |
| 572 | /// Whose deletion this is: the identity fingerprint, which is a prefix of the |
| 573 | /// public key's digest and therefore not a secret. |
| 574 | /// |
| 575 | /// STORED BECAUSE THE KEY OUTLIVES THE ACCOUNT. `forgetIdentity` in daimond.js |
| 576 | /// sweeps a NAMED list of `daimond-*` keys for the primary account, and the |
| 577 | /// primary's keys are un-namespaced — so anything not on that list is |
| 578 | /// inherited whole by the next identity made in this browser. A held sweep is |
| 579 | /// a commit body for an account that no longer exists: it would paint a chip |
| 580 | /// for a stranger and, if pressed, send a token the gateway can only refuse. |
| 581 | /// Binding the record to the fingerprint answers that here rather than by |
| 582 | /// adding a line to a list in another lane's file — and it answers the same |
| 583 | /// question for a restored backup and for switching accounts. |
| 584 | function whoseFp() { |
| 585 | try { return (window.DaimondIdentity && DaimondIdentity.fingerprint()) || ''; } |
| 586 | catch (e) { return ''; } |
| 587 | } |
| 588 | |
| 589 | function writeHeld() { |
| 590 | persisted = false; |
| 591 | if (!heldSweep) { try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ } return; } |
| 592 | var s; |
| 593 | try { s = JSON.stringify({ |
| 594 | body: heldSweep.body, |
| 595 | n: heldSweep.n, |
| 596 | m: heldSweep.m, |
| 597 | token: heldSweep.token, |
| 598 | why: heldSweep.why, |
| 599 | at: heldSweep.at, |
| 600 | fp: whoseFp(), |
| 601 | }); } catch (e) { return; } |
| 602 | if (s.length > HELD_MAX_BYTES) { |
| 603 | log('standing deletion too large to persist (', s.length, 'bytes ) — held in memory only'); |
| 604 | try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ } |
| 605 | return; |
| 606 | } |
| 607 | try { localStorage.setItem(HELD_KEY, s); persisted = true; } |
| 608 | catch (e) { /* quota or private mode: it stands for this sitting only */ } |
| 609 | } |
| 610 | |
| 611 | /// The standing deletion this device last recorded, or null. |
| 612 | /// |
| 613 | /// Shape-checked rather than trusted: a half-written or hand-edited entry |
| 614 | /// would otherwise become a commit body, and the one thing this file must |
| 615 | /// never do is send a deletion it cannot account for. |
| 616 | function readHeld() { |
| 617 | var raw; |
| 618 | try { raw = localStorage.getItem(HELD_KEY); } catch (e) { return null; } |
| 619 | if (!raw) return null; |
| 620 | var h; |
| 621 | try { h = JSON.parse(raw); } catch (e) { return null; } |
| 622 | if (!h || typeof h !== 'object') return null; |
| 623 | if (!h.body || h.body.op !== 'commit' || !Array.isArray(h.body.chunks)) return null; |
| 624 | if (typeof h.token !== 'string' || !h.token) return null; |
| 625 | // Somebody else's deletion, or nobody's. Dropped rather than shown: see |
| 626 | // `whoseFp`. |
| 627 | var fp = whoseFp(); |
| 628 | if (!fp || h.fp !== fp) { |
| 629 | try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ } |
| 630 | return null; |
| 631 | } |
| 632 | return { |
| 633 | body: h.body, |
| 634 | n: h.n | 0, |
| 635 | m: h.m | 0, |
| 636 | token: h.token, |
| 637 | why: typeof h.why === 'string' ? h.why : '', |
| 638 | at: typeof h.at === 'number' ? h.at : 0, |
| 639 | }; |
| 640 | } |
| 641 | |
| 642 | /// The chip that says something is standing, drawn beside sync's. |
| 643 | /// |
| 644 | /// It is sync's chip in everything but ownership: same row, same shape, same |
| 645 | /// `stalled` colour, standing rather than fading, reason on hover, and never |
| 646 | /// a dialog over the app unasked. It is a separate element only because |
| 647 | /// `setStatus` is private to sync.js and a held-back sweep outlives the round |
| 648 | /// that found it — sync paints "Synced" the instant `commit` returns, so |
| 649 | /// anything this file wrote into that chip would live for no time at all. |
| 650 | /// |
| 651 | /// A BUTTON, AND THAT IS THE FIX. It was a `role="status"` div: a permanent |
| 652 | /// amber pill saying a deletion was standing, with nothing anywhere in the app |
| 653 | /// that could carry the deletion out. `confirmHeldSweep` — the only code that |
| 654 | /// re-sends the body with its token — had no production caller at all, so the |
| 655 | /// notice was the whole feature. The chip is where the fact already lives and |
| 656 | /// the rail's status strip is where this app already puts the state of the |
| 657 | /// machine "at a glance and without asking", one row per question, each row |
| 658 | /// that can be acted on a button that goes there. So the control goes here. |
| 659 | /// It was in the TOP BAR until 2026-08-28, beside `#update-chip`; that row |
| 660 | /// turned out to be a row of targets a standing notice must not appear in. |
| 661 | /// |
| 662 | /// The Credits drawer was the other candidate — it is where the app answers |
| 663 | /// "what account have I got and what does it cost me", and cloud storage is |
| 664 | /// part of that answer. It is not used, for a plain reason: `drawCredits` |
| 665 | /// belongs to daimond.js, its one published extension point |
| 666 | /// (`DaimondCredits.render`) is already taken by passcode.js, and a second |
| 667 | /// surface for a fact that is already on screen is a second thing to keep in |
| 668 | /// step. One control, where the notice is. |
| 669 | /// |
| 670 | /// `aria-live` rather than `role="status"`: the live region has to move to the |
| 671 | /// button, because a button containing a status region announces the region |
| 672 | /// and leaves the control unnamed. |
| 673 | var _chip = null; |
| 674 | function chip() { |
| 675 | if (_chip) return _chip; |
| 676 | // IN THE SYNC ROW, and for the same reason sync itself moved out of the top |
| 677 | // bar on 2026-08-28: there this was a pill that appeared among the icon |
| 678 | // buttons and pushed the whole chip row 98px sideways when it did. A notice |
| 679 | // nobody asked for must not move what somebody is reaching for. |
| 680 | // |
| 681 | // IN THAT ROW AND NOT BESIDE IT. A row of its own at the end of the strip |
| 682 | // was the first answer and it was the same fault one surface over: the |
| 683 | // strip grew when the notice arrived and the rail's own controls moved with |
| 684 | // it -- caught by `dev/verify_sweep_seen.mjs`'s ANCHOR family, which asks |
| 685 | // exactly this question of every transient it can find. `#astat-sync` is |
| 686 | // permanent and always holds one line of text, so a second occupant sharing |
| 687 | // it costs no height at all. It is the right row on the merits too: this |
| 688 | // notice "is sync's chip in everything but ownership", as the comment below |
| 689 | // says, and what it is about is whether this account's work is travelling. |
| 690 | var host = document.getElementById('astat-sync') |
| 691 | || document.getElementById('admin-status') || document.querySelector('.admin-status'); |
| 692 | if (!host) return null; |
| 693 | if (!document.getElementById('chunk-status-styles')) { |
| 694 | var st = document.createElement('style'); |
| 695 | st.id = 'chunk-status-styles'; |
| 696 | st.textContent = |
| 697 | // `flex: 0 1 auto`, not a width: it SHARES the sync row, so when both |
| 698 | // have something to say each takes what it needs and ellipsises, |
| 699 | // and the row is one line either way. |
| 700 | '#chunk-chip{display:none;flex:0 1 auto;min-width:0;align-items:center;gap:6px;text-align:left;' + |
| 701 | // NO PADDING OF ITS OWN. It is an occupant of `#astat-sync`, not a row: |
| 702 | // its 3px top and bottom made that row 36px where every other row in |
| 703 | // the strip is 30, and the six pixels moved ten controls above it. |
| 704 | 'font:inherit;font-size:var(--fs-xs);padding:0;border:none;background:none;' + |
| 705 | 'border-radius:var(--radius-sm);min-width:0;' + |
| 706 | // A deletion that did not happen is not an error and not a success: |
| 707 | // something is standing that the operator can act on, which is what |
| 708 | // --warn is for everywhere else in the app. |
| 709 | 'color:var(--warn);cursor:pointer}' + |
| 710 | '#chunk-chip:hover,#chunk-chip:focus-visible{background:var(--bg-hover)}' + |
| 711 | '#chunk-chip .ctext{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + |
| 712 | '#chunk-chip .cdot{flex:none;width:7px;height:7px;border-radius:50%;background:currentColor}'; |
| 713 | document.head.appendChild(st); |
| 714 | } |
| 715 | var c = document.createElement('button'); |
| 716 | c.id = 'chunk-chip'; |
| 717 | c.type = 'button'; // never submit an enclosing form. |
| 718 | c.setAttribute('aria-live', 'polite'); |
| 719 | c.innerHTML = '<span class="cdot" aria-hidden="true"></span><span class="ctext"></span>'; |
| 720 | c.addEventListener('click', function () { onChipClick(); }); |
| 721 | host.appendChild(c); |
| 722 | _chip = c; |
| 723 | return c; |
| 724 | } |
| 725 | |
| 726 | /// The reason sentence for the deletion that is standing. |
| 727 | function heldReason() { |
| 728 | return t('chunks.sweep_held_reason', 'Cloud storage holds pieces that no file ' |
| 729 | + 'on this account still refers to. They have NOT been deleted, because no ' |
| 730 | + 'single request may remove more than half of what is stored.', |
| 731 | { n: heldSweep ? heldSweep.n : 0, m: heldSweep ? heldSweep.m : 0 }); |
| 732 | } |
| 733 | |
| 734 | /// Show or hide the standing notice, and tell anything else that is watching. |
| 735 | /// |
| 736 | /// A refused upload outranks a held-back deletion, because the two are not |
| 737 | /// equally urgent: one means the user's work has stopped travelling, the other |
| 738 | /// means some space has not been reclaimed yet. They compose rather than |
| 739 | /// compete — "delete something, or ask for more room" and a deletion waiting to |
| 740 | /// be authorised are the same conversation — so when both stand the chip says |
| 741 | /// the refusal and the dialog behind it carries both. |
| 742 | /// |
| 743 | /// The event mirrors gateway.js's `daimond:credits`: one place owns the fact |
| 744 | /// and announces it, rather than every panel that wants it polling for it. |
| 745 | function draw() { |
| 746 | var c = chip(); |
| 747 | if (c) { |
| 748 | if (!heldSweep && !refusal) c.style.display = 'none'; |
| 749 | else { |
| 750 | var text = refusal |
| 751 | ? t('chunks.upload_refused', 'Uploads paused') |
| 752 | : t('chunks.sweep_held', 'Cleanup paused'); |
| 753 | var title = refusal ? refusal.msg : heldReason(); |
| 754 | c.querySelector('.ctext').textContent = text; |
| 755 | c.title = title; |
| 756 | c.setAttribute('aria-label', text + '. ' + title); |
| 757 | c.style.display = 'flex'; |
| 758 | } |
| 759 | } |
| 760 | try { |
| 761 | window.dispatchEvent(new CustomEvent('daimond:chunks', { detail: state() })); |
| 762 | } catch (e) { /* no window to tell */ } |
| 763 | } |
| 764 | |
| 765 | /// The chip was pressed. Say what is standing, and offer the one act that |
| 766 | /// answers it. |
| 767 | /// |
| 768 | /// ASKED, ALWAYS. `confirmHeldSweep` deletes on a person's word rather than on |
| 769 | /// the engine's, so the word has to be given here rather than assumed from a |
| 770 | /// tap on a status row. With no dialog to ask through — a stripped build, |
| 771 | /// or this script running without the module — nothing is deleted: a |
| 772 | /// destructive act with no way to put the question is not carried out. It is |
| 773 | /// not escalated to `window.confirm` either, which is an OS box with the origin |
| 774 | /// in its title and is exactly what `DaimondCore.confirm` exists to replace. |
| 775 | async function onChipClick() { |
| 776 | var core = window.DaimondCore; |
| 777 | if (!core || !core.confirm) { log('no dialog available; the chip cannot ask'); return; } |
| 778 | if (!heldSweep) { |
| 779 | // A refusal on its own is a notice, not a question: there is nothing |
| 780 | // here for the user to authorise. `cancelLabel: null` drops the second |
| 781 | // button, which is how daimond.js's dialog draws one. |
| 782 | if (!refusal) return; |
| 783 | try { |
| 784 | await core.confirm(refusal.msg, t('common.close', 'Close'), { |
| 785 | title: t('chunks.upload_refused_title', 'Cloud storage refused an upload'), |
| 786 | danger: false, |
| 787 | cancelLabel: null, |
| 788 | }); |
| 789 | } catch (e) { /* the dialog went with a redraw */ } |
| 790 | return; |
| 791 | } |
| 792 | var message = (refusal ? refusal.msg + '\n\n' : '') + heldReason() + '\n\n' |
| 793 | + t('chunks.sweep_confirm_ask', |
| 794 | 'Delete them now? Nothing you can still see is touched, and the space is freed.'); |
| 795 | var yes = false; |
| 796 | try { |
| 797 | yes = await core.confirm(message, |
| 798 | t('chunks.sweep_confirm_ok', 'Delete them'), |
| 799 | { title: t('chunks.sweep_confirm_title', 'Free the unreferenced pieces?') }); |
| 800 | } catch (e) { yes = false; } // no answer is not a yes. |
| 801 | if (!yes) return; |
| 802 | await confirmHeldSweep(); |
| 803 | } |
| 804 | |
| 805 | /// Note a deletion the gateway would not carry out and this client would not |
| 806 | /// insist on. `why` is the short reason, for the verifier and the log. |
| 807 | /// |
| 808 | /// The commit body is kept beside it, because the only way to carry the |
| 809 | /// deletion out later is to send that body again unchanged: the token names |
| 810 | /// the doomed set, which the gateway derives from the live set in the body, |
| 811 | /// so a rebuilt one would name a different deletion. |
| 812 | function standHeld(body, n, m, token, why) { |
| 813 | heldSweep = { body: body, n: n, m: m, token: token, why: why, at: Date.now() }; |
| 814 | writeHeld(); |
| 815 | log('sweep held back:', n, 'of', m, 'chunks not deleted —', why); |
| 816 | draw(); |
| 817 | } |
| 818 | |
| 819 | /// Nothing is standing any more: the commit that just ran collected whatever |
| 820 | /// the last one could not. |
| 821 | function noteCollected() { |
| 822 | if (!heldSweep) return; |
| 823 | heldSweep = null; |
| 824 | writeHeld(); |
| 825 | draw(); |
| 826 | } |
| 827 | |
| 828 | /// The gateway turned an upload away, in its own words. |
| 829 | /// |
| 830 | /// NOT PERSISTED, and the asymmetry with `heldSweep` is the point rather than |
| 831 | /// an oversight. A refusal is re-derived by the very next upload attempt: the |
| 832 | /// ceiling is still there, the store is still full, and the sentence comes back |
| 833 | /// unchanged. A sweep token is not re-derivable by this client at all. Keeping |
| 834 | /// a refusal across a reload would therefore only risk showing a ceiling that |
| 835 | /// has since been raised. |
| 836 | function standRefused(msg, status) { |
| 837 | refusal = { msg: String(msg || ''), status: status | 0, at: Date.now() }; |
| 838 | log('upload refused:', status, msg); |
| 839 | draw(); |
| 840 | } |
| 841 | |
| 842 | /// Uploads are working again. |
| 843 | function clearRefused() { |
| 844 | if (!refusal) return; |
| 845 | refusal = null; |
| 846 | draw(); |
| 847 | } |
| 848 | |
| 849 | /// The gateway names the free allowance it grants, so the next tiering can be |
| 850 | /// honest about which files fit inside it. |
| 851 | function noteAllowance(j) { |
| 852 | if (window.DaimondCloud && j && typeof j.free_allowance === 'number') { |
| 853 | DaimondCloud.setAllowance(j.free_allowance); |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | /// Why this client will not confirm a held-back sweep, or '' when it will. |
| 858 | /// The three conditions are argued at the head of this section. |
| 859 | function refusalToConfirm(entries, n, m) { |
| 860 | var may = window.DaimondCore && DaimondCore.syncMayCommitChunks |
| 861 | && DaimondCore.syncMayCommitChunks(); |
| 862 | if (!may) return 'not_merged'; |
| 863 | if (!entries.length) return 'names_nothing'; |
| 864 | if (m - n !== entries.length) return 'unaccounted'; |
| 865 | return ''; |
| 866 | } |
| 867 | |
| 868 | /// Answer a held-back sweep: confirm it if this client may, and otherwise |
| 869 | /// leave it standing and say so. Returns the reply to hand back to sync. |
| 870 | async function answerHeldBack(body, j) { |
| 871 | var n = j.sweep_held_back | 0, m = j.sweep_held | 0; |
| 872 | var why = refusalToConfirm(body.chunks, n, m); |
| 873 | if (why) { standHeld(body, n, m, j.sweep_token, why); return j; } |
| 874 | |
| 875 | // The IDENTICAL commit, quoting what the gateway said it was about to |
| 876 | // delete. Identical to the byte: the token is over the doomed set, which |
| 877 | // the gateway derives from the live set in this body, so a body that |
| 878 | // differed anywhere in `chunks` would name a different deletion and be |
| 879 | // held back again. |
| 880 | body.sweep_token = j.sweep_token; |
| 881 | var res; |
| 882 | try { res = await call(body); } |
| 883 | finally { delete body.sweep_token; } |
| 884 | if (res.status !== 200 || !res.json || !res.json.ok) { |
| 885 | standHeld(body, n, m, j.sweep_token, 'refused'); |
| 886 | return j; |
| 887 | } |
| 888 | noteAllowance(res.json); |
| 889 | if (res.json.sweep_token) { |
| 890 | // Held back a second time: the account's chunks moved between the two |
| 891 | // requests, so the token no longer names this deletion. One attempt |
| 892 | // only — a loop here would be a client insisting until it got its way, |
| 893 | // which is the behaviour the floor exists to stop. |
| 894 | standHeld(body, res.json.sweep_held_back | 0, res.json.sweep_held | 0, |
| 895 | res.json.sweep_token, 'moved'); |
| 896 | return res.json; |
| 897 | } |
| 898 | confirmed++; |
| 899 | noteCollected(); |
| 900 | log('confirmed a large sweep:', res.json.swept, 'of', m, 'chunks removed'); |
| 901 | return res.json; |
| 902 | } |
| 903 | |
| 904 | /// Declare the live, tiered chunk set to the gateway and let it sweep |
| 905 | /// everything unreferenced. `manifests` is the map of `{path: manifest}` in |
| 906 | /// the state just pushed; every chunk it names is committed as paid overflow. |
| 907 | /// |
| 908 | /// `blobVersion` is the sync blob version this set was derived from. The |
| 909 | /// gateway refuses to sweep for a device naming a version older than the one |
| 910 | /// it holds, because such a device cannot know about a file another device |
| 911 | /// added — and a sweep on its word would delete that file's chunks. |
| 912 | /// |
| 913 | /// A sweep over the gateway's floor comes back undone, with a token: see |
| 914 | /// `answerHeldBack` for when this client confirms one and when it does not. |
| 915 | async function commit(manifests, blobVersion, tiers) { |
| 916 | var seen = {}, entries = []; |
| 917 | Object.keys(manifests || {}).forEach(function (path) { |
| 918 | var m = manifests[path]; |
| 919 | if (!m || !Array.isArray(m.chunks)) return; |
| 920 | // A file's chunks carry its tier. Tagging everything paid would mean a |
| 921 | // lapsed account lost its whole store at the end of grace instead of |
| 922 | // only its overflow, which is the opposite of the promise. |
| 923 | var tier = (tiers && tiers[path] === 'f') ? 'f' : 'p'; |
| 924 | m.chunks.forEach(function (c) { |
| 925 | if (seen[c.addr]) return; // dedup across files. |
| 926 | seen[c.addr] = 1; |
| 927 | entries.push({ addr: c.addr, size: c.size | 0, tier: tier }); |
| 928 | }); |
| 929 | }); |
| 930 | var body = { op: 'commit', chunks: entries }; |
| 931 | if (typeof blobVersion === 'number') body.blob_version = blobVersion | 0; |
| 932 | var res = await call(body); |
| 933 | if (res.status !== 200 || !res.json || !res.json.ok) { log('commit failed', res.status); return null; } |
| 934 | noteAllowance(res.json); |
| 935 | if (res.json.sweep_token) return await answerHeldBack(body, res.json); |
| 936 | noteCollected(); |
| 937 | log('committed', entries.length, 'live chunks; swept', res.json.swept); |
| 938 | return res.json; |
| 939 | } |
| 940 | |
| 941 | /// Carry out a deletion this client declined to confirm on its own. |
| 942 | /// |
| 943 | /// The escape hatch for the one case the conditions above deliberately never |
| 944 | /// clear by themselves — an index that names nothing, on an account that |
| 945 | /// really has been emptied — and the only thing in this file that deletes on |
| 946 | /// a person's word rather than the engine's. It re-sends the commit exactly |
| 947 | /// as it stood, so a set that has moved since is refused by the token. |
| 948 | /// |
| 949 | /// Reached from the chip, through `onChipClick`, which asks first. |
| 950 | async function confirmHeldSweep() { |
| 951 | if (!heldSweep || !heldSweep.body) return null; |
| 952 | var body = heldSweep.body; |
| 953 | body.sweep_token = heldSweep.token; |
| 954 | var res; |
| 955 | try { res = await call(body); } |
| 956 | finally { delete body.sweep_token; } |
| 957 | if (res.status !== 200 || !res.json || !res.json.ok) { |
| 958 | // The person asked for this, so they are told why it did not happen — |
| 959 | // in the gateway's own words, which name the remedy. A stale |
| 960 | // `blob_version` answers 409 with "pull, merge and commit again", and |
| 961 | // returning a bare null left them pressing a chip that did nothing. |
| 962 | standRefused((res.json && (res.json.error || res.json.message)) |
| 963 | || ('HTTP ' + res.status), res.status); |
| 964 | return null; |
| 965 | } |
| 966 | clearRefused(); |
| 967 | noteAllowance(res.json); |
| 968 | if (res.json.sweep_token) { |
| 969 | standHeld(body, res.json.sweep_held_back | 0, res.json.sweep_held | 0, |
| 970 | res.json.sweep_token, 'moved'); |
| 971 | return res.json; |
| 972 | } |
| 973 | confirmed++; |
| 974 | noteCollected(); |
| 975 | return res.json; |
| 976 | } |
| 977 | |
| 978 | /// What this file would say if asked — the same facts the chip shows, in |
| 979 | /// words, for anything that needs them other than as a coloured pill. |
| 980 | function state() { |
| 981 | return { |
| 982 | /// A deletion the gateway would not carry out and this client did not |
| 983 | /// insist on. Standing until a commit collects it. |
| 984 | heldBack: heldSweep ? heldSweep.n : 0, |
| 985 | held: heldSweep ? heldSweep.m : 0, |
| 986 | /// '' | 'not_merged' | 'names_nothing' | 'unaccounted' | 'refused' | 'moved' |
| 987 | why: heldSweep ? heldSweep.why : '', |
| 988 | since: heldSweep ? heldSweep.at : 0, |
| 989 | standing: !!heldSweep, |
| 990 | /// Did the standing deletion reach localStorage, so a reload keeps it? |
| 991 | /// False when nothing is standing, and false when the body was too |
| 992 | /// large to store — which is a real difference and not a detail. |
| 993 | persisted: persisted && !!heldSweep, |
| 994 | /// The gateway's own sentence for the upload it last turned away, or |
| 995 | /// ''. Its words, not a status code: it is the half that names the |
| 996 | /// remedy. |
| 997 | refused: refusal ? refusal.msg : '', |
| 998 | refusedStatus: refusal ? refusal.status : 0, |
| 999 | /// Large sweeps this page has confirmed, for the verifier. |
| 1000 | confirmed: confirmed, |
| 1001 | }; |
| 1002 | } |
| 1003 | |
| 1004 | // ── Boot ─────────────────────────────────────────────────── |
| 1005 | // |
| 1006 | // Pick up a deletion an earlier sitting left standing, and paint it. Read ONCE, |
| 1007 | // here: a re-read on every repaint would overwrite a sweep that is standing in |
| 1008 | // memory because it was too large to store, which is the one case where the two |
| 1009 | // disagree. |
| 1010 | // |
| 1011 | // `draw` is then registered with `DaimondI18n.onChange`, which fires when the |
| 1012 | // locale table lands as well as on a language change — so the first paint may |
| 1013 | // carry the English fallbacks and the second carries the reader's own words. |
| 1014 | (function restore() { |
| 1015 | heldSweep = readHeld(); |
| 1016 | if (heldSweep) { |
| 1017 | persisted = true; |
| 1018 | log('a deletion was left standing:', heldSweep.n, 'of', heldSweep.m, '—', heldSweep.why); |
| 1019 | } |
| 1020 | draw(); |
| 1021 | if (window.DaimondI18n && DaimondI18n.onChange) DaimondI18n.onChange(draw); |
| 1022 | })(); |
| 1023 | |
| 1024 | // ── Public surface ───────────────────────────────────────── |
| 1025 | window.DaimondChunks = { |
| 1026 | offloadFile: offloadFile, // (path, File) -> manifest v2 |
| 1027 | offloadBytes: offloadBytes, // (label, Uint8Array) -> manifest v2 |
| 1028 | materialiseStream: materialiseStream, // (manifest, write) -> bool |
| 1029 | materialiseBytes: materialiseBytes, // (manifest) -> Uint8Array|null, one item whole |
| 1030 | materialiseV1: materialiseV1, // (manifest) -> text|null, old files only |
| 1031 | chunkSizeFor: chunkSizeFor, |
| 1032 | commit: commit, // ({path: manifest}, version) -> {swept,...}|null |
| 1033 | /// A deletion this client declined to confirm, carried out on a person's |
| 1034 | /// word. Reached from the notice in the rail's status strip, which asks |
| 1035 | /// first; exported |
| 1036 | /// as well so a verifier can drive the act without driving a dialog. |
| 1037 | confirmHeldSweep: confirmHeldSweep, |
| 1038 | state: state, |
| 1039 | /// Is a re-offload owed because the passphrase changed? Read by |
| 1040 | /// `collectChunked` in daimond.js, which suspends its unchanged-file skip |
| 1041 | /// for exactly one round when it is set, and clears it when that round is |
| 1042 | /// done. See "Surviving a passphrase change" above. |
| 1043 | staleSinceRekey: staleSinceRekey, |
| 1044 | clearStale: clearStale, |
| 1045 | // exposed for tests/tools: |
| 1046 | _b64urlEncode: b64urlEncode, |
| 1047 | _b64urlDecode: b64urlDecode, |
| 1048 | _sha256Hex: sha256Hex, |
| 1049 | }; |
| 1050 | })(); |