diff --git a/Dockerfile b/Dockerfile index 8c028681..40204ec1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ COPY workspace-storage.js /usr/share/nginx/html/ COPY visual-settings-adapters.js /usr/share/nginx/html/ COPY visual-settings-store.js /usr/share/nginx/html/ COPY script.js /usr/share/nginx/html/ +COPY sharing-crypto.js sharing-media.js sharing-live.js /usr/share/nginx/html/ COPY styles.css /usr/share/nginx/html/ COPY sw.js /usr/share/nginx/html/ COPY RELEASE_NOTES /usr/share/nginx/html/ @@ -39,6 +40,9 @@ RUN echo 'server { \ } \ \ # Cache static assets \ + location ~* \.mjs$ { \ + default_type application/javascript; \ + } \ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { \ expires 1y; \ add_header Cache-Control "public, immutable"; \ diff --git a/README.md b/README.md index 5e4a3b16..85e2cc9e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Markdown Viewer is an open source, local-first workspace for developers, writers, students, researchers, and anyone working with `.md` or `.markdown` files. Go beyond plain text. Organize multiple documents, review with anchored comments without changing the Markdown, and render diagrams, maps, charts, math, 3D models, and music—all in one focused workspace. -Turn a document into a Share Snapshot link for quick handoffs, or start access-controlled Live Share for real-time co-editing, live cursors, anchored comments, and threaded replies. Everyday editing stays on your device. No account is required, and the application includes no ads, analytics, telemetry, or subscriptions. +Turn a document into an encrypted Share Snapshot link for quick handoffs, or start encrypted Live Share for real-time co-editing, live cursors, anchored comments, and threaded replies. Everyday editing stays on your device. No account is required, and the application includes no ads, analytics, telemetry, or subscriptions.

Application UI @@ -74,7 +74,7 @@ Turn a document into a Share Snapshot link for quick handoffs, or start access-c - **Import and export:** open local files or GitHub content from branches, tags, commit SHAs, and optionally private repositories; export Markdown, standalone HTML, PNG, Standard PDF/Save as PDF, or Rendered PDF with remembered Light or Dark appearance and complete rich-content preparation for PDF and PNG. -- **Optional sharing:** create a Share Snapshot with View only or Can edit access, or start a temporary Live Share room with host, Can edit, and View only capabilities. +- **Optional sharing:** create an encrypted Share Snapshot with View only or Can edit access, or start an encrypted Live Share room with role-based signatures. Keep complete links private: they carry decryption keys. Existing shares retain their original expiry.

Live share @@ -115,12 +115,12 @@ Markdown Viewer is local-first, not network-free. The following table shows the | Web libraries and uncached renderer dependencies | CDN requests in the web/PWA build | | GitHub import and emoji lookup | Public content uses GitHub APIs and raw-content hosts; private content uses an optional PAT only with `api.github.com` | | PlantUML, D2, Graphviz, Vega-Lite, WaveDrom, and some diagram previews | Diagram source can be sent to PlantUML, Kroki, or mermaid.ink | -| Consented image, GIF, and video insertion | Public, content-addressed Cloudflare media storage for 90 days | +| Consented image, GIF, and video insertion | Encrypted Cloudflare media storage for 90 days | | Large Share Snapshot | Cloudflare KV for 90 days | | Live Share | WebSocket relay through a Cloudflare Durable Object; document content is not persisted server-side | | External images, media, links, and map tiles | The referenced external host | -Share Snapshot and Live Share URLs are bearer links. Anyone who obtains a valid link and capability can use the access it grants. Live Share is not end-to-end encrypted. See [Privacy and Security](wiki/Privacy-and-Security.md) before using network features with sensitive content. +Share Snapshot and Live Share URLs are bearer links. Anyone who obtains a valid link and capability can use the access it grants. New snapshots, managed media, and live rooms use client-side encryption; complete links contain decryption keys. See [Privacy and Security](wiki/Privacy-and-Security.md) before using network features with sensitive content. ## Visual Renderer Summary @@ -164,7 +164,7 @@ Remote renderer services receive the source of the diagram they render. Do not s - A local Markdown file larger than 10 MB is rejected. - The GitHub credential vault stores up to 50 named PAT entries; each token name is limited to 60 characters. - Managed source media is limited to 25 MiB before processing; stored payload limits are 300 KiB for still images, 5 MiB for GIFs, and 10 MiB for videos. -- Stored Share Snapshot content is limited to 8,000,000 characters and expires after 90 days. +- Stored Share Snapshot payloads are limited to 8 MiB before encryption and expire after 90 days. - Live Share allows up to 64 WebSocket participants and 8 MB per live message. - STL source is limited to 2 MiB and rendered geometry to 300,000 vertices. - The desktop vault retains up to 20 recent history copies per document. diff --git a/assets/i18n/bg.json b/assets/i18n/bg.json index 90c23ca1..e0723545 100644 --- a/assets/i18n/bg.json +++ b/assets/i18n/bg.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Шифровано споделяне. Пазете цялата връзка в тайна; тя съдържа ключа за дешифриране.", + "Delete snapshot": "Изтриване на моментната снимка", + "Snapshot deleted.": "Моментната снимка е изтрита.", + "Connected to encrypted live room": "Свързано с шифрована стая на живо", "Linking folder": "Свързване на папка", "Scanning folder…": "Сканиране на папката…", "Reading files…": "Четене на файлове…", diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 82d8db05..f3ca960a 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Verschlüsselte Freigabe. Halten Sie den vollständigen Link geheim; er enthält den Entschlüsselungsschlüssel.", + "Delete snapshot": "Momentaufnahme löschen", + "Snapshot deleted.": "Momentaufnahme gelöscht.", + "Connected to encrypted live room": "Mit verschlüsseltem Live-Raum verbunden", "Linking folder": "Ordner wird verknüpft", "Scanning folder…": "Ordner wird durchsucht…", "Reading files…": "Dateien werden gelesen…", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 74f011b4..2a70d56e 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Encrypted sharing. Keep the complete link private; it includes the decryption key.", + "Delete snapshot": "Delete snapshot", + "Snapshot deleted.": "Snapshot deleted.", + "Connected to encrypted live room": "Connected to encrypted live room", "Linking folder": "Linking folder", "Scanning folder…": "Scanning folder…", "Reading files…": "Reading files…", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 95a5e8fb..0e8c1429 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Uso compartido cifrado. Mantén privado el enlace completo; incluye la clave de descifrado.", + "Delete snapshot": "Eliminar instantánea", + "Snapshot deleted.": "Instantánea eliminada.", + "Connected to encrypted live room": "Conectado a la sala en vivo cifrada", "Linking folder": "Vinculando carpeta", "Scanning folder…": "Explorando carpeta…", "Reading files…": "Leyendo archivos…", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index c7faefe7..e8e9d69e 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Partage chiffré. Gardez le lien complet privé ; il contient la clé de déchiffrement.", + "Delete snapshot": "Supprimer l’instantané", + "Snapshot deleted.": "Instantané supprimé.", + "Connected to encrypted live room": "Connecté à la salle en direct chiffrée", "Linking folder": "Liaison du dossier", "Scanning folder…": "Analyse du dossier…", "Reading files…": "Lecture des fichiers…", diff --git a/assets/i18n/it.json b/assets/i18n/it.json index 62ef95e6..b7d7b4e9 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Condivisione crittografata. Mantieni privato il link completo: contiene la chiave di decrittazione.", + "Delete snapshot": "Elimina istantanea", + "Snapshot deleted.": "Istantanea eliminata.", + "Connected to encrypted live room": "Connesso alla stanza live crittografata", "Linking folder": "Collegamento della cartella", "Scanning folder…": "Scansione della cartella…", "Reading files…": "Lettura dei file…", diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index f97aa7f4..9ee8ff3b 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "暗号化された共有です。完全なリンクには復号キーが含まれるため、秘密にしてください。", + "Delete snapshot": "スナップショットを削除", + "Snapshot deleted.": "スナップショットを削除しました。", + "Connected to encrypted live room": "暗号化されたライブルームに接続しました", "Linking folder": "フォルダーをリンク中", "Scanning folder…": "フォルダーをスキャン中…", "Reading files…": "ファイルを読み込み中…", diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index bea0a9bd..ce39c79e 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "암호화된 공유입니다. 전체 링크에는 복호화 키가 포함되어 있으므로 비공개로 유지하세요.", + "Delete snapshot": "스냅샷 삭제", + "Snapshot deleted.": "스냅샷이 삭제되었습니다.", + "Connected to encrypted live room": "암호화된 라이브 룸에 연결됨", "Linking folder": "폴더 연결 중", "Scanning folder…": "폴더 검색 중…", "Reading files…": "파일 읽는 중…", diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index bcc339e9..e1e4e22f 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Udostępnianie szyfrowane. Zachowaj pełny link w tajemnicy; zawiera klucz odszyfrowywania.", + "Delete snapshot": "Usuń migawkę", + "Snapshot deleted.": "Migawka usunięta.", + "Connected to encrypted live room": "Połączono z szyfrowanym pokojem na żywo", "Linking folder": "Łączenie folderu", "Scanning folder…": "Skanowanie folderu…", "Reading files…": "Odczytywanie plików…", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 4d179cfd..360961b1 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Partilha encriptada. Mantenha a ligação completa privada; inclui a chave de desencriptação.", + "Delete snapshot": "Eliminar instantâneo", + "Snapshot deleted.": "Instantâneo eliminado.", + "Connected to encrypted live room": "Ligado à sala ao vivo encriptada", "Linking folder": "Vinculando pasta", "Scanning folder…": "Verificando pasta…", "Reading files…": "Lendo arquivos…", diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index 5d88eb1a..5ecf0ab5 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Зашифрованный общий доступ. Храните полную ссылку в тайне: она содержит ключ расшифровки.", + "Delete snapshot": "Удалить снимок", + "Snapshot deleted.": "Снимок удалён.", + "Connected to encrypted live room": "Подключено к зашифрованной комнате", "Linking folder": "Подключение папки", "Scanning folder…": "Сканирование папки…", "Reading files…": "Чтение файлов…", diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index 74ad6f2c..29cee545 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Şifreli paylaşım. Şifre çözme anahtarını içerdiğinden bağlantının tamamını gizli tutun.", + "Delete snapshot": "Anlık görüntüyü sil", + "Snapshot deleted.": "Anlık görüntü silindi.", + "Connected to encrypted live room": "Şifreli canlı odaya bağlanıldı", "Linking folder": "Klasör bağlanıyor", "Scanning folder…": "Klasör taranıyor…", "Reading files…": "Dosyalar okunuyor…", diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index f7f0ba3f..7d4fca3d 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "加密分享。請將完整連結保密,其中包含解密金鑰。", + "Delete snapshot": "刪除快照", + "Snapshot deleted.": "快照已刪除。", + "Connected to encrypted live room": "已連線至加密的即時協作房間", "Linking folder": "正在連結資料夾", "Scanning folder…": "正在掃描資料夾…", "Reading files…": "正在讀取檔案…", diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index f242705d..d48d1376 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "Зашифрований спільний доступ. Зберігайте повне посилання в таємниці: воно містить ключ розшифрування.", + "Delete snapshot": "Видалити знімок", + "Snapshot deleted.": "Знімок видалено.", + "Connected to encrypted live room": "Підключено до зашифрованої кімнати", "Linking folder": "Підключення папки", "Scanning folder…": "Сканування папки…", "Reading files…": "Читання файлів…", diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 8636d728..c2d07794 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -1,4 +1,8 @@ { + "Encrypted sharing. Keep the complete link private; it includes the decryption key.": "加密分享。请将完整链接保密,其中包含解密密钥。", + "Delete snapshot": "删除快照", + "Snapshot deleted.": "快照已删除。", + "Connected to encrypted live room": "已连接到加密的实时协作房间", "Linking folder": "正在链接文件夹", "Scanning folder…": "正在扫描文件夹…", "Reading files…": "正在读取文件…", diff --git a/assets/yjs.LICENSE.txt b/assets/yjs.LICENSE.txt new file mode 100644 index 00000000..a922cce8 --- /dev/null +++ b/assets/yjs.LICENSE.txt @@ -0,0 +1,47 @@ +Yjs +The MIT License (MIT) + +Copyright (c) 2023 + - Kevin Jahns . + - Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +lib0 +The MIT License (MIT) + +Copyright (c) 2019 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/yjs.mjs b/assets/yjs.mjs new file mode 100644 index 00000000..b58dfe72 --- /dev/null +++ b/assets/yjs.mjs @@ -0,0 +1,5 @@ +var A=()=>new Map,qe=e=>{let t=A();return e.forEach((n,r)=>{t.set(r,n)}),t},L=(e,t,n)=>{let r=e.get(t);return r===void 0&&e.set(t,r=n()),r},Jr=(e,t)=>{let n=[];for(let[r,s]of e)n.push(t(s,r));return n},Xr=(e,t)=>{for(let[n,r]of e)if(t(r,n))return!0;return!1};var rt=()=>new Set;var Ge=e=>e[e.length-1];var Pr=(e,t)=>{for(let n=0;n{for(let n=0;n{for(let n=0;n{let n=new Array(e);for(let r=0;r{this.off(t,r),n(...s)};this.on(t,r)}off(t,n){let r=this._observers.get(t);r!==void 0&&(r.delete(n),r.size===0&&this._observers.delete(t))}emit(t,n){return Y((this._observers.get(t)||A()).values()).forEach(r=>r(...n))}destroy(){this._observers=A()}};var B=Math.floor;var yt=Math.abs;var He=(e,t)=>ee>t?e:t,kl=Number.isNaN;var Je=e=>e!==0?e<0:1/e<0;var Tt=Number.MAX_SAFE_INTEGER,Ln=Number.MIN_SAFE_INTEGER,Sl=1<<31;var Yr=Number.isInteger||(e=>typeof e=="number"&&isFinite(e)&&B(e)===e),_l=Number.isNaN,El=Number.parseInt;var Mn=String.fromCharCode,$i=String.fromCodePoint,Ul=Mn(65535),Fi=e=>e.toLowerCase(),ji=/^\s*/g,qi=e=>e.replace(ji,""),Gi=/([A-Z])/g,Rn=(e,t)=>qi(e.replace(Gi,n=>`${t}${Fi(n)}`));var zi=e=>{let t=unescape(encodeURIComponent(e)),n=t.length,r=new Uint8Array(n);for(let s=0;sOt.encode(e),Wr=Ot?Hi:zi;var vt=typeof TextDecoder>"u"?null:new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0});vt&&vt.decode(new Uint8Array).length===1&&(vt=null);var Xe=(e,t)=>Kr(t,()=>e).join("");var xt=class{constructor(){this.cpos=0,this.cbuf=new Uint8Array(100),this.bufs=[]}},kt=()=>new xt;var Ji=e=>{let t=e.cpos;for(let n=0;n{let t=new Uint8Array(Ji(e)),n=0;for(let r=0;r{let n=e.cbuf.length;n-e.cpos{let n=e.cbuf.length;e.cpos===n&&(e.bufs.push(e.cbuf),e.cbuf=new Uint8Array(n*2),e.cpos=0),e.cbuf[e.cpos++]=t};var Bt=D;var g=(e,t)=>{for(;t>127;)D(e,128|127&t),t=B(t/128);D(e,127&t)},ge=(e,t)=>{let n=Je(t);for(n&&(t=-t),D(e,(t>63?128:0)|(n?64:0)|63&t),t=B(t/64);t>0;)D(e,(t>127?128:0)|127&t),t=B(t/128)},$n=new Uint8Array(3e4),Pi=$n.length/3,Ki=(e,t)=>{if(t.length{let n=unescape(encodeURIComponent(t)),r=n.length;g(e,r);for(let s=0;sMt(e,q(t)),Mt=(e,t)=>{let n=e.cbuf.length,r=e.cpos,s=He(n-r,t.length),i=t.length-s;e.cbuf.set(t.subarray(0,s),r),e.cpos+=s,i>0&&(e.bufs.push(e.cbuf),e.cbuf=new Uint8Array(it(n*2,i)),e.cbuf.set(t.subarray(s)),e.cpos=i)},$=(e,t)=>{g(e,t.byteLength),Mt(e,t)},Fn=(e,t)=>{Xi(e,t);let n=new DataView(e.cbuf.buffer,e.cpos,t);return e.cpos+=t,n},Wi=(e,t)=>Fn(e,4).setFloat32(0,t,!1),Zi=(e,t)=>Fn(e,8).setFloat64(0,t,!1),Qi=(e,t)=>Fn(e,8).setBigInt64(0,t,!1);var Qr=new DataView(new ArrayBuffer(4)),to=e=>(Qr.setFloat32(0,e),Qr.getFloat32(0)===e),Nt=(e,t)=>{switch(typeof t){case"string":D(e,119),lt(e,t);break;case"number":Yr(t)&&yt(t)<=2147483647?(D(e,125),ge(e,t)):to(t)?(D(e,124),Wi(e,t)):(D(e,123),Zi(e,t));break;case"bigint":D(e,122),Qi(e,t);break;case"object":if(t===null)D(e,126);else if(st(t)){D(e,117),g(e,t.length);for(let n=0;n0&&g(this,this.count-1),this.count=1,this.w(this,t),this.s=t)}};var ts=e=>{e.count>0&&(ge(e.encoder,e.count===1?e.s:-e.s),e.count>1&&g(e.encoder,e.count-2))},bt=class{constructor(){this.encoder=new xt,this.s=0,this.count=0}write(t){this.s===t?this.count++:(ts(this),this.count=1,this.s=t)}toUint8Array(){return ts(this),q(this.encoder)}};var es=e=>{if(e.count>0){let t=e.diff*2+(e.count===1?0:1);ge(e.encoder,t),e.count>1&&g(e.encoder,e.count-2)}},Lt=class{constructor(){this.encoder=new xt,this.s=0,this.count=0,this.diff=0}write(t){this.diff===t-this.s?(this.s=t,this.count++):(es(this),this.count=1,this.diff=t-this.s,this.s=t)}toUint8Array(){return es(this),q(this.encoder)}},Pe=class{constructor(){this.sarr=[],this.s="",this.lensE=new bt}write(t){this.s+=t,this.s.length>19&&(this.sarr.push(this.s),this.s=""),this.lensE.write(t.length)}toUint8Array(){let t=new xt;return this.sarr.push(this.s),this.s="",lt(t,this.sarr.join("")),Mt(t,this.lensE.toUint8Array()),q(t)}};var G=e=>new Error(e),z=()=>{throw G("Method unimplemented")},T=()=>{throw G("Unexpected case")};var Ye=G("Unexpected end of array"),rs=G("Integer out of Range"),no=(e,t,n)=>{if(t<0||n<0||t+n>e.length)throw Ye;return new Uint8Array(e.buffer,e.byteOffset+t,n)},ro=(e,t,n)=>{if(t<0||n<0||t+n>e.length)throw Ye;return new DataView(e.buffer,e.byteOffset+t,n)},Rt=class{constructor(t){this.arr=t,this.pos=0}},v=e=>new Rt(e),qn=e=>e.pos!==e.arr.length;var so=(e,t)=>{let n=no(e.arr,e.pos,t);return e.pos+=t,n},F=e=>so(e,w(e));var St=e=>e.arr[e.pos++];var w=e=>{let t=0,n=1,r=e.arr.length;for(;e.posTt)throw rs}throw Ye},we=e=>{let t=e.arr[e.pos++],n=t&63,r=64,s=(t&64)>0?-1:1;if((t&128)===0)return s*n;let i=e.arr.length;for(;e.posTt)throw rs}throw Ye};var io=e=>{let t=w(e);if(t===0)return"";{let n=String.fromCodePoint(St(e));if(--t<100)for(;t--;)n+=String.fromCodePoint(St(e));else for(;t>0;){let r=t<1e4?t:1e4,s=e.arr.subarray(e.pos,e.pos+r);e.pos+=r,n+=String.fromCodePoint.apply(null,s),t-=r}return decodeURIComponent(escape(n))}},oo=e=>vt.decode(F(e)),ht=vt?oo:io;var Gn=(e,t)=>{let n=ro(e.arr,e.pos,t);return e.pos+=t,n},co=e=>Gn(e,4).getFloat32(0,!1),lo=e=>Gn(e,8).getFloat64(0,!1),ao=e=>Gn(e,8).getBigInt64(0,!1);var ho=[e=>{},e=>null,we,co,lo,ao,e=>!1,e=>!0,ht,e=>{let t=w(e),n={};for(let r=0;r{let t=w(e),n=[];for(let r=0;rho[127-St(e)](e),me=class extends Rt{constructor(t,n){super(t),this.reader=n,this.s=null,this.count=0}read(){return this.count===0&&(this.s=this.reader(this),qn(this)?this.count=w(this)+1:this.count=-1),this.count--,this.s}};var _t=class extends Rt{constructor(t){super(t),this.s=0,this.count=0}read(){if(this.count===0){this.s=we(this);let t=Je(this.s);this.count=1,t&&(this.s=-this.s,this.count=w(this)+2)}return this.count--,this.s}};var Ft=class extends Rt{constructor(t){super(t),this.s=0,this.count=0,this.diff=0}read(){if(this.count===0){let t=we(this),n=t&1;this.diff=B(t/2),this.count=1,n&&(this.count=w(this)+2)}return this.s+=this.diff,this.count--,this.s}},Ke=class{constructor(t){this.decoder=new _t(t),this.str=ht(this.decoder),this.spos=0}read(){let t=this.spos+this.decoder.read(),n=this.str.slice(this.spos,t);return this.spos=t,n}};var Il=crypto.subtle,ss=crypto.getRandomValues.bind(crypto);var zn=()=>ss(new Uint32Array(1))[0];var fo="10000000-1000-4000-8000"+-1e11,is=()=>fo.replace(/[018]/g,e=>(e^zn()&15>>e/4).toString(16));var We=Date.now;var Hn=e=>new Promise(e);var vl=Promise.all.bind(Promise);var Jn=e=>e===void 0?null:e;var Xn=class{constructor(){this.map=new Map}setItem(t,n){this.map.set(t,n)}getItem(t){return this.map.get(t)}},cs=new Xn,wo=!0;try{typeof window<"u"&&typeof localStorage<"u"&&localStorage&&(cs=localStorage,wo=!1)}catch{}var ls=cs;var Et=Symbol("Equality"),Ze=(e,t)=>e===t||!!e?.[Et]?.(t)||!1;var as=e=>typeof e=="object",hs=Object.assign,xo=Object.keys;var us=(e,t)=>{for(let n in e)t(e[n],n)};var ye=e=>xo(e).length;var ds=e=>{for(let t in e)return!1;return!0},jt=(e,t)=>{for(let n in e)if(!t(e[n],n))return!1;return!0},xe=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),Kn=(e,t)=>e===t||ye(e)===ye(t)&&jt(e,(n,r)=>(n!==void 0||xe(t,r))&&Ze(t[r],n)),bo=Object.freeze,Yn=e=>{for(let t in e){let n=e[t];(typeof n=="object"||typeof n=="function")&&Yn(e[t])}return bo(e)};var ke=(e,t,n=0)=>{try{for(;ne;var be=(e,t)=>{if(e===t)return!0;if(e==null||t==null||e.constructor!==t.constructor&&(e.constructor||Object)!==(t.constructor||Object))return!1;if(e[Et]!=null)return e[Et](t);switch(e.constructor){case ArrayBuffer:e=new Uint8Array(e),t=new Uint8Array(t);case Uint8Array:{if(e.byteLength!==t.byteLength)return!1;for(let n=0;nt.includes(e);var Se=typeof process<"u"&&process.release&&/node|io\.js/.test(process.release.name)&&Object.prototype.toString.call(typeof process<"u"?process:0)==="[object process]";var Ol=typeof navigator<"u"?/Mac/.test(navigator.platform):!1,W,ko=[],So=()=>{if(W===void 0)if(Se){W=A();let e=process.argv,t=null;for(let n=0;n{if(e.length!==0){let[t,n]=e.split("=");W.set(`--${Rn(t,"-")}`,n),W.set(`-${Rn(t,"-")}`,n)}})):W=A();return W},tr=e=>So().has(e);var _e=e=>Se?Jn(process.env[e.toUpperCase().replaceAll("-","_")]):Jn(ls.getItem(e));var ps=e=>tr("--"+e)||_e(e)!==null,gs=ps("production"),_o=Se&&fs(process.env.FORCE_COLOR,["true","1","2"]),ms=_o||!tr("--no-colors")&&!ps("no-color")&&(!Se||process.stdout.isTTY)&&(!Se||tr("--color")||_e("COLORTERM")!==null||(_e("TERM")||"").includes("color"));var Eo=e=>new Uint8Array(e);var ws=e=>{let t=Eo(e.byteLength);return t.set(e),t};var nr=class{constructor(t,n){this.left=t,this.right=n}},Z=(e,t)=>new nr(e,t);var rr=e=>e.next()>=.5,Qe=(e,t,n)=>B(e.next()*(n+1-t)+t);var sr=(e,t,n)=>B(e.next()*(n+1-t)+t);var ir=(e,t,n)=>sr(e,t,n);var Ao=e=>Mn(ir(e,97,122)),ys=(e,t=0,n=20)=>{let r=ir(e,t,n),s="";for(let i=0;it[ir(e,0,t.length-1)];var Do=Symbol("0schema"),or=class{constructor(){this._rerrs=[]}extend(t,n,r,s=null){this._rerrs.push({path:t,expected:n,has:r,message:s})}toString(){let t=[];for(let n=this._rerrs.length-1;n>0;n--){let r=this._rerrs[n];t.push(Xe(" ",(this._rerrs.length-n)*2)+`${r.path!=null?`[${r.path}] `:""}${r.has} doesn't match ${r.expected}. ${r.message}`)}return t.join(` +`)}},cr=(e,t)=>e===t?!0:e==null||t==null||e.constructor!==t.constructor?!1:e[Et]?Ze(e,t):st(e)?ze(e,n=>he(t,r=>cr(n,r))):as(e)?jt(e,(n,r)=>cr(n,t[r])):!1,O=class{static _dilutes=!1;extends(t){let[n,r]=[this.shape,t.shape];return this.constructor._dilutes&&([r,n]=[n,r]),cr(n,r)}equals(t){return this.constructor===t.constructor&&be(this.shape,t.shape)}[Do](){return!0}[Et](t){return this.equals(t)}validate(t){return this.check(t)}check(t,n){z()}get nullable(){return Jt(this,fn)}get optional(){return new en(this)}cast(t){return xs(t,this),t}expect(t){return xs(t,this),t}},Ee=class extends O{constructor(t,n){super(),this.shape=t,this._c=n}check(t,n=void 0){let r=t?.constructor===this.shape&&(this._c==null||this._c(t));return!r&&n?.extend(null,this.shape.name,t?.constructor.name,t?.constructor!==this.shape?"Constructor match failed":"Check failed"),r}},E=(e,t=null)=>new Ee(e,t),Nl=E(Ee),Ue=class extends O{constructor(t){super(),this.shape=t}check(t,n){let r=this.shape(t);return!r&&n?.extend(null,"custom prop",t?.constructor.name,"failed to check custom prop"),r}},I=e=>new Ue(e),Ll=E(Ue),zt=class extends O{constructor(t){super(),this.shape=t}check(t,n){let r=this.shape.some(s=>s===t);return!r&&n?.extend(null,this.shape.join(" | "),t.toString()),r}},un=(...e)=>new zt(e),bs=E(zt),To=RegExp.escape||(e=>e.replace(/[().|&,$^[\]]/g,t=>"\\"+t)),ks=e=>{if(Ht.check(e))return[To(e)];if(bs.check(e))return e.shape.map(t=>t+"");if(Cs.check(e))return["[+-]?\\d+.?\\d*"];if(As.check(e))return[".*"];if(an.check(e))return e.shape.map(ks).flat(1);T()},lr=class extends O{constructor(t){super(),this.shape=t,this._r=new RegExp("^"+t.map(ks).map(n=>`(${n.join("|")})`).join("")+"$")}check(t,n){let r=this._r.exec(t)!=null;return!r&&n?.extend(null,this._r.toString(),t.toString(),"String doesn't match string template."),r}};var Bl=E(lr),vo=Symbol("optional"),en=class extends O{constructor(t){super(),this.shape=t}check(t,n){let r=t===void 0||this.shape.check(t);return!r&&n?.extend(null,"undefined (optional)","()"),r}get[vo](){return!0}},Oo=E(en),nn=class extends O{check(t,n){return n?.extend(null,"never",typeof t),!1}},Ml=new nn,Rl=E(nn),rn=class e extends O{constructor(t,n=!1){super(),this.shape=t,this._isPartial=n}static _dilutes=!0;get partial(){return new e(this.shape,!0)}check(t,n){return t==null?(n?.extend(null,"object","null"),!1):jt(this.shape,(r,s)=>{let i=this._isPartial&&!xe(t,s)||r.check(t[s],n);return!i&&n?.extend(s.toString(),r.toString(),typeof t[s],"Object property does not match"),i})}},Vo=e=>new rn(e),No=E(rn),Lo=I(e=>e!=null&&(e.constructor===Object||e.constructor==null)),sn=class extends O{constructor(t,n){super(),this.shape={keys:t,values:n}}check(t,n){return t!=null&&jt(t,(r,s)=>{let i=this.shape.keys.check(s,n);return!i&&n?.extend(s+"","Record",typeof t,i?"Key doesn't match schema":"Value doesn't match value"),i&&this.shape.values.check(r,n)})}},Ss=(e,t)=>new sn(e,t),Bo=E(sn),on=class extends O{constructor(t){super(),this.shape=t}check(t,n){return t!=null&&jt(this.shape,(r,s)=>{let i=r.check(t[s],n);return!i&&n?.extend(s.toString(),"Tuple",typeof r),i})}},Mo=(...e)=>new on(e),$l=E(on),cn=class extends O{constructor(t){super(),this.shape=t.length===1?t[0]:new Ce(t)}check(t,n){let r=st(t)&&ze(t,s=>this.shape.check(s));return!r&&n?.extend(null,"Array",""),r}},_s=(...e)=>new cn(e),Ro=E(cn),$o=I(e=>st(e)),ln=class extends O{constructor(t,n){super(),this.shape=t,this._c=n}check(t,n){let r=t instanceof this.shape&&(this._c==null||this._c(t));return!r&&n?.extend(null,this.shape.name,t?.constructor.name),r}},Fo=(e,t=null)=>new ln(e,t),Fl=E(ln),jo=Fo(O),ar=class extends O{constructor(t){super(),this.len=t.length-1,this.args=Mo(...t.slice(-1)),this.res=t[this.len]}check(t,n){let r=t.constructor===Function&&t.length<=this.len;return!r&&n?.extend(null,"function",typeof t),r}};var qo=E(ar),Go=I(e=>typeof e=="function"),hr=class extends O{constructor(t){super(),this.shape=t}check(t,n){let r=ze(this.shape,s=>s.check(t,n));return!r&&n?.extend(null,"Intersectinon",typeof t),r}};var jl=E(hr,e=>e.shape.length>0),Ce=class extends O{static _dilutes=!0;constructor(t){super(),this.shape=t}check(t,n){let r=he(this.shape,s=>s.check(t,n));return n?.extend(null,"Union",typeof t),r}},Jt=(...e)=>e.findIndex(t=>an.check(t))>=0?Jt(...e.map(t=>Ae(t)).map(t=>an.check(t)?t.shape:[t]).flat(1)):e.length===1?e[0]:new Ce(e),an=E(Ce),Es=()=>!0,hn=I(Es),zo=E(Ue,e=>e.shape===Es),dr=I(e=>typeof e=="bigint"),Ho=I(e=>e===dr),Us=I(e=>typeof e=="symbol"),ql=I(e=>e===Us),Gt=I(e=>typeof e=="number"),Cs=I(e=>e===Gt),Ht=I(e=>typeof e=="string"),As=I(e=>e===Ht),dn=I(e=>typeof e=="boolean"),Jo=I(e=>e===dn),Is=un(void 0),Gl=E(zt,e=>e.shape.length===1&&e.shape[0]===void 0),zl=un(void 0);var fn=un(null),Xo=E(zt,e=>e.shape.length===1&&e.shape[0]===null),Hl=E(Uint8Array),Jl=E(Ee,e=>e.shape===Uint8Array),Po=Jt(Gt,Ht,fn,Is,dr,dn,Us),Xl=(()=>{let e=_s(hn),t=Ss(Ht,hn),n=Jt(Gt,Ht,fn,dn,e,t);return e.shape=n,t.shape.values=n,n})(),Ae=e=>{if(jo.check(e))return e;if(Lo.check(e)){let t={};for(let n in e)t[n]=Ae(e[n]);return Vo(t)}else{if($o.check(e))return Jt(...e.map(Ae));if(Po.check(e))return un(e);if(Go.check(e))return E(e)}T()},xs=gs?()=>{}:(e,t)=>{let n=new or;if(!t.check(e,n))throw G(`Expected value to be of type ${t.constructor.name}. +${n.toString()}`)},ur=class{constructor(t){this.patterns=[],this.$state=t}if(t,n){return this.patterns.push({if:Ae(t),h:n}),this}else(t){return this.if(hn,t)}done(){return(t,n)=>{for(let r=0;rnew ur(e),Ds=Ko(hn).if(Cs,(e,t)=>Qe(t,Ln,Tt)).if(As,(e,t)=>ys(t)).if(Jo,(e,t)=>rr(t)).if(Ho,(e,t)=>BigInt(Qe(t,Ln,Tt))).if(an,(e,t)=>qt(t,tn(t,e.shape))).if(No,(e,t)=>{let n={};for(let r in e.shape){let s=e.shape[r];if(Oo.check(s)){if(rr(t))continue;s=s.shape}n[r]=Ds(s,t)}return n}).if(Ro,(e,t)=>{let n=[],r=sr(t,0,42);for(let s=0;stn(t,e.shape)).if(Xo,(e,t)=>null).if(qo,(e,t)=>{let n=qt(t,e.res);return()=>n}).if(zo,(e,t)=>qt(t,tn(t,[Gt,Ht,fn,Is,dr,dn,_s(Gt),Ss(Jt("a","b","c"),Gt)]))).if(Bo,(e,t)=>{let n={},r=Qe(t,0,3);for(let s=0;sDs(Ae(t),e);var Ut=typeof document<"u"?document:{};var Pl=I(e=>e.nodeType===tc);var Kl=typeof DOMParser<"u"?new DOMParser:null;var Yl=I(e=>e.nodeType===Wo);var Wl=I(e=>e.nodeType===Zo);var Ts=e=>Jr(e,(t,n)=>`${n}:${t};`).join("");var Wo=Ut.ELEMENT_NODE,Zo=Ut.TEXT_NODE,Zl=Ut.CDATA_SECTION_NODE,Ql=Ut.COMMENT_NODE,Qo=Ut.DOCUMENT_NODE,ta=Ut.DOCUMENT_TYPE_NODE,tc=Ut.DOCUMENT_FRAGMENT_NODE,ea=I(e=>e.nodeType===Qo);var Q=Symbol;var Ie=Q(),De=Q(),fr=Q(),pr=Q(),gr=Q(),Te=Q(),mr=Q(),Xt=Q(),wr=Q(),vs=e=>{e.length===1&&e[0]?.constructor===Function&&(e=e[0]());let t=[],n=[],r=0;for(;r0&&n.push(t.join(""));r{e.length===1&&e[0]?.constructor===Function&&(e=e[0]());let t=[],n=[],r=A(),s=[],i=0;for(;i0||l.length>0?(t.push("%c"+o),n.push(l)):t.push(o)}else break}}for(i>0&&(s=n,s.unshift(t.join("")));i{console.log(...Os(e)),Vs.forEach(t=>t.print(e))},yr=(...e)=>{console.warn(...Os(e)),e.unshift(Xt),Vs.forEach(t=>t.print(e))};var Vs=rt();var Ns=e=>({[Symbol.iterator](){return this},next:e}),Ls=(e,t)=>Ns(()=>{let n;do n=e.next();while(!n.done&&!t(n.value));return n}),gn=(e,t)=>Ns(()=>{let{done:n,value:r}=e.next();return{done:n,value:n?void 0:t(r)}});var Rs=class extends Dt{constructor(t,n){super(),this.doc=t,this.awareness=n}},Yt=class{constructor(t,n){this.clock=t,this.len=n}},gt=class{constructor(){this.clients=new Map}},Wt=(e,t,n)=>t.clients.forEach((r,s)=>{let i=e.doc.store.clients.get(s);if(i!=null){let o=i[i.length-1],c=o.id.clock+o.length;for(let l=0,h=r[l];l{let n=0,r=e.length-1;for(;n<=r;){let s=B((n+r)/2),i=e[s],o=i.clock;if(o<=t){if(t{let n=e.clients.get(t.client);return n!==void 0&&hc(n,t.clock)!==null},Nr=e=>{e.clients.forEach(t=>{t.sort((s,i)=>s.clock-i.clock);let n,r;for(n=1,r=1;n=i.clock?t[r-1]=new Yt(s.clock,it(s.len,i.clock+i.len-s.clock)):(r{let t=new gt;for(let n=0;n{if(!t.clients.has(s)){let i=r.slice();for(let o=n+1;o{L(e.clients,t,()=>[]).push(new Yt(n,r))},Lr=()=>new gt,ri=e=>{let t=Lr();return e.clients.forEach((n,r)=>{let s=[];for(let i=0;i0&&t.clients.set(r,s)}),t},tt=(e,t)=>{g(e.restEncoder,t.clients.size),Y(t.clients.entries()).sort((n,r)=>r[0]-n[0]).forEach(([n,r])=>{e.resetDsCurVal(),g(e.restEncoder,n);let s=r.length;g(e.restEncoder,s);for(let i=0;i{let t=new gt,n=w(e.restDecoder);for(let r=0;r0){let o=L(t.clients,s,()=>[]);for(let c=0;c{let r=new gt,s=w(e.restDecoder);for(let i=0;i0){let i=new X;return g(i.restEncoder,0),tt(i,r),i.toUint8Array()}return null},uc=(e,t)=>{if(e.clients.size!==t.clients.size)return!1;for(let[n,r]of e.clients.entries()){let s=t.clients.get(n);if(s===void 0||r.length!==s.length)return!1;for(let i=0;i!0,meta:i=null,autoLoad:o=!1,shouldLoad:c=!0}={}){super(),this.gc=r,this.gcFilter=s,this.clientID=si(),this.guid=t,this.collectionid=n,this.share=new Map,this.store=new En,this._transaction=null,this._transactionCleanups=[],this.subdocs=new Set,this._item=null,this.shouldLoad=c,this.autoLoad=o,this.meta=i,this.isLoaded=!1,this.isSynced=!1,this.isDestroyed=!1,this.whenLoaded=Hn(h=>{this.on("load",()=>{this.isLoaded=!0,h(this)})});let l=()=>Hn(h=>{let a=u=>{(u===void 0||u===!0)&&(this.off("sync",a),h())};this.on("sync",a)});this.on("sync",h=>{h===!1&&this.isSynced&&(this.whenSynced=l()),this.isSynced=h===void 0||h===!0,this.isSynced&&!this.isLoaded&&this.emit("load",[this])}),this.whenSynced=l()}load(){let t=this._item;t!==null&&!this.shouldLoad&&x(t.parent.doc,n=>{n.subdocsLoaded.add(this)},null,!0),this.shouldLoad=!0}getSubdocs(){return this.subdocs}getSubdocGuids(){return new Set(Y(this.subdocs).map(t=>t.guid))}transact(t,n=null){return x(this,t,n)}get(t,n=U){let r=L(this.share,t,()=>{let i=new n;return i._integrate(this,null),i}),s=r.constructor;if(n!==U&&s!==n)if(s===U){let i=new n;i._map=r._map,r._map.forEach(o=>{for(;o!==null;o=o.left)o.parent=i}),i._start=r._start;for(let o=i._start;o!==null;o=o.right)o.parent=i;return i._length=r._length,this.share.set(t,i),i._integrate(this,null),i}else throw new Error(`Type with the name ${t} has already been defined with a different constructor`);return r}getArray(t=""){return this.get(t,ee)}getText(t=""){return this.get(t,Re)}getMap(t=""){return this.get(t,ne)}getXmlElement(t=""){return this.get(t,se)}getXmlFragment(t=""){return this.get(t,re)}toJSON(){let t={};return this.share.forEach((n,r)=>{t[r]=n.toJSON()}),t}destroy(){this.isDestroyed=!0,Y(this.subdocs).forEach(n=>n.destroy());let t=this._item;if(t!==null){this._item=null;let n=t.content;n.doc=new e({guid:this.guid,...n.opts,shouldLoad:!1}),n.doc._item=t,x(t.parent.doc,r=>{let s=n.doc;t.deleted||r.subdocsAdded.add(s),r.subdocsRemoved.add(this)},null,!0)}this.emit("destroyed",[!0]),this.emit("destroy",[this]),super.destroy()}},At=class{constructor(t){this.restDecoder=t}resetDsCurVal(){}readDsClock(){return w(this.restDecoder)}readDsLen(){return w(this.restDecoder)}},H=class extends At{readLeftID(){return m(w(this.restDecoder),w(this.restDecoder))}readRightID(){return m(w(this.restDecoder),w(this.restDecoder))}readClient(){return w(this.restDecoder)}readInfo(){return St(this.restDecoder)}readString(){return ht(this.restDecoder)}readParentInfo(){return w(this.restDecoder)===1}readTypeRef(){return w(this.restDecoder)}readLen(){return w(this.restDecoder)}readAny(){return $t(this.restDecoder)}readBuf(){return ws(F(this.restDecoder))}readJSON(){return JSON.parse(ht(this.restDecoder))}readKey(){return ht(this.restDecoder)}},kn=class{constructor(t){this.dsCurrVal=0,this.restDecoder=t}resetDsCurVal(){this.dsCurrVal=0}readDsClock(){return this.dsCurrVal+=w(this.restDecoder),this.dsCurrVal}readDsLen(){let t=w(this.restDecoder)+1;return this.dsCurrVal+=t,t}},j=class extends kn{constructor(t){super(t),this.keys=[],w(t),this.keyClockDecoder=new Ft(F(t)),this.clientDecoder=new _t(F(t)),this.leftClockDecoder=new Ft(F(t)),this.rightClockDecoder=new Ft(F(t)),this.infoDecoder=new me(F(t),St),this.stringDecoder=new Ke(F(t)),this.parentInfoDecoder=new me(F(t),St),this.typeRefDecoder=new _t(F(t)),this.lenDecoder=new _t(F(t))}readLeftID(){return new pt(this.clientDecoder.read(),this.leftClockDecoder.read())}readRightID(){return new pt(this.clientDecoder.read(),this.rightClockDecoder.read())}readClient(){return this.clientDecoder.read()}readInfo(){return this.infoDecoder.read()}readString(){return this.stringDecoder.read()}readParentInfo(){return this.parentInfoDecoder.read()===1}readTypeRef(){return this.typeRefDecoder.read()}readLen(){return this.lenDecoder.read()}readAny(){return $t(this.restDecoder)}readBuf(){return F(this.restDecoder)}readJSON(){return $t(this.restDecoder)}readKey(){let t=this.keyClockDecoder.read();if(t{r=it(r,t[0].id.clock);let s=P(t,r);g(e.restEncoder,t.length-s),e.writeClient(n),g(e.restEncoder,r);let i=t[s];i.write(e,r-i.id.clock);for(let o=s+1;o{let r=new Map;n.forEach((s,i)=>{_(t,i)>s&&r.set(i,s)}),je(t).forEach((s,i)=>{n.has(i)||r.set(i,0)}),g(e.restEncoder,r.size),Y(r.entries()).sort((s,i)=>i[0]-s[0]).forEach(([s,i])=>{dc(e,t.clients.get(s),s,i)})},fc=(e,t)=>{let n=A(),r=w(e.restDecoder);for(let s=0;s{let r=[],s=Y(n.keys()).sort((f,p)=>f-p);if(s.length===0)return null;let i=()=>{if(s.length===0)return null;let f=n.get(s[s.length-1]);for(;f.refs.length===f.i;)if(s.pop(),s.length>0)f=n.get(s[s.length-1]);else return null;return f},o=i();if(o===null)return null;let c=new En,l=new Map,h=(f,p)=>{let y=l.get(f);(y==null||y>p)&&l.set(f,p)},a=o.refs[o.i++],u=new Map,d=()=>{for(let f of r){let p=f.id.client,y=n.get(p);y?(y.i--,c.clients.set(p,y.refs.slice(y.i)),n.delete(p),y.i=0,y.refs=[]):c.clients.set(p,[f]),s=s.filter(k=>k!==p)}r.length=0};for(;;){if(a.constructor!==N){let p=L(u,a.id.client,()=>_(t,a.id.client))-a.id.clock;if(p<0)r.push(a),h(a.id.client,a.id.clock-1),d();else{let y=a.getMissing(e,t);if(y!==null){r.push(a);let k=n.get(y)||{refs:[],i:0};if(k.refs.length===k.i)h(y,_(t,y)),d();else{a=k.refs[k.i++];continue}}else(p===0||p0)a=r.pop();else if(o!==null&&o.i0){let f=new X;return Br(f,c,new Map),g(f.restEncoder,0),{missing:l,update:f.toUint8Array()}}return null},gc=(e,t)=>Br(e,t.doc.store,t.beforeState),ii=(e,t,n,r=new j(e))=>x(t,s=>{s.local=!1;let i=!1,o=s.doc,c=o.store,l=fc(r,o),h=pc(s,c,l),a=c.pendingStructs;if(a){for(let[d,f]of a.missing)if(f<_(c,d)){i=!0;break}if(h){for(let[d,f]of h.missing){let p=a.missing.get(d);(p==null||p>f)&&a.missing.set(d,f)}a.update=Un([a.update,h.update])}}else c.pendingStructs=h;let u=$s(r,s,c);if(c.pendingDs){let d=new j(v(c.pendingDs));w(d.restDecoder);let f=$s(d,s,c);u&&f?c.pendingDs=Un([u,f]):c.pendingDs=u||f}else c.pendingDs=u;if(i){let d=c.pendingStructs.update;c.pendingStructs=null,Mr(s.doc,d)}},n,!1),da=(e,t,n)=>ii(e,t,n,new H(e)),Mr=(e,t,n,r=j)=>{let s=v(t);ii(s,e,n,new r(s))},fa=(e,t,n)=>Mr(e,t,n,H),mc=(e,t,n=new Map)=>{Br(e,t.store,n),tt(e,ri(t.store))},wc=(e,t=new Uint8Array([0]),n=new X)=>{let r=ci(t);mc(n,e,r);let s=[n.toUint8Array()];if(e.store.pendingDs&&s.push(e.store.pendingDs),e.store.pendingStructs&&s.push(mi(e.store.pendingStructs.update,t)),s.length>1){if(n.constructor===ct)return Oc(s.map((i,o)=>o===0?i:Bc(i)));if(n.constructor===X)return Un(s)}return s[0]},pa=(e,t)=>wc(e,t,new ct),oi=e=>{let t=new Map,n=w(e.restDecoder);for(let r=0;roi(new At(v(e))),Rr=(e,t)=>(g(e.restEncoder,t.size),Y(t.entries()).sort((n,r)=>r[0]-n[0]).forEach(([n,r])=>{g(e.restEncoder,n),g(e.restEncoder,r)}),e),yc=(e,t)=>Rr(e,je(t.store)),xc=(e,t=new Qt)=>(e instanceof Map?Rr(t,e):yc(t,e),t.toUint8Array()),ga=e=>xc(e,new mt),br=class{constructor(){this.l=[]}},Fs=()=>new br,js=(e,t)=>e.l.push(t),qs=(e,t)=>{let n=e.l,r=n.length;e.l=n.filter(s=>t!==s),r===e.l.length&&console.error("[yjs] Tried to remove event handler that doesn't exist.")},li=(e,t,n)=>ke(e.l,[t,n]),pt=class{constructor(t,n){this.client=t,this.clock=n}},Pt=(e,t)=>e===t||e!==null&&t!==null&&e.client===t.client&&e.clock===t.clock,m=(e,t)=>new pt(e,t),Gs=(e,t)=>{g(e,t.client),g(e,t.clock)},zs=e=>m(w(e),w(e)),ai=e=>{for(let[t,n]of e.doc.share.entries())if(n===e)return t;throw T()},Sn=(e,t)=>{for(;t!==null;){if(t.parent===e)return!0;t=t.parent._item}return!1},ma=e=>{let t=[],n=e._start;for(;n;)t.push(n),n=n.right;console.log("Children: ",t),console.log("Children content: ",t.filter(r=>!r.deleted).map(r=>r.content))},Hs=class{constructor(t,n=t.getMap("users")){let r=new Map;this.yusers=n,this.doc=t,this.clients=new Map,this.dss=r;let s=(i,o)=>{let c=i.get("ds"),l=i.get("ids"),h=a=>this.clients.set(a,o);c.observe(a=>{a.changes.added.forEach(u=>{u.content.getContent().forEach(d=>{d instanceof Uint8Array&&this.dss.set(o,Zt([this.dss.get(o)||Lr(),ot(new At(v(d)))]))})})}),this.dss.set(o,Zt(c.map(a=>ot(new At(v(a)))))),l.observe(a=>a.changes.added.forEach(u=>u.content.getContent().forEach(h))),l.forEach(h)};n.observe(i=>{i.keysChanged.forEach(o=>s(n.get(o),o))}),n.forEach(s)}setUserMapping(t,n,r,{filter:s=()=>!0}={}){let i=this.yusers,o=i.get(r);o||(o=new ne,o.set("ids",new ee),o.set("ds",new ee),i.set(r,o)),o.get("ids").push([n]),i.observe(c=>{setTimeout(()=>{let l=i.get(r);if(l!==o){o=l,this.clients.forEach((u,d)=>{r===u&&o.get("ids").push([d])});let h=new mt,a=this.dss.get(r);a&&(tt(h,a),o.get("ds").push([h.toUint8Array()]))}},0)}),t.on("afterTransaction",c=>{setTimeout(()=>{let l=o.get("ds"),h=c.deleteSet;if(c.local&&h.clients.size>0&&s(c,h)){let a=new mt;tt(a,h),l.push([a.toUint8Array()])}})})}getUserByClientId(t){return this.clients.get(t)||null}getUserByDeletedId(t){for(let[n,r]of this.dss.entries())if(ce(r,t))return n;return null}},Ve=class{constructor(t,n,r,s=0){this.type=t,this.tname=n,this.item=r,this.assoc=s}},wa=e=>{let t={};return e.type&&(t.type=e.type),e.tname&&(t.tname=e.tname),e.item&&(t.item=e.item),e.assoc!=null&&(t.assoc=e.assoc),t},ya=e=>new Ve(e.type==null?null:m(e.type.client,e.type.clock),e.tname??null,e.item==null?null:m(e.item.client,e.item.clock),e.assoc==null?0:e.assoc),kr=class{constructor(t,n,r=0){this.type=t,this.index=n,this.assoc=r}},bc=(e,t,n=0)=>new kr(e,t,n),mn=(e,t,n)=>{let r=null,s=null;return e._item===null?s=ai(e):r=m(e._item.id.client,e._item.id.clock),new Ve(r,s,t,n)},xa=(e,t,n=0)=>{let r=e._start;if(n<0){if(t===0)return mn(e,null,n);t--}for(;r!==null;){if(!r.deleted&&r.countable){if(r.length>t)return mn(e,m(r.id.client,r.id.clock+t),n);t-=r.length}if(r.right===null&&n<0)return mn(e,r.lastId,n);r=r.right}return mn(e,null,n)},kc=(e,t)=>{let{type:n,tname:r,item:s,assoc:i}=t;if(s!==null)g(e,0),Gs(e,s);else if(r!==null)Bt(e,1),lt(e,r);else if(n!==null)Bt(e,2),Gs(e,n);else throw T();return ge(e,i),e},ba=e=>{let t=kt();return kc(t,e),q(t)},Sc=e=>{let t=null,n=null,r=null;switch(w(e)){case 0:r=zs(e);break;case 1:n=ht(e);break;case 2:t=zs(e)}let s=qn(e)?we(e):0;return new Ve(t,n,r,s)},ka=e=>Sc(v(e)),_c=(e,t)=>{let n=Kt(e,t),r=t.clock-n.id.clock;return{item:n,diff:r}},Sa=(e,t,n=!0)=>{let r=t.store,s=e.item,i=e.type,o=e.tname,c=e.assoc,l=null,h=0;if(s!==null){if(_(r,s.client)<=s.clock)return null;let a=n?Or(r,s):_c(r,s),u=a.item;if(!(u instanceof b))return null;if(l=u.parent,l._item===null||!l._item.deleted){h=u.deleted||!u.countable?0:a.diff+(c>=0?0:1);let d=u.left;for(;d!==null;)!d.deleted&&d.countable&&(h+=d.length),d=d.left}}else{if(o!==null)l=t.get(o);else if(i!==null){if(_(r,i.client)<=i.clock)return null;let{item:a}=n?Or(r,i):{item:Kt(r,i)};if(a instanceof b&&a.content instanceof K)l=a.content.type;else return null}else throw T();c>=0?h=l._length:h=0}return bc(l,h,e.assoc)},_a=(e,t)=>e===t||e!==null&&t!==null&&e.tname===t.tname&&Pt(e.item,t.item)&&Pt(e.type,t.type)&&e.assoc===t.assoc,_n=class{constructor(t,n){this.ds=t,this.sv=n}},Ea=(e,t)=>{let n=e.ds.clients,r=t.ds.clients,s=e.sv,i=t.sv;if(s.size!==i.size||n.size!==r.size)return!1;for(let[o,c]of s.entries())if(i.get(o)!==c)return!1;for(let[o,c]of n.entries()){let l=r.get(o)||[];if(c.length!==l.length)return!1;for(let h=0;h(tt(t,e.ds),Rr(t,e.sv),t.toUint8Array()),Ua=e=>Ec(e,new mt),Uc=(e,t=new kn(v(e)))=>new _n(ot(t),oi(t)),Ca=e=>Uc(e,new At(v(e))),hi=(e,t)=>new _n(e,t),Aa=hi(Lr(),new Map),Ia=e=>hi(ri(e.store),je(e.store)),ut=(e,t)=>t===void 0?!e.deleted:t.sv.has(e.id.client)&&(t.sv.get(e.id.client)||0)>e.id.clock&&!ce(t.ds,e.id),Sr=(e,t)=>{let n=L(e.meta,Sr,rt),r=e.doc.store;n.has(t)||(t.sv.forEach((s,i)=>{s<_(r,i)&&M(e,m(i,s))}),Wt(e,t.ds,s=>{}),n.add(t))},Da=(e,t,n=new Ct)=>{if(e.gc)throw new Error("Garbage-collection must be disabled in `originDoc`!");let{sv:r,ds:s}=t,i=new X;return e.transact(o=>{let c=0;r.forEach(l=>{l>0&&c++}),g(i.restEncoder,c);for(let[l,h]of r){if(h===0)continue;h<_(e.store,l)&&M(o,m(l,h));let a=e.store.clients.get(l)||[],u=P(a,h-1);g(i.restEncoder,u+1),i.writeClient(l),g(i.restEncoder,0);for(let d=0;d<=u;d++)a[d].write(i,0)}tt(i,s)}),Mr(n,i.toUint8Array(),"snapshot"),n},Cc=(e,t,n=j)=>{let r=new n(v(t)),s=new et(r,!1);for(let o=s.curr;o!==null;o=s.next())if((e.sv.get(o.id.client)||0)Cc(e,t,H),En=class{constructor(){this.clients=new Map,this.pendingStructs=null,this.pendingDs=null}},je=e=>{let t=new Map;return e.clients.forEach((n,r)=>{let s=n[n.length-1];t.set(r,s.id.clock+s.length)}),t},_=(e,t)=>{let n=e.clients.get(t);if(n===void 0)return 0;let r=n[n.length-1];return r.id.clock+r.length},ui=(e,t)=>{let n=e.clients.get(t.id.client);if(n===void 0)n=[],e.clients.set(t.id.client,n);else{let r=n[n.length-1];if(r.id.clock+r.length!==t.id.clock)throw T()}n.push(t)},P=(e,t)=>{let n=0,r=e.length-1,s=e[r],i=s.id.clock;if(i===t)return r;let o=B(t/(i+s.length-1)*r);for(;n<=r;){if(s=e[o],i=s.id.clock,i<=t){if(t{let n=e.clients.get(t.client);return n[P(n,t.clock)]},Kt=Ac,_r=(e,t,n)=>{let r=P(t,n),s=t[r];return s.id.clock{let n=e.doc.store.clients.get(t.client);return n[_r(e,n,t.clock)]},Js=(e,t,n)=>{let r=t.clients.get(n.client),s=P(r,n.clock),i=r[s];return n.clock!==i.id.clock+i.length-1&&i.constructor!==R&&r.splice(s+1,0,Tn(e,i,n.clock-i.id.clock+1)),i},Ic=(e,t,n)=>{let r=e.clients.get(t.id.client);r[P(r,t.id.clock)]=n},di=(e,t,n,r,s)=>{if(r===0)return;let i=n+r,o=_r(e,t,n),c;do c=t[o++],it.deleteSet.clients.size===0&&!Xr(t.afterState,(n,r)=>t.beforeState.get(r)!==n)?!1:(Nr(t.deleteSet),gc(e,t),tt(e,t.deleteSet),!0),Ps=(e,t,n)=>{let r=t._item;(r===null||r.id.clock<(e.beforeState.get(r.id.client)||0)&&!r.deleted)&&L(e.changed,t,rt).add(n)},xn=(e,t)=>{let n=e[t],r=e[t-1],s=t;for(;s>0;n=r,r=e[--s-1]){if(r.deleted===n.deleted&&r.constructor===n.constructor&&r.mergeWith(n)){n instanceof b&&n.parentSub!==null&&n.parent._map.get(n.parentSub)===n&&n.parent._map.set(n.parentSub,r);continue}break}let i=t-s;return i&&e.splice(t+1-i,i),i},fi=(e,t,n)=>{for(let[r,s]of e.clients.entries()){let i=t.clients.get(r);for(let o=s.length-1;o>=0;o--){let c=s[o],l=c.clock+c.len;for(let h=P(i,c.clock),a=i[h];h{e.clients.forEach((n,r)=>{let s=t.clients.get(r);for(let i=n.length-1;i>=0;i--){let o=n[i],c=He(s.length-1,1+P(s,o.clock+o.len-1));for(let l=c,h=s[l];l>0&&h.id.clock>=o.clock;h=s[l])l-=1+xn(s,l)}})},va=(e,t,n)=>{fi(e,t,n),pi(e,t)},gi=(e,t)=>{if(tc.push(()=>{(h._item===null||!h._item.deleted)&&h._callObserver(n,l)})),c.push(()=>{n.changedParentTypes.forEach((l,h)=>{h._dEH.l.length>0&&(h._item===null||!h._item.deleted)&&(l=l.filter(a=>a.target._item===null||!a.target._item.deleted),l.forEach(a=>{a.currentTarget=h,a._path=null}),l.sort((a,u)=>a.path.length-u.path.length),c.push(()=>{li(h._dEH,l,n)}))}),c.push(()=>r.emit("afterTransaction",[n,r])),c.push(()=>{n._needFormattingCleanup&&Xc(n)})}),ke(c,[])}finally{r.gc&&fi(i,s,r.gcFilter),pi(i,s),n.afterState.forEach((a,u)=>{let d=n.beforeState.get(u)||0;if(d!==a){let f=s.clients.get(u),p=it(P(f,d),1);for(let y=f.length-1;y>=p;)y-=1+xn(f,y)}});for(let a=o.length-1;a>=0;a--){let{client:u,clock:d}=o[a].id,f=s.clients.get(u),p=P(f,d);p+11||p>0&&xn(f,p)}if(!n.local&&n.afterState.get(r.clientID)!==n.beforeState.get(r.clientID)&&(pn(Xt,Ie,"[yjs] ",De,Te,"Changed the client-id because another client seems to be using it."),r.clientID=si()),r.emit("afterTransactionCleanup",[n,r]),r._observers.has("update")){let a=new ct;Xs(a,n)&&r.emit("update",[a.toUint8Array(),n.origin,r,n])}if(r._observers.has("updateV2")){let a=new X;Xs(a,n)&&r.emit("updateV2",[a.toUint8Array(),n.origin,r,n])}let{subdocsAdded:c,subdocsLoaded:l,subdocsRemoved:h}=n;(c.size>0||h.size>0||l.size>0)&&(c.forEach(a=>{a.clientID=r.clientID,a.collectionid==null&&(a.collectionid=r.collectionid),r.subdocs.add(a)}),h.forEach(a=>r.subdocs.delete(a)),r.emit("subdocs",[{loaded:l,added:c,removed:h},r,n]),h.forEach(a=>a.destroy())),e.length<=t+1?(r._transactionCleanups=[],r.emit("afterAllTransactions",[r,e])):gi(e,t+1)}}},x=(e,t,n=null,r=!0)=>{let s=e._transactionCleanups,i=!1,o=null;e._transaction===null&&(i=!0,e._transaction=new Er(e,n,r),s.push(e._transaction),s.length===1&&e.emit("beforeAllTransactions",[e]),e.emit("beforeTransaction",[e._transaction,e]));try{o=t(e._transaction)}finally{if(i){let c=e._transaction===s[0];e._transaction=null,c&&gi(s,0)}}return o},Ur=class{constructor(t,n){this.insertions=n,this.deletions=t,this.meta=new Map}},Ks=(e,t,n)=>{Wt(e,n.deletions,r=>{r instanceof b&&t.scope.some(s=>s===e.doc||Sn(s,r))&&Gr(r,!1)})},Ys=(e,t,n)=>{let r=null,s=e.doc,i=e.scope;x(s,c=>{for(;t.length>0&&e.currStackItem===null;){let l=s.store,h=t.pop(),a=new Set,u=[],d=!1;Wt(c,h.insertions,f=>{if(f instanceof b){if(f.redone!==null){let{item:p,diff:y}=Or(l,f.id);y>0&&(p=M(c,m(p.id.client,p.id.clock+y))),f=p}!f.deleted&&i.some(p=>p===c.doc||Sn(p,f))&&u.push(f)}}),Wt(c,h.deletions,f=>{f instanceof b&&i.some(p=>p===c.doc||Sn(p,f))&&!ce(h.insertions,f.id)&&a.add(f)}),a.forEach(f=>{d=Li(c,f,a,h.insertions,e.ignoreRemoteMapChanges,e)!==null||d});for(let f=u.length-1;f>=0;f--){let p=u[f];e.deleteFilter(p)&&(p.delete(c),d=!0)}e.currStackItem=d?h:null}c.changed.forEach((l,h)=>{l.has(null)&&h._searchMarker&&(h._searchMarker.length=0)}),r=c},e);let o=e.currStackItem;if(o!=null){let c=r.changedParentTypes;e.emit("stack-item-popped",[{stackItem:o,type:n,changedParentTypes:c,origin:e},e]),e.currStackItem=null}return o},Ws=class extends Dt{constructor(t,{captureTimeout:n=500,captureTransaction:r=l=>!0,deleteFilter:s=()=>!0,trackedOrigins:i=new Set([null]),ignoreRemoteMapChanges:o=!1,doc:c=st(t)?t[0].doc:t instanceof Ct?t:t.doc}={}){super(),this.scope=[],this.doc=c,this.addToScope(t),this.deleteFilter=s,i.add(this),this.trackedOrigins=i,this.captureTransaction=r,this.undoStack=[],this.redoStack=[],this.undoing=!1,this.redoing=!1,this.currStackItem=null,this.lastChange=0,this.ignoreRemoteMapChanges=o,this.captureTimeout=n,this.afterTransactionHandler=l=>{if(!this.captureTransaction(l)||!this.scope.some(k=>l.changedParentTypes.has(k)||k===this.doc)||!this.trackedOrigins.has(l.origin)&&(!l.origin||!this.trackedOrigins.has(l.origin.constructor)))return;let h=this.undoing,a=this.redoing,u=h?this.redoStack:this.undoStack;h?this.stopCapturing():a||this.clear(!1,!0);let d=new gt;l.afterState.forEach((k,S)=>{let zr=l.beforeState.get(S)||0,Hr=k-zr;Hr>0&&Oe(d,S,zr,Hr)});let f=We(),p=!1;if(this.lastChange>0&&f-this.lastChange0&&!h&&!a){let k=u[u.length-1];k.deletions=Zt([k.deletions,l.deleteSet]),k.insertions=Zt([k.insertions,d])}else u.push(new Ur(l.deleteSet,d)),p=!0;!h&&!a&&(this.lastChange=f),Wt(l,l.deleteSet,k=>{k instanceof b&&this.scope.some(S=>S===l.doc||Sn(S,k))&&Gr(k,!0)});let y=[{stackItem:u[u.length-1],origin:l.origin,type:h?"redo":"undo",changedParentTypes:l.changedParentTypes},this];p?this.emit("stack-item-added",y):this.emit("stack-item-updated",y)},this.doc.on("afterTransaction",this.afterTransactionHandler),this.doc.on("destroy",()=>{this.destroy()})}addToScope(t){let n=new Set(this.scope);t=st(t)?t:[t],t.forEach(r=>{n.has(r)||(n.add(r),(r instanceof U?r.doc!==this.doc:r!==this.doc)&&yr("[yjs#509] Not same Y.Doc"),this.scope.push(r))})}addTrackedOrigin(t){this.trackedOrigins.add(t)}removeTrackedOrigin(t){this.trackedOrigins.delete(t)}clear(t=!0,n=!0){(t&&this.canUndo()||n&&this.canRedo())&&this.doc.transact(r=>{t&&(this.undoStack.forEach(s=>Ks(r,this,s)),this.undoStack=[]),n&&(this.redoStack.forEach(s=>Ks(r,this,s)),this.redoStack=[]),this.emit("stack-cleared",[{undoStackCleared:t,redoStackCleared:n}])})}stopCapturing(){this.lastChange=0}undo(){this.undoing=!0;let t;try{t=Ys(this,this.undoStack,"undo")}finally{this.undoing=!1}return t}redo(){this.redoing=!0;let t;try{t=Ys(this,this.redoStack,"redo")}finally{this.redoing=!1}return t}canUndo(){return this.undoStack.length>0}canRedo(){return this.redoStack.length>0}destroy(){this.trackedOrigins.delete(this),this.doc.off("afterTransaction",this.afterTransactionHandler),super.destroy()}};function*Dc(e){let t=w(e.restDecoder);for(let n=0;nTc(e,H),Tc=(e,t=j)=>{let n=[],r=new t(v(e)),s=new et(r,!1);for(let o=s.curr;o!==null;o=s.next())n.push(o);pn("Structs: ",n);let i=ot(r);pn("DeleteSet: ",i)},Va=e=>vc(e,H),vc=(e,t=j)=>{let n=[],r=new t(v(e)),s=new et(r,!1);for(let i=s.curr;i!==null;i=s.next())n.push(i);return{structs:n,ds:ot(r)}},Ne=class{constructor(t){this.currClient=0,this.startClock=0,this.written=0,this.encoder=t,this.clientStructs=[]}},Oc=e=>Un(e,H,ct),Vc=(e,t=Qt,n=j)=>{let r=new t,s=new et(new n(v(e)),!1),i=s.curr;if(i!==null){let o=0,c=i.id.client,l=i.id.clock!==0,h=l?0:i.id.clock+i.length;for(;i!==null;i=s.next())c!==i.id.client&&(h!==0&&(o++,g(r.restEncoder,c),g(r.restEncoder,h)),c=i.id.client,h=0,l=i.id.clock!==0),i.constructor===N&&(l=!0),l||(h=i.id.clock+i.length);h!==0&&(o++,g(r.restEncoder,c),g(r.restEncoder,h));let a=kt();return g(a,o),ns(a,r.restEncoder),r.restEncoder=a,r.toUint8Array()}else return g(r.restEncoder,0),r.toUint8Array()},Na=e=>Vc(e,mt,H),Nc=(e,t=j)=>{let n=new Map,r=new Map,s=new et(new t(v(e)),!1),i=s.curr;if(i!==null){let o=i.id.client,c=i.id.clock;for(n.set(o,c);i!==null;i=s.next())o!==i.id.client&&(r.set(o,c),n.set(i.id.client,i.id.clock),o=i.id.client),c=i.id.clock+i.length;r.set(o,c)}return{from:n,to:r}},La=e=>Nc(e,H),Lc=(e,t)=>{if(e.constructor===R){let{client:n,clock:r}=e.id;return new R(m(n,r+t),e.length-t)}else if(e.constructor===N){let{client:n,clock:r}=e.id;return new N(m(n,r+t),e.length-t)}else{let n=e,{client:r,clock:s}=n.id;return new b(m(r,s+t),null,m(r,s+t-1),null,n.rightOrigin,n.parent,n.parentSub,n.content.splice(t))}},Un=(e,t=j,n=X)=>{if(e.length===1)return e[0];let r=e.map(a=>new t(v(a))),s=r.map(a=>new et(a,!0)),i=null,o=new n,c=new Ne(o);for(;s=s.filter(d=>d.curr!==null),s.sort((d,f)=>{if(d.curr.id.client===f.curr.id.client){let p=d.curr.id.clock-f.curr.id.clock;return p===0?d.curr.constructor===f.curr.constructor?0:d.curr.constructor===N?1:-1:p}else return f.curr.id.client-d.curr.id.client}),s.length!==0;){let a=s[0],u=a.curr.id.client;if(i!==null){let d=a.curr,f=!1;for(;d!==null&&d.id.clock+d.length<=i.struct.id.clock+i.struct.length&&d.id.client>=i.struct.id.client;)d=a.next(),f=!0;if(d===null||d.id.client!==u||f&&d.id.clock>i.struct.id.clock+i.struct.length)continue;if(u!==i.struct.id.client)dt(c,i.struct,i.offset),i={struct:d,offset:0},a.next();else if(i.struct.id.clock+i.struct.length0&&(i.struct.constructor===N?i.struct.length-=p:d=Lc(d,p)),i.struct.mergeWith(d)||(dt(c,i.struct,i.offset),i={struct:d,offset:0},a.next())}}else i={struct:a.curr,offset:0},a.next();for(let d=a.curr;d!==null&&d.id.client===u&&d.id.clock===i.struct.id.clock+i.struct.length&&d.constructor!==N;d=a.next())dt(c,i.struct,i.offset),i={struct:d,offset:0}}i!==null&&(dt(c,i.struct,i.offset),i=null),$r(c);let l=r.map(a=>ot(a)),h=Zt(l);return tt(o,h),o.toUint8Array()},mi=(e,t,n=j,r=X)=>{let s=ci(t),i=new r,o=new Ne(i),c=new n(v(e)),l=new et(c,!1);for(;l.curr;){let a=l.curr,u=a.id.client,d=s.get(u)||0;if(l.curr.constructor===N){l.next();continue}if(a.id.clock+a.length>d)for(dt(o,a,it(d-a.id.clock,0)),l.next();l.curr&&l.curr.id.client===u;)dt(o,l.curr,0),l.next();else for(;l.curr&&l.curr.id.client===u&&l.curr.id.clock+l.curr.length<=d;)l.next()}$r(o);let h=ot(c);return tt(i,h),i.toUint8Array()},Ba=(e,t)=>mi(e,t,H,ct),wi=e=>{e.written>0&&(e.clientStructs.push({written:e.written,restEncoder:q(e.encoder.restEncoder)}),e.encoder.restEncoder=kt(),e.written=0)},dt=(e,t,n)=>{e.written>0&&e.currClient!==t.id.client&&wi(e),e.written===0&&(e.currClient=t.id.client,e.encoder.writeClient(t.id.client),g(e.encoder.restEncoder,t.id.clock+n)),t.write(e.encoder,n),e.written++},$r=e=>{wi(e);let t=e.encoder.restEncoder;g(t,e.clientStructs.length);for(let n=0;n{let s=new n(v(e)),i=new et(s,!1),o=new r,c=new Ne(o);for(let h=i.curr;h!==null;h=i.next())dt(c,t(h),0);$r(c);let l=ot(s);return tt(o,l),o.toUint8Array()},yi=({formatting:e=!0,subdocs:t=!0,yxml:n=!0}={})=>{let r=0,s=A(),i=A(),o=A(),c=A();return c.set(null,null),l=>{switch(l.constructor){case R:case N:return l;case b:{let h=l,a=h.content;switch(a.constructor){case Fe:break;case K:{if(n){let u=a.type;u instanceof se&&(u.nodeName=L(i,u.nodeName,()=>"node-"+r)),u instanceof In&&(u.hookName=L(i,u.hookName,()=>"hook-"+r))}break}case It:{let u=a;u.arr=u.arr.map(()=>r);break}case ie:{let u=a;u.content=new Uint8Array([r]);break}case oe:{let u=a;t&&(u.opts={},u.doc.guid=r+"");break}case wt:{let u=a;u.embed={};break}case C:{let u=a;e&&(u.key=L(o,u.key,()=>r+""),u.value=L(c,u.value,()=>({i:r})));break}case Dn:{let u=a;u.arr=u.arr.map(()=>r);break}case nt:{let u=a;u.str=Xe(r%10+"",u.str.length);break}default:T()}return h.parentSub&&(h.parentSub=L(s,h.parentSub,()=>r+"")),r++,l}default:T()}}},Ma=(e,t)=>vn(e,yi(t),H,ct),Ra=(e,t)=>vn(e,yi(t),j,X),$a=e=>vn(e,Zn,H,X),Bc=e=>vn(e,Zn,j,ct),Zs="You must not compute changes after the event-handler fired.",te=class{constructor(t,n){this.target=t,this.currentTarget=t,this.transaction=n,this._changes=null,this._keys=null,this._delta=null,this._path=null}get path(){return this._path||(this._path=Mc(this.currentTarget,this.target))}deletes(t){return ce(this.transaction.deleteSet,t.id)}get keys(){if(this._keys===null){if(this.transaction.doc._transactionCleanups.length===0)throw G(Zs);let t=new Map,n=this.target;this.transaction.changed.get(n).forEach(s=>{if(s!==null){let i=n._map.get(s),o,c;if(this.adds(i)){let l=i.left;for(;l!==null&&this.adds(l);)l=l.left;if(this.deletes(i))if(l!==null&&this.deletes(l))o="delete",c=Ge(l.content.getContent());else return;else l!==null&&this.deletes(l)?(o="update",c=Ge(l.content.getContent())):(o="add",c=void 0)}else if(this.deletes(i))o="delete",c=Ge(i.content.getContent());else return;t.set(s,{action:o,oldValue:c})}}),this._keys=t}return this._keys}get delta(){return this.changes.delta}adds(t){return t.id.clock>=(this.transaction.beforeState.get(t.id.client)||0)}get changes(){let t=this._changes;if(t===null){if(this.transaction.doc._transactionCleanups.length===0)throw G(Zs);let n=this.target,r=rt(),s=rt(),i=[];if(t={added:r,deleted:s,delta:i,keys:this.keys},this.transaction.changed.get(n).has(null)){let c=null,l=()=>{c&&i.push(c)};for(let h=n._start;h!==null;h=h.right)h.deleted?this.deletes(h)&&!this.adds(h)&&((c===null||c.delete===void 0)&&(l(),c={delete:0}),c.delete+=h.length,s.add(h)):this.adds(h)?((c===null||c.insert===void 0)&&(l(),c={insert:[]}),c.insert=c.insert.concat(h.content.getContent()),r.add(h)):((c===null||c.retain===void 0)&&(l(),c={retain:0}),c.retain+=h.length);c!==null&&c.retain===void 0&&l()}this._changes=t}return t}},Mc=(e,t)=>{let n=[];for(;t._item!==null&&t!==e;){if(t._item.parentSub!==null)n.unshift(t._item.parentSub);else{let r=0,s=t._item.parent._start;for(;s!==t._item&&s!==null;)!s.deleted&&s.countable&&(r+=s.length),s=s.right;n.unshift(r)}t=t._item.parent}return n},V=()=>{yr("Invalid access: Add Yjs type to a document before reading data.")},xi=80,Fr=0,Cr=class{constructor(t,n){t.marker=!0,this.p=t,this.index=n,this.timestamp=Fr++}},Rc=e=>{e.timestamp=Fr++},bi=(e,t,n)=>{e.p.marker=!1,e.p=t,t.marker=!0,e.index=n,e.timestamp=Fr++},$c=(e,t,n)=>{if(e.length>=xi){let r=e.reduce((s,i)=>s.timestamp{if(e._start===null||t===0||e._searchMarker===null)return null;let n=e._searchMarker.length===0?null:e._searchMarker.reduce((i,o)=>yt(t-i.index)t;)r=r.left,!r.deleted&&r.countable&&(s-=r.length);for(;r.left!==null&&r.left.id.client===r.id.client&&r.left.id.clock+r.left.length===r.id.clock;)r=r.left,!r.deleted&&r.countable&&(s-=r.length);return n!==null&&yt(n.index-s){for(let r=e.length-1;r>=0;r--){let s=e[r];if(n>0){let i=s.p;for(i.marker=!1;i&&(i.deleted||!i.countable);)i=i.left,i&&!i.deleted&&i.countable&&(s.index-=i.length);if(i===null||i.marker===!0){e.splice(r,1);continue}s.p=i,i.marker=!0}(t0&&t===s.index)&&(s.index=it(t,s.index+n))}},Fa=e=>{e.doc??V();let t=e._start,n=[];for(;t;)n.push(t),t=t.right;return n},Vn=(e,t,n)=>{let r=e,s=t.changedParentTypes;for(;L(s,e,()=>[]).push(n),e._item!==null;)e=e._item.parent;li(r._eH,n,t)},U=class{constructor(){this._item=null,this._map=new Map,this._start=null,this.doc=null,this._length=0,this._eH=Fs(),this._dEH=Fs(),this._searchMarker=null}get parent(){return this._item?this._item.parent:null}_integrate(t,n){this.doc=t,this._item=n}_copy(){throw z()}clone(){throw z()}_write(t){}get _first(){let t=this._start;for(;t!==null&&t.deleted;)t=t.right;return t}_callObserver(t,n){!t.local&&this._searchMarker&&(this._searchMarker.length=0)}observe(t){js(this._eH,t)}observeDeep(t){js(this._dEH,t)}unobserve(t){qs(this._eH,t)}unobserveDeep(t){qs(this._dEH,t)}toJSON(){}},ki=(e,t,n)=>{e.doc??V(),t<0&&(t=e._length+t),n<0&&(n=e._length+n);let r=n-t,s=[],i=e._start;for(;i!==null&&r>0;){if(i.countable&&!i.deleted){let o=i.content.getContent();if(o.length<=t)t-=o.length;else{for(let c=t;c0;c++)s.push(o[c]),r--;t=0}}i=i.right}return s},Si=e=>{e.doc??V();let t=[],n=e._start;for(;n!==null;){if(n.countable&&!n.deleted){let r=n.content.getContent();for(let s=0;s{let n=[],r=e._start;for(;r!==null;){if(r.countable&&ut(r,t)){let s=r.content.getContent();for(let i=0;i{let n=0,r=e._start;for(e.doc??V();r!==null;){if(r.countable&&!r.deleted){let s=r.content.getContent();for(let i=0;i{let n=[];return Be(e,(r,s)=>{n.push(t(r,s,e))}),n},Fc=e=>{let t=e._start,n=null,r=0;return{[Symbol.iterator](){return this},next:()=>{if(n===null){for(;t!==null&&t.deleted;)t=t.right;if(t===null)return{done:!0,value:void 0};n=t.content.getContent(),r=0,t=t.right}let s=n[r++];return n.length<=r&&(n=null),{done:!1,value:s}}}},Ei=(e,t)=>{e.doc??V();let n=On(e,t),r=e._start;for(n!==null&&(r=n.p,t-=n.index);r!==null;r=r.right)if(!r.deleted&&r.countable){if(t{let s=n,i=e.doc,o=i.clientID,c=i.store,l=n===null?t._start:n.right,h=[],a=()=>{h.length>0&&(s=new b(m(o,_(c,o)),s,s&&s.lastId,l,l&&l.id,t,null,new It(h)),s.integrate(e,0),h=[])};r.forEach(u=>{if(u===null)h.push(u);else switch(u.constructor){case Number:case Object:case Boolean:case Array:case String:h.push(u);break;default:switch(a(),u.constructor){case Uint8Array:case ArrayBuffer:s=new b(m(o,_(c,o)),s,s&&s.lastId,l,l&&l.id,t,null,new ie(new Uint8Array(u))),s.integrate(e,0);break;case Ct:s=new b(m(o,_(c,o)),s,s&&s.lastId,l,l&&l.id,t,null,new oe(u)),s.integrate(e,0);break;default:if(u instanceof U)s=new b(m(o,_(c,o)),s,s&&s.lastId,l,l&&l.id,t,null,new K(u)),s.integrate(e,0);else throw new Error("Unexpected content type in insert operation")}}}),a()},Ui=()=>G("Length exceeded!"),Ci=(e,t,n,r)=>{if(n>t._length)throw Ui();if(n===0)return t._searchMarker&&Le(t._searchMarker,n,r.length),Cn(e,t,null,r);let s=n,i=On(t,n),o=t._start;for(i!==null&&(o=i.p,n-=i.index,n===0&&(o=o.prev,n+=o&&o.countable&&!o.deleted?o.length:0));o!==null;o=o.right)if(!o.deleted&&o.countable){if(n<=o.length){n{let s=(t._searchMarker||[]).reduce((i,o)=>o.index>i.index?o:i,{index:0,p:t._start}).p;if(s)for(;s.right;)s=s.right;return Cn(e,t,s,n)},Ai=(e,t,n,r)=>{if(r===0)return;let s=n,i=r,o=On(t,n),c=t._start;for(o!==null&&(c=o.p,n-=o.index);c!==null&&n>0;c=c.right)!c.deleted&&c.countable&&(n0&&c!==null;)c.deleted||(r0)throw Ui();t._searchMarker&&Le(t._searchMarker,s,-i+r)},An=(e,t,n)=>{let r=t._map.get(n);r!==void 0&&r.delete(e)},jr=(e,t,n,r)=>{let s=t._map.get(n)||null,i=e.doc,o=i.clientID,c;if(r==null)c=new It([r]);else switch(r.constructor){case Number:case Object:case Boolean:case Array:case String:case Date:case BigInt:c=new It([r]);break;case Uint8Array:c=new ie(r);break;case Ct:c=new oe(r);break;default:if(r instanceof U)c=new K(r);else throw new Error("Unexpected content type")}new b(m(o,_(i.store,o)),s,s&&s.lastId,null,null,t,n,c).integrate(e,0)},qr=(e,t)=>{e.doc??V();let n=e._map.get(t);return n!==void 0&&!n.deleted?n.content.getContent()[n.length-1]:void 0},Ii=e=>{let t={};return e.doc??V(),e._map.forEach((n,r)=>{n.deleted||(t[r]=n.content.getContent()[n.length-1])}),t},Di=(e,t)=>{e.doc??V();let n=e._map.get(t);return n!==void 0&&!n.deleted},qa=(e,t,n)=>{let r=e._map.get(t)||null;for(;r!==null&&(!n.sv.has(r.id.client)||r.id.clock>=(n.sv.get(r.id.client)||0));)r=r.left;return r!==null&&ut(r,n)?r.content.getContent()[r.length-1]:void 0},qc=(e,t)=>{let n={};return e._map.forEach((r,s)=>{let i=r;for(;i!==null&&(!t.sv.has(i.id.client)||i.id.clock>=(t.sv.get(i.id.client)||0));)i=i.left;i!==null&&ut(i,t)&&(n[s]=i.content.getContent()[i.length-1])}),n},wn=e=>(e.doc??V(),Ls(e._map.entries(),t=>!t[1].deleted)),Ar=class extends te{},ee=class e extends U{constructor(){super(),this._prelimContent=[],this._searchMarker=[]}static from(t){let n=new e;return n.push(t),n}_integrate(t,n){super._integrate(t,n),this.insert(0,this._prelimContent),this._prelimContent=null}_copy(){return new e}clone(){let t=new e;return t.insert(0,this.toArray().map(n=>n instanceof U?n.clone():n)),t}get length(){return this.doc??V(),this._length}_callObserver(t,n){super._callObserver(t,n),Vn(this,t,new Ar(this,t))}insert(t,n){this.doc!==null?x(this.doc,r=>{Ci(r,this,t,n)}):this._prelimContent.splice(t,0,...n)}push(t){this.doc!==null?x(this.doc,n=>{jc(n,this,t)}):this._prelimContent.push(...t)}unshift(t){this.insert(0,t)}delete(t,n=1){this.doc!==null?x(this.doc,r=>{Ai(r,this,t,n)}):this._prelimContent.splice(t,n)}get(t){return Ei(this,t)}toArray(){return Si(this)}slice(t=0,n=this.length){return ki(this,t,n)}toJSON(){return this.map(t=>t instanceof U?t.toJSON():t)}map(t){return _i(this,t)}forEach(t){Be(this,t)}[Symbol.iterator](){return Fc(this)}_write(t){t.writeTypeRef(hl)}},Gc=e=>new ee,Ir=class extends te{constructor(t,n,r){super(t,n),this.keysChanged=r}},ne=class e extends U{constructor(t){super(),this._prelimContent=null,t===void 0?this._prelimContent=new Map:this._prelimContent=new Map(t)}_integrate(t,n){super._integrate(t,n),this._prelimContent.forEach((r,s)=>{this.set(s,r)}),this._prelimContent=null}_copy(){return new e}clone(){let t=new e;return this.forEach((n,r)=>{t.set(r,n instanceof U?n.clone():n)}),t}_callObserver(t,n){Vn(this,t,new Ir(this,t,n))}toJSON(){this.doc??V();let t={};return this._map.forEach((n,r)=>{if(!n.deleted){let s=n.content.getContent()[n.length-1];t[r]=s instanceof U?s.toJSON():s}}),t}get size(){return[...wn(this)].length}keys(){return gn(wn(this),t=>t[0])}values(){return gn(wn(this),t=>t[1].content.getContent()[t[1].length-1])}entries(){return gn(wn(this),t=>[t[0],t[1].content.getContent()[t[1].length-1]])}forEach(t){this.doc??V(),this._map.forEach((n,r)=>{n.deleted||t(n.content.getContent()[n.length-1],r,this)})}[Symbol.iterator](){return this.entries()}delete(t){this.doc!==null?x(this.doc,n=>{An(n,this,t)}):this._prelimContent.delete(t)}set(t,n){return this.doc!==null?x(this.doc,r=>{jr(r,this,t,n)}):this._prelimContent.set(t,n),n}get(t){return qr(this,t)}has(t){return Di(this,t)}clear(){this.doc!==null?x(this.doc,t=>{this.forEach(function(n,r,s){An(t,s,r)})}):this._prelimContent.clear()}_write(t){t.writeTypeRef(ul)}},zc=e=>new ne,ft=(e,t)=>e===t||typeof e=="object"&&typeof t=="object"&&e&&t&&Kn(e,t),Me=class{constructor(t,n,r,s){this.left=t,this.right=n,this.index=r,this.currentAttributes=s}forward(){switch(this.right===null&&T(),this.right.content.constructor){case C:this.right.deleted||le(this.currentAttributes,this.right.content);break;default:this.right.deleted||(this.index+=this.right.length);break}this.left=this.right,this.right=this.right.right}},Qs=(e,t,n)=>{for(;t.right!==null&&n>0;){switch(t.right.content.constructor){case C:t.right.deleted||le(t.currentAttributes,t.right.content);break;default:t.right.deleted||(n{let s=new Map,i=r?On(t,n):null;if(i){let o=new Me(i.p.left,i.p,i.index,s);return Qs(e,o,n-i.index)}else{let o=new Me(null,t._start,0,s);return Qs(e,o,n)}},Ti=(e,t,n,r)=>{for(;n.right!==null&&(n.right.deleted===!0||n.right.content.constructor===C&&ft(r.get(n.right.content.key),n.right.content.value));)n.right.deleted||r.delete(n.right.content.key),n.forward();let s=e.doc,i=s.clientID;r.forEach((o,c)=>{let l=n.left,h=n.right,a=new b(m(i,_(s.store,i)),l,l&&l.lastId,h,h&&h.id,t,null,new C(c,o));a.integrate(e,0),n.right=a,n.forward()})},le=(e,t)=>{let{key:n,value:r}=t;r===null?e.delete(n):e.set(n,r)},vi=(e,t)=>{for(;e.right!==null;){if(!(e.right.deleted||e.right.content.constructor===C&&ft(t[e.right.content.key]??null,e.right.content.value)))break;e.forward()}},Oi=(e,t,n,r)=>{let s=e.doc,i=s.clientID,o=new Map;for(let c in r){let l=r[c],h=n.currentAttributes.get(c)??null;if(!ft(h,l)){o.set(c,h);let{left:a,right:u}=n;n.right=new b(m(i,_(s.store,i)),a,a&&a.lastId,u,u&&u.id,t,null,new C(c,l)),n.right.integrate(e,0),n.forward()}}return o},xr=(e,t,n,r,s)=>{n.currentAttributes.forEach((d,f)=>{s[f]===void 0&&(s[f]=null)});let i=e.doc,o=i.clientID;vi(n,s);let c=Oi(e,t,n,s),l=r.constructor===String?new nt(r):r instanceof U?new K(r):new wt(r),{left:h,right:a,index:u}=n;t._searchMarker&&Le(t._searchMarker,n.index,l.getLength()),a=new b(m(o,_(i.store,o)),h,h&&h.lastId,a,a&&a.id,t,null,l),a.integrate(e,0),n.right=a,n.index=u,n.forward(),Ti(e,t,n,c)},ti=(e,t,n,r,s)=>{let i=e.doc,o=i.clientID;vi(n,s);let c=Oi(e,t,n,s);t:for(;n.right!==null&&(r>0||c.size>0&&(n.right.deleted||n.right.content.constructor===C));){if(!n.right.deleted)switch(n.right.content.constructor){case C:{let{key:l,value:h}=n.right.content,a=s[l];if(a!==void 0){if(ft(a,h))c.delete(l);else{if(r===0)break t;c.set(l,h)}n.right.delete(e)}else n.currentAttributes.set(l,h);break}default:r0){let l="";for(;r>0;r--)l+=` +`;n.right=new b(m(o,_(i.store,o)),n.left,n.left&&n.left.lastId,n.right,n.right&&n.right.id,t,null,new nt(l)),n.right.integrate(e,0),n.forward()}Ti(e,t,n,c)},Vi=(e,t,n,r,s)=>{let i=t,o=A();for(;i&&(!i.countable||i.deleted);){if(!i.deleted&&i.content.constructor===C){let h=i.content;o.set(h.key,h)}i=i.right}let c=0,l=!1;for(;t!==i;){if(n===t&&(l=!0),!t.deleted){let h=t.content;switch(h.constructor){case C:{let{key:a,value:u}=h,d=r.get(a)??null;(o.get(a)!==h||d===u)&&(t.delete(e),c++,!l&&(s.get(a)??null)===u&&d!==u&&(d===null?s.delete(a):s.set(a,d))),!l&&!t.deleted&&le(s,h);break}}}t=t.right}return c},Hc=(e,t)=>{for(;t&&t.right&&(t.right.deleted||!t.right.countable);)t=t.right;let n=new Set;for(;t&&(t.deleted||!t.countable);){if(!t.deleted&&t.content.constructor===C){let r=t.content.key;n.has(r)?t.delete(e):n.add(r)}t=t.left}},Jc=e=>{let t=0;return x(e.doc,n=>{let r=e._start,s=e._start,i=A(),o=qe(i);for(;s;){if(s.deleted===!1)switch(s.content.constructor){case C:le(o,s.content);break;default:t+=Vi(n,r,s,i,o),i=qe(o),r=s;break}s=s.right}}),t},Xc=e=>{let t=new Set,n=e.doc;for(let[r,s]of e.afterState.entries()){let i=e.beforeState.get(r)||0;s!==i&&di(e,n.store.clients.get(r),i,s,o=>{!o.deleted&&o.content.constructor===C&&o.constructor!==R&&t.add(o.parent)})}x(n,r=>{Wt(e,e.deleteSet,s=>{if(s instanceof R||!s.parent._hasFormatting||t.has(s.parent))return;let i=s.parent;s.content.constructor===C?t.add(i):Hc(r,s)});for(let s of t)Jc(s)})},ei=(e,t,n)=>{let r=n,s=qe(t.currentAttributes),i=t.right;for(;n>0&&t.right!==null;){if(t.right.deleted===!1)switch(t.right.content.constructor){case K:case wt:case nt:n{s===null?this.childListChanged=!0:this.keysChanged.add(s)})}get changes(){if(this._changes===null){let t={keys:this.keys,delta:this.delta,added:new Set,deleted:new Set};this._changes=t}return this._changes}get delta(){if(this._delta===null){let t=this.target.doc,n=[];x(t,r=>{let s=new Map,i=new Map,o=this.target._start,c=null,l={},h="",a=0,u=0,d=()=>{if(c!==null){let f=null;switch(c){case"delete":u>0&&(f={delete:u}),u=0;break;case"insert":(typeof h=="object"||h.length>0)&&(f={insert:h},s.size>0&&(f.attributes={},s.forEach((p,y)=>{p!==null&&(f.attributes[y]=p)}))),h="";break;case"retain":a>0&&(f={retain:a},ds(l)||(f.attributes=hs({},l))),a=0;break}f&&n.push(f),c=null}};for(;o!==null;){switch(o.content.constructor){case K:case wt:this.adds(o)?this.deletes(o)||(d(),c="insert",h=o.content.getContent()[0],d()):this.deletes(o)?(c!=="delete"&&(d(),c="delete"),u+=1):o.deleted||(c!=="retain"&&(d(),c="retain"),a+=1);break;case nt:this.adds(o)?this.deletes(o)||(c!=="insert"&&(d(),c="insert"),h+=o.content.str):this.deletes(o)?(c!=="delete"&&(d(),c="delete"),u+=o.length):o.deleted||(c!=="retain"&&(d(),c="retain"),a+=o.length);break;case C:{let{key:f,value:p}=o.content;if(this.adds(o)){if(!this.deletes(o)){let y=s.get(f)??null;ft(y,p)?p!==null&&o.delete(r):(c==="retain"&&d(),ft(p,i.get(f)??null)?delete l[f]:l[f]=p)}}else if(this.deletes(o)){i.set(f,p);let y=s.get(f)??null;ft(y,p)||(c==="retain"&&d(),l[f]=y)}else if(!o.deleted){i.set(f,p);let y=l[f];y!==void 0&&(ft(y,p)?y!==null&&o.delete(r):(c==="retain"&&d(),p===null?delete l[f]:l[f]=p))}o.deleted||(c==="insert"&&d(),le(s,o.content));break}}o=o.right}for(d();n.length>0;){let f=n[n.length-1];if(f.retain!==void 0&&f.attributes===void 0)n.pop();else break}}),this._delta=n}return this._delta}},Re=class e extends U{constructor(t){super(),this._pending=t!==void 0?[()=>this.insert(0,t)]:[],this._searchMarker=[],this._hasFormatting=!1}get length(){return this.doc??V(),this._length}_integrate(t,n){super._integrate(t,n);try{this._pending.forEach(r=>r())}catch(r){console.error(r)}this._pending=null}_copy(){return new e}clone(){let t=new e;return t.applyDelta(this.toDelta()),t}_callObserver(t,n){super._callObserver(t,n);let r=new Dr(this,t,n);Vn(this,t,r),!t.local&&this._hasFormatting&&(t._needFormattingCleanup=!0)}toString(){this.doc??V();let t="",n=this._start;for(;n!==null;)!n.deleted&&n.countable&&n.content.constructor===nt&&(t+=n.content.str),n=n.right;return t}toJSON(){return this.toString()}applyDelta(t,{sanitize:n=!0}={}){this.doc!==null?x(this.doc,r=>{let s=new Me(null,this._start,0,new Map);for(let i=0;i0)&&xr(r,this,s,c,o.attributes||{})}else o.retain!==void 0?ti(r,this,s,o.retain,o.attributes||{}):o.delete!==void 0&&ei(r,s,o.delete)}}):this._pending.push(()=>this.applyDelta(t))}toDelta(t,n,r){this.doc??V();let s=[],i=new Map,o=this.doc,c="",l=this._start;function h(){if(c.length>0){let u={},d=!1;i.forEach((p,y)=>{d=!0,u[y]=p});let f={insert:c};d&&(f.attributes=u),s.push(f),c=""}}let a=()=>{for(;l!==null;){if(ut(l,t)||n!==void 0&&ut(l,n))switch(l.content.constructor){case nt:{let u=i.get("ychange");t!==void 0&&!ut(l,t)?(u===void 0||u.user!==l.id.client||u.type!=="removed")&&(h(),i.set("ychange",r?r("removed",l.id):{type:"removed"})):n!==void 0&&!ut(l,n)?(u===void 0||u.user!==l.id.client||u.type!=="added")&&(h(),i.set("ychange",r?r("added",l.id):{type:"added"})):u!==void 0&&(h(),i.delete("ychange")),c+=l.content.str;break}case K:case wt:{h();let u={insert:l.content.getContent()[0]};if(i.size>0){let d={};u.attributes=d,i.forEach((f,p)=>{d[p]=f})}s.push(u);break}case C:ut(l,t)&&(h(),le(i,l.content));break}l=l.right}h()};return t||n?x(o,u=>{t&&Sr(u,t),n&&Sr(u,n),a()},"cleanup"):a(),s}insert(t,n,r){if(n.length<=0)return;let s=this.doc;s!==null?x(s,i=>{let o=yn(i,this,t,!r);r||(r={},o.currentAttributes.forEach((c,l)=>{r[l]=c})),xr(i,this,o,n,r)}):this._pending.push(()=>this.insert(t,n,r))}insertEmbed(t,n,r){let s=this.doc;s!==null?x(s,i=>{let o=yn(i,this,t,!r);xr(i,this,o,n,r||{})}):this._pending.push(()=>this.insertEmbed(t,n,r||{}))}delete(t,n){if(n===0)return;let r=this.doc;r!==null?x(r,s=>{ei(s,yn(s,this,t,!0),n)}):this._pending.push(()=>this.delete(t,n))}format(t,n,r){if(n===0)return;let s=this.doc;s!==null?x(s,i=>{let o=yn(i,this,t,!1);o.right!==null&&ti(i,this,o,n,r)}):this._pending.push(()=>this.format(t,n,r))}removeAttribute(t){this.doc!==null?x(this.doc,n=>{An(n,this,t)}):this._pending.push(()=>this.removeAttribute(t))}setAttribute(t,n){this.doc!==null?x(this.doc,r=>{jr(r,this,t,n)}):this._pending.push(()=>this.setAttribute(t,n))}getAttribute(t){return qr(this,t)}getAttributes(){return Ii(this)}_write(t){t.writeTypeRef(dl)}},Pc=e=>new Re,ve=class{constructor(t,n=()=>!0){this._filter=n,this._root=t,this._currentNode=t._start,this._firstCall=!0,t.doc??V()}[Symbol.iterator](){return this}next(){let t=this._currentNode,n=t&&t.content&&t.content.type;if(t!==null&&(!this._firstCall||t.deleted||!this._filter(n)))do if(n=t.content.type,!t.deleted&&(n.constructor===se||n.constructor===re)&&n._start!==null)t=n._start;else for(;t!==null;){let r=t.next;if(r!==null){t=r;break}else t.parent===this._root?t=null:t=t.parent._item}while(t!==null&&(t.deleted||!this._filter(t.content.type)));return this._firstCall=!1,t===null?{value:void 0,done:!0}:(this._currentNode=t,{value:t.content.type,done:!1})}},re=class e extends U{constructor(){super(),this._prelimContent=[]}get firstChild(){let t=this._first;return t?t.content.getContent()[0]:null}_integrate(t,n){super._integrate(t,n),this.insert(0,this._prelimContent),this._prelimContent=null}_copy(){return new e}clone(){let t=new e;return t.insert(0,this.toArray().map(n=>n instanceof U?n.clone():n)),t}get length(){return this.doc??V(),this._prelimContent===null?this._length:this._prelimContent.length}createTreeWalker(t){return new ve(this,t)}querySelector(t){t=t.toUpperCase();let r=new ve(this,s=>s.nodeName&&s.nodeName.toUpperCase()===t).next();return r.done?null:r.value}querySelectorAll(t){return t=t.toUpperCase(),Y(new ve(this,n=>n.nodeName&&n.nodeName.toUpperCase()===t))}_callObserver(t,n){Vn(this,t,new Tr(this,n,t))}toString(){return _i(this,t=>t.toString()).join("")}toJSON(){return this.toString()}toDOM(t=document,n={},r){let s=t.createDocumentFragment();return r!==void 0&&r._createAssociation(s,this),Be(this,i=>{s.insertBefore(i.toDOM(t,n,r),null)}),s}insert(t,n){this.doc!==null?x(this.doc,r=>{Ci(r,this,t,n)}):this._prelimContent.splice(t,0,...n)}insertAfter(t,n){if(this.doc!==null)x(this.doc,r=>{let s=t&&t instanceof U?t._item:t;Cn(r,this,s,n)});else{let r=this._prelimContent,s=t===null?0:r.findIndex(i=>i===t)+1;if(s===0&&t!==null)throw G("Reference item not found");r.splice(s,0,...n)}}delete(t,n=1){this.doc!==null?x(this.doc,r=>{Ai(r,this,t,n)}):this._prelimContent.splice(t,n)}toArray(){return Si(this)}push(t){this.insert(this.length,t)}unshift(t){this.insert(0,t)}get(t){return Ei(this,t)}slice(t=0,n=this.length){return ki(this,t,n)}forEach(t){Be(this,t)}_write(t){t.writeTypeRef(pl)}},Kc=e=>new re,se=class e extends re{constructor(t="UNDEFINED"){super(),this.nodeName=t,this._prelimAttrs=new Map}get nextSibling(){let t=this._item?this._item.next:null;return t?t.content.type:null}get prevSibling(){let t=this._item?this._item.prev:null;return t?t.content.type:null}_integrate(t,n){super._integrate(t,n),this._prelimAttrs.forEach((r,s)=>{this.setAttribute(s,r)}),this._prelimAttrs=null}_copy(){return new e(this.nodeName)}clone(){let t=new e(this.nodeName),n=this.getAttributes();return us(n,(r,s)=>{t.setAttribute(s,r)}),t.insert(0,this.toArray().map(r=>r instanceof U?r.clone():r)),t}toString(){let t=this.getAttributes(),n=[],r=[];for(let c in t)r.push(c);r.sort();let s=r.length;for(let c=0;c0?" "+n.join(" "):"";return`<${i}${o}>${super.toString()}`}removeAttribute(t){this.doc!==null?x(this.doc,n=>{An(n,this,t)}):this._prelimAttrs.delete(t)}setAttribute(t,n){this.doc!==null?x(this.doc,r=>{jr(r,this,t,n)}):this._prelimAttrs.set(t,n)}getAttribute(t){return qr(this,t)}hasAttribute(t){return Di(this,t)}getAttributes(t){return t?qc(this,t):Ii(this)}toDOM(t=document,n={},r){let s=t.createElement(this.nodeName),i=this.getAttributes();for(let o in i){let c=i[o];typeof c=="string"&&s.setAttribute(o,c)}return Be(this,o=>{s.appendChild(o.toDOM(t,n,r))}),r!==void 0&&r._createAssociation(s,this),s}_write(t){t.writeTypeRef(fl),t.writeKey(this.nodeName)}},Yc=e=>new se(e.readKey()),Tr=class extends te{constructor(t,n,r){super(t,r),this.childListChanged=!1,this.attributesChanged=new Set,n.forEach(s=>{s===null?this.childListChanged=!0:this.attributesChanged.add(s)})}},In=class e extends ne{constructor(t){super(),this.hookName=t}_copy(){return new e(this.hookName)}clone(){let t=new e(this.hookName);return this.forEach((n,r)=>{t.set(r,n)}),t}toDOM(t=document,n={},r){let s=n[this.hookName],i;return s!==void 0?i=s.createDom(this):i=document.createElement(this.hookName),i.setAttribute("data-yjs-hook",this.hookName),r!==void 0&&r._createAssociation(i,this),i}_write(t){t.writeTypeRef(gl),t.writeKey(this.hookName)}},Wc=e=>new In(e.readKey()),vr=class e extends Re{get nextSibling(){let t=this._item?this._item.next:null;return t?t.content.type:null}get prevSibling(){let t=this._item?this._item.prev:null;return t?t.content.type:null}_copy(){return new e}clone(){let t=new e;return t.applyDelta(this.toDelta()),t}toDOM(t=document,n,r){let s=t.createTextNode(this.toString());return r!==void 0&&r._createAssociation(s,this),s}toString(){return this.toDelta().map(t=>{let n=[];for(let s in t.attributes){let i=[];for(let o in t.attributes[s])i.push({key:o,value:t.attributes[s][o]});i.sort((o,c)=>o.keys.nodeName=0;s--)r+=``;return r}).join("")}toJSON(){return this.toString()}_write(t){t.writeTypeRef(ml)}},Zc=e=>new vr,$e=class{constructor(t,n){this.id=t,this.length=n}get deleted(){throw z()}mergeWith(t){return!1}write(t,n,r){throw z()}integrate(t,n){throw z()}},Qc=0,R=class extends $e{get deleted(){return!0}delete(){}mergeWith(t){return this.constructor!==t.constructor?!1:(this.length+=t.length,!0)}integrate(t,n){n>0&&(this.id.clock+=n,this.length-=n),ui(t.doc.store,this)}write(t,n){t.writeInfo(Qc),t.writeLen(this.length-n)}getMissing(t,n){return null}},ie=class e{constructor(t){this.content=t}getLength(){return 1}getContent(){return[this.content]}isCountable(){return!0}copy(){return new e(this.content)}splice(t){throw z()}mergeWith(t){return!1}integrate(t,n){}delete(t){}gc(t){}write(t,n){t.writeBuf(this.content)}getRef(){return 3}},tl=e=>new ie(e.readBuf()),Fe=class e{constructor(t){this.len=t}getLength(){return this.len}getContent(){return[]}isCountable(){return!1}copy(){return new e(this.len)}splice(t){let n=new e(this.len-t);return this.len=t,n}mergeWith(t){return this.len+=t.len,!0}integrate(t,n){Oe(t.deleteSet,n.id.client,n.id.clock,this.len),n.markDeleted()}delete(t){}gc(t){}write(t,n){t.writeLen(this.len-n)}getRef(){return 1}},el=e=>new Fe(e.readLen()),Ni=(e,t)=>new Ct({guid:e,...t,shouldLoad:t.shouldLoad||t.autoLoad||!1}),oe=class e{constructor(t){t._item&&console.error("This document was already integrated as a sub-document. You should create a second instance instead with the same guid."),this.doc=t;let n={};this.opts=n,t.gc||(n.gc=!1),t.autoLoad&&(n.autoLoad=!0),t.meta!==null&&(n.meta=t.meta)}getLength(){return 1}getContent(){return[this.doc]}isCountable(){return!0}copy(){return new e(Ni(this.doc.guid,this.opts))}splice(t){throw z()}mergeWith(t){return!1}integrate(t,n){this.doc._item=n,t.subdocsAdded.add(this.doc),this.doc.shouldLoad&&t.subdocsLoaded.add(this.doc)}delete(t){t.subdocsAdded.has(this.doc)?t.subdocsAdded.delete(this.doc):t.subdocsRemoved.add(this.doc)}gc(t){}write(t,n){t.writeString(this.doc.guid),t.writeAny(this.opts)}getRef(){return 9}},nl=e=>new oe(Ni(e.readString(),e.readAny())),wt=class e{constructor(t){this.embed=t}getLength(){return 1}getContent(){return[this.embed]}isCountable(){return!0}copy(){return new e(this.embed)}splice(t){throw z()}mergeWith(t){return!1}integrate(t,n){}delete(t){}gc(t){}write(t,n){t.writeJSON(this.embed)}getRef(){return 5}},rl=e=>new wt(e.readJSON()),C=class e{constructor(t,n){this.key=t,this.value=n}getLength(){return 1}getContent(){return[]}isCountable(){return!1}copy(){return new e(this.key,this.value)}splice(t){throw z()}mergeWith(t){return!1}integrate(t,n){let r=n.parent;r._searchMarker=null,r._hasFormatting=!0}delete(t){}gc(t){}write(t,n){t.writeKey(this.key),t.writeJSON(this.value)}getRef(){return 6}},sl=e=>new C(e.readKey(),e.readJSON()),Dn=class e{constructor(t){this.arr=t}getLength(){return this.arr.length}getContent(){return this.arr}isCountable(){return!0}copy(){return new e(this.arr)}splice(t){let n=new e(this.arr.slice(t));return this.arr=this.arr.slice(0,t),n}mergeWith(t){return this.arr=this.arr.concat(t.arr),!0}integrate(t,n){}delete(t){}gc(t){}write(t,n){let r=this.arr.length;t.writeLen(r-n);for(let s=n;s{let t=e.readLen(),n=[];for(let r=0;r{let t=e.readLen(),n=[];for(let r=0;r=55296&&r<=56319&&(this.str=this.str.slice(0,t-1)+"\uFFFD",n.str="\uFFFD"+n.str.slice(1)),n}mergeWith(t){return this.str+=t.str,!0}integrate(t,n){}delete(t){}gc(t){}write(t,n){t.writeString(n===0?this.str:this.str.slice(n))}getRef(){return 4}},ll=e=>new nt(e.readString()),al=[Gc,zc,Pc,Yc,Kc,Wc,Zc],hl=0,ul=1,dl=2,fl=3,pl=4,gl=5,ml=6,K=class e{constructor(t){this.type=t}getLength(){return 1}getContent(){return[this.type]}isCountable(){return!0}copy(){return new e(this.type._copy())}splice(t){throw z()}mergeWith(t){return!1}integrate(t,n){this.type._integrate(t.doc,n)}delete(t){let n=this.type._start;for(;n!==null;)n.deleted?n.id.clock<(t.beforeState.get(n.id.client)||0)&&t._mergeStructs.push(n):n.delete(t),n=n.right;this.type._map.forEach(r=>{r.deleted?r.id.clock<(t.beforeState.get(r.id.client)||0)&&t._mergeStructs.push(r):r.delete(t)}),t.changed.delete(this.type)}gc(t){let n=this.type._start;for(;n!==null;)n.gc(t,!0),n=n.right;this.type._start=null,this.type._map.forEach(r=>{for(;r!==null;)r.gc(t,!0),r=r.left}),this.type._map=new Map}write(t,n){this.type._write(t)}getRef(){return 7}},wl=e=>new K(al[e.readTypeRef()](e)),Or=(e,t)=>{let n=t,r=0,s;do r>0&&(n=m(n.client,n.clock+r)),s=Kt(e,n),r=n.clock-s.id.clock,n=s.redone;while(n!==null&&s instanceof b);return{item:s,diff:r}},Gr=(e,t)=>{for(;e!==null&&e.keep!==t;)e.keep=t,e=e.parent._item},Tn=(e,t,n)=>{let{client:r,clock:s}=t.id,i=new b(m(r,s+n),t,m(r,s+n-1),t.right,t.rightOrigin,t.parent,t.parentSub,t.content.splice(n));return t.deleted&&i.markDeleted(),t.keep&&(i.keep=!0),t.redone!==null&&(i.redone=m(t.redone.client,t.redone.clock+n)),t.right=i,i.right!==null&&(i.right.left=i),e._mergeStructs.push(i),i.parentSub!==null&&i.right===null&&i.parent._map.set(i.parentSub,i),t.length=n,i},ni=(e,t)=>he(e,n=>ce(n.deletions,t)),Li=(e,t,n,r,s,i)=>{let o=e.doc,c=o.store,l=o.clientID,h=t.redone;if(h!==null)return M(e,h);let a=t.parent._item,u=null,d;if(a!==null&&a.deleted===!0){if(a.redone===null&&(!n.has(a)||Li(e,a,n,r,s,i)===null))return null;for(;a.redone!==null;)a=M(e,a.redone)}let f=a===null?t.parent:a.content.type;if(t.parentSub===null){for(u=t.left,d=t;u!==null;){let S=u;for(;S!==null&&S.parent._item!==a;)S=S.redone===null?null:M(e,S.redone);if(S!==null&&S.parent._item===a){u=S;break}u=u.left}for(;d!==null;){let S=d;for(;S!==null&&S.parent._item!==a;)S=S.redone===null?null:M(e,S.redone);if(S!==null&&S.parent._item===a){d=S;break}d=d.right}}else{if(d=null,t.right&&!s){for(u=t;u!==null&&u.right!==null&&(u.right.redone||ce(r,u.right.id)||ni(i.undoStack,u.right.id)||ni(i.redoStack,u.right.id));)for(u=u.right;u.redone;)u=M(e,u.redone);if(u&&u.right!==null)return null}else u=f._map.get(t.parentSub)||null;u!==null&&u.parent._item!==a&&(u=f._map.get(t.parentSub)||null)}let p=_(c,l),y=m(l,p),k=new b(y,u,u&&u.lastId,d,d&&d.id,f,t.parentSub,t.content.copy());return t.redone=y,Gr(k,!0),k.integrate(e,0),k},b=class e extends $e{constructor(t,n,r,s,i,o,c,l){super(t,l.getLength()),this.origin=r,this.left=n,this.right=s,this.rightOrigin=i,this.parent=o,this.parentSub=c,this.redone=null,this.content=l,this.info=this.content.isCountable()?2:0}set marker(t){(this.info&8)>0!==t&&(this.info^=8)}get marker(){return(this.info&8)>0}get keep(){return(this.info&1)>0}set keep(t){this.keep!==t&&(this.info^=1)}get countable(){return(this.info&2)>0}get deleted(){return(this.info&4)>0}set deleted(t){this.deleted!==t&&(this.info^=4)}markDeleted(){this.info|=4}getMissing(t,n){if(this.origin&&this.origin.client!==this.id.client&&this.origin.clock>=_(n,this.origin.client))return this.origin.client;if(this.rightOrigin&&this.rightOrigin.client!==this.id.client&&this.rightOrigin.clock>=_(n,this.rightOrigin.client))return this.rightOrigin.client;if(this.parent&&this.parent.constructor===pt&&this.id.client!==this.parent.client&&this.parent.clock>=_(n,this.parent.client))return this.parent.client;if(this.origin&&(this.left=Js(t,n,this.origin),this.origin=this.left.lastId),this.rightOrigin&&(this.right=M(t,this.rightOrigin),this.rightOrigin=this.right.id),this.left&&this.left.constructor===R||this.right&&this.right.constructor===R)this.parent=null;else if(!this.parent)this.left&&this.left.constructor===e?(this.parent=this.left.parent,this.parentSub=this.left.parentSub):this.right&&this.right.constructor===e&&(this.parent=this.right.parent,this.parentSub=this.right.parentSub);else if(this.parent.constructor===pt){let r=Kt(n,this.parent);r.constructor===R?this.parent=null:this.parent=r.content.type}return null}integrate(t,n){if(n>0&&(this.id.clock+=n,this.left=Js(t,t.doc.store,m(this.id.client,this.id.clock-1)),this.origin=this.left.lastId,this.content=this.content.splice(n),this.length-=n),this.parent){if(!this.left&&(!this.right||this.right.left!==null)||this.left&&this.left.right!==this.right){let r=this.left,s;if(r!==null)s=r.right;else if(this.parentSub!==null)for(s=this.parent._map.get(this.parentSub)||null;s!==null&&s.left!==null;)s=s.left;else s=this.parent._start;let i=new Set,o=new Set;for(;s!==null&&s!==this.right;){if(o.add(s),i.add(s),Pt(this.origin,s.origin)){if(s.id.client{r.p===t&&(r.p=this,!this.deleted&&this.countable&&(r.index-=this.length))}),t.keep&&(this.keep=!0),this.right=t.right,this.right!==null&&(this.right.left=this),this.length+=t.length,!0}return!1}delete(t){if(!this.deleted){let n=this.parent;this.countable&&this.parentSub===null&&(n._length-=this.length),this.markDeleted(),Oe(t.deleteSet,this.id.client,this.id.clock,this.length),Ps(t,n,this.parentSub),this.content.delete(t)}}gc(t,n){if(!this.deleted)throw T();this.content.gc(t),n?Ic(t,this,new R(this.id,this.length)):this.content=new Fe(this.length)}write(t,n){let r=n>0?m(this.id.client,this.id.clock+n-1):this.origin,s=this.rightOrigin,i=this.parentSub,o=this.content.getRef()&31|(r===null?0:128)|(s===null?0:64)|(i===null?0:32);if(t.writeInfo(o),r!==null&&t.writeLeftID(r),s!==null&&t.writeRightID(s),r===null&&s===null){let c=this.parent;if(c._item!==void 0){let l=c._item;if(l===null){let h=ai(c);t.writeParentInfo(!0),t.writeString(h)}else t.writeParentInfo(!1),t.writeLeftID(l.id)}else c.constructor===String?(t.writeParentInfo(!0),t.writeString(c)):c.constructor===pt?(t.writeParentInfo(!1),t.writeLeftID(c)):T();i!==null&&t.writeString(i)}this.content.write(t,n)}},Bi=(e,t)=>yl[t&31](e),yl=[()=>{T()},el,il,tl,ll,rl,sl,wl,cl,nl,()=>{T()}],xl=10,N=class extends $e{get deleted(){return!0}delete(){}mergeWith(t){return this.constructor!==t.constructor?!1:(this.length+=t.length,!0)}integrate(t,n){T()}write(t,n){t.writeInfo(xl),g(t.restEncoder,this.length-n)}getMissing(t,n){return null}},Mi=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:{},Ri="__ $YJS$ __";Mi[Ri]===!0&&console.error("Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438");Mi[Ri]=!0;export{kr as AbsolutePosition,Rs as AbstractConnector,$e as AbstractStruct,U as AbstractType,ee as Array,It as ContentAny,ie as ContentBinary,Fe as ContentDeleted,oe as ContentDoc,wt as ContentEmbed,C as ContentFormat,Dn as ContentJSON,nt as ContentString,K as ContentType,Ct as Doc,R as GC,pt as ID,b as Item,ne as Map,Hs as PermanentUserData,Ve as RelativePosition,N as Skip,_n as Snapshot,Re as Text,Er as Transaction,Ws as UndoManager,H as UpdateDecoderV1,j as UpdateDecoderV2,ct as UpdateEncoderV1,X as UpdateEncoderV2,se as XmlElement,re as XmlFragment,In as XmlHook,vr as XmlText,Ar as YArrayEvent,te as YEvent,Ir as YMapEvent,Dr as YTextEvent,Tr as YXmlEvent,fa as applyUpdate,Mr as applyUpdateV2,Jc as cleanupYTextFormatting,Pt as compareIDs,_a as compareRelativePositions,$a as convertUpdateFormatV1ToV2,Bc as convertUpdateFormatV2ToV1,Sa as createAbsolutePositionFromRelativePosition,Lr as createDeleteSet,ri as createDeleteSetFromStructStore,Da as createDocFromSnapshot,m as createID,ya as createRelativePositionFromJSON,xa as createRelativePositionFromTypeIndex,hi as createSnapshot,ka as decodeRelativePosition,Ca as decodeSnapshot,Uc as decodeSnapshotV2,ci as decodeStateVector,Va as decodeUpdate,vc as decodeUpdateV2,Ba as diffUpdate,mi as diffUpdateV2,Aa as emptySnapshot,ba as encodeRelativePosition,Ua as encodeSnapshot,Ec as encodeSnapshotV2,pa as encodeStateAsUpdate,wc as encodeStateAsUpdateV2,ga as encodeStateVector,Na as encodeStateVectorFromUpdate,Vc as encodeStateVectorFromUpdateV2,uc as equalDeleteSets,Ea as equalSnapshots,P as findIndexSS,ai as findRootTypeKey,Kt as getItem,Js as getItemCleanEnd,M as getItemCleanStart,_ as getState,Fa as getTypeChildren,ce as isDeleted,Sn as isParentOf,Wt as iterateDeletedStructs,ma as logType,Oa as logUpdate,Tc as logUpdateV2,Zt as mergeDeleteSets,Oc as mergeUpdates,Un as mergeUpdatesV2,Ma as obfuscateUpdate,Ra as obfuscateUpdateV2,La as parseUpdateMeta,Nc as parseUpdateMetaV2,da as readUpdate,ii as readUpdateV2,wa as relativePositionToJSON,Ia as snapshot,Ta as snapshotContainsUpdate,x as transact,va as tryGc,ja as typeListToArraySnapshot,qc as typeMapGetAllSnapshot,qa as typeMapGetSnapshot}; diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index a9233fd5..c465bcae 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -49,6 +49,9 @@ console.log("✓ Copied script.js → resources/js/script.js"); fs.copyFileSync(path.join(ROOT_DIR, "workspace-storage.js"), path.join(jsDest, "workspace-storage.js")); fs.copyFileSync(path.join(ROOT_DIR, "native-markdown-source.js"), path.join(jsDest, "native-markdown-source.js")); fs.copyFileSync(path.join(ROOT_DIR, "browser-drop-source.js"), path.join(jsDest, "browser-drop-source.js")); +for (const name of ['sharing-crypto.js', 'sharing-media.js', 'sharing-live.js']) { + fs.copyFileSync(path.join(ROOT_DIR, name), path.join(jsDest, name)); +} console.log("✓ Copied workspace-storage.js → resources/js/workspace-storage.js"); if (fs.existsSync(path.join(ROOT_DIR, "visual-settings-store.js"))) { @@ -352,22 +355,14 @@ async function prepareOfflineDependencies() { html = html.replace(/'); html = html.replace(/'); - - // Inject Neutralino script tags - html = html.replace( - /]*><\/script>(?:\s*]*><\/script>)?(?:\s*]*><\/script>)?\s*]*><\/script>/i, - (match) => { - let tags = '\n \n '; - if (match.includes('visual-settings-adapters.js')) { - tags += '\n '; - } - if (match.includes('visual-settings-store.js')) { - tags += '\n '; - } - tags += '\n '; - return tags; - } - ); + html = html.replace(/'); + + // Preserve script order without requiring adjacent tags: sharing modules load + // between settings and the application entry point. + html = html.replace(/]*><\/script>/i, + '\n \n '); + html = html.replace(/]*><\/script>/g, + ''); // Explicit desktop demo opt-in; the official runtime pin remains unchanged. // Default desktop and web builds diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 4cd27953..e64fa26b 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1168,6 +1168,8 @@

Markdown Viewer

Copy +

Encrypted sharing. Keep the complete link private; it includes the decryption key.

+
@@ -2357,6 +2359,9 @@

No comments yet

+ + +
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 5927d52b..400aea36 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -247,7 +247,7 @@ document.addEventListener("DOMContentLoaded", async function () { markmapLib: 'https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js', markmapView: 'https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js', jszip: 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', - yjs: 'https://esm.sh/yjs@13.6.10/es2022/yjs.mjs' + yjs: './assets/yjs.mjs' }; // Resolve local paths for desktop (Neutralinojs) offline support @@ -270,6 +270,7 @@ document.addEventListener("DOMContentLoaded", async function () { CDN.markmapLib = '/libs/markmap-lib.iife.js'; CDN.markmapView = '/libs/markmap-view.js'; CDN.jszip = '/libs/jszip.min.js'; + CDN.yjs = '/assets/yjs.mjs'; } // Active WebGL / Three.js 3D STL renderers Map for memory cleanup @@ -545,6 +546,16 @@ document.addEventListener("DOMContentLoaded", async function () { return Boolean(activeTabId && isShareSnapshotTabId(activeTabId)); } + function isEncryptedSharingActive() { + const tab = tabs.find(item => item.id === activeTabId); + return Boolean((tab && tab.encryptedSharing) || + (liveCollaboration && liveCollaboration.encryption && liveCollaboration.tabId === activeTabId)); + } + + function prepareSharedPreviewHtml(html) { + return MarkdownShareMedia.prepareHtml(html, { base: getShareApiBaseUrl(), privateContent: isEncryptedSharingActive() }); + } + function isReleaseNotesTab(tab) { return Boolean(tab && tab.kind === RELEASE_NOTES_TAB_KIND); } @@ -1059,7 +1070,6 @@ document.addEventListener("DOMContentLoaded", async function () { const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; const MANAGED_GIF_MAX_BYTES = 5 * 1024 * 1024; const MANAGED_VIDEO_MAX_BYTES = 10 * 1024 * 1024; - const MANAGED_IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; const managedImageMigrationOfferedTabIds = new Set(); const managedImageMigrationInProgressTabIds = new Set(); const EMOJI_API_URL = 'https://api.github.com/emojis'; @@ -3533,6 +3543,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function fetchDiagramSvgRequest(url, options, engine, attempts = DIAGRAM_REQUEST_RETRIES) { + if (isEncryptedSharingActive()) throw new Error('Remote rendering is disabled for encrypted shared documents.'); let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const controller = new AbortController(); @@ -14992,6 +15003,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function disposeDocumentSplitPreviewResources() { + if (documentSplitPreview) MarkdownShareMedia.release(documentSplitPreview); if (!documentSplitPreview) return; documentSplitPreview.querySelectorAll('.geojson-map, .topojson-map').forEach(function(node) { disposeMapNode(node); @@ -15023,14 +15035,17 @@ document.addEventListener("DOMContentLoaded", async function () { const referenceData = extractReferenceDefinitions(parsed.body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); disposeDocumentSplitPreviewResources(); - documentSplitPreview.innerHTML = sanitizePreviewHtml(html); + const privateContent = Boolean(tab.encryptedSharing || (liveCollaboration && liveCollaboration.encryption && liveCollaboration.tabId === tab.id)); + documentSplitPreview.innerHTML = MarkdownShareMedia.prepareHtml(sanitizePreviewHtml(html), { base: getShareApiBaseUrl(), privateContent }); + MarkdownShareMedia.resolve(documentSplitPreview, { base: getShareApiBaseUrl() }).catch(function() {}); applyReferencePreviewLinks(documentSplitPreview, referenceData.definitions); enhanceGitHubAlerts(documentSplitPreview); enhancePreviewCodeBlocks(documentSplitPreview); - processEmojis(documentSplitPreview); + if (!privateContent) processEmojis(documentSplitPreview); namespaceSplitPreviewIds(documentSplitPreview, tab.id); documentSplitPreviewTabId = tab.id; documentSplitPreviewContent = content; + if (privateContent) return; const context = { isCurrent: function() { return ( @@ -16471,6 +16486,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context) { + sanitizedHtml = prepareSharedPreviewHtml(sanitizedHtml); + const hasEncryptedMedia = sanitizedHtml.includes('data-encrypted-media=') || Boolean(markdownPreview.querySelector('[data-encrypted-media]')); + MarkdownShareMedia.release(markdownPreview); const renderedDocumentId = previewLastRenderedTabId; const renderedScrollSnapshot = captureRenderedPreviewScroll(renderedDocumentId); if (renderedScrollSnapshot) { @@ -16483,13 +16501,14 @@ document.addEventListener("DOMContentLoaded", async function () { mathJaxTypesetRunId += 1; clearMathJaxPreviewState(markdownPreview); const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { - reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, + reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force && !hasEncryptedMedia && !isEncryptedSharingActive(), }); invalidateReviewTargetSourceSnapshots( patchResult && patchResult.fullReplace ? [markdownPreview] : ((patchResult && patchResult.updatedNodes) || []) ); applyReferencePreviewLinks(markdownPreview, referenceData.definitions); enhanceGitHubAlerts(markdownPreview); + MarkdownShareMedia.resolve(markdownPreview, { base: getShareApiBaseUrl() }).catch(() => {}); _lastRenderedContent = rawVal; previewHasCommittedRender = true; @@ -17706,6 +17725,7 @@ ${selector} .arrowheadPath { } async function renderThemeSensitivePreviewContentForPrint() { + if (isEncryptedSharingActive()) return; const context = { renderId: previewRenderGeneration, previewDocumentId: getActivePreviewDocumentId(), @@ -17980,6 +18000,14 @@ ${selector} .arrowheadPath { function postProcessPreview(rawVal, context, patchResult) { const roots = getPreviewPostProcessRoots(patchResult, context); + if (isEncryptedSharingActive()) { + roots.forEach(root => { enhancePreviewCodeBlocks(root); enhanceGitHubAlerts(root); }); + refreshDocumentOutline(); + decorateReviewTargets(); + updateFindHighlights(); + scheduleLineNumberUpdate(); + return; + } // Clean up orphaned STL views that are no longer present in the document activeStlViews.forEach((view, id) => { @@ -20793,7 +20821,7 @@ ${selector} .arrowheadPath { async function requestManagedImageUploadConsent() { if (loadGlobalState().managedImageUploadAcknowledged === true) return true; const accepted = await showAppToastConfirmation( - 'Images, GIFs, and videos are uploaded to Markdown Viewer public media storage for up to 90 days. Anyone with a media URL can view it during that time.', + 'Images, GIFs, and videos are encrypted on this device before upload and kept for up to 90 days. Keep the complete media link private; it includes the key needed to open the media.', { title: 'Upload media?', confirmLabel: 'Continue', cancelLabel: 'Cancel', dedupeKey: 'managed-media-consent' } ); if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); @@ -20802,12 +20830,15 @@ ${selector} .arrowheadPath { async function uploadManagedImageDataUrl(dataUrl, endpoint) { const apiPath = endpoint === '/api/media' ? '/api/media' : '/api/image'; + MarkdownShareMedia.validateDataUrl(dataUrl); + const mediaKey = MarkdownShareCrypto.random(); + const encrypted = await MarkdownShareCrypto.seal({ dataUrl }, mediaKey, 'media', MarkdownShareCrypto.MEDIA_LIMIT); let response; try { response = await fetch(getShareApiBaseUrl() + apiPath, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dataUrl: dataUrl }) + body: JSON.stringify({ encrypted }) }); } catch (_) { throw new Error('The media file could not be uploaded. Check your connection and try again, or use External Media (URL).'); @@ -20820,7 +20851,7 @@ ${selector} .arrowheadPath { if (!response.ok) { throw new Error((payload && payload.error) || 'The media host rejected this file. Try a smaller file or use External Media (URL).'); } - if (!payload || !MANAGED_IMAGE_ID_PATTERN.test(payload.id || '')) { + if (!payload || !/^e2_[A-Za-z0-9_-]{24}$/.test(payload.id || '')) { throw new Error('The media host returned an invalid short link. Please try again.'); } @@ -20835,6 +20866,8 @@ ${selector} .arrowheadPath { if (managedUrl.origin !== expectedBase.origin || managedUrl.pathname !== apiPath + '/' + payload.id) { throw new Error('The media host returned an invalid short link. Please try again.'); } + managedUrl.search = ''; + managedUrl.hash = 'key=' + mediaKey; return managedUrl.toString(); } @@ -28847,7 +28880,7 @@ ${selector} .arrowheadPath { } }); - exportMd.addEventListener("click", function () { + exportMd.addEventListener("click", async function () { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; @@ -28856,7 +28889,8 @@ ${selector} .arrowheadPath { return; } try { - const blob = new Blob([markdownEditor.value], { + const portableMarkdown = await MarkdownShareMedia.inlineMarkdown(markdownEditor.value, getShareApiBaseUrl()); + const blob = new Blob([portableMarkdown], { type: "text/markdown;charset=utf-8", }); saveAs(blob, getExportFilename("md", "document.md")); @@ -28920,10 +28954,11 @@ ${selector} .arrowheadPath { generateHtmlExport(selectedTheme); }); - function generateHtmlExport(exportTheme = 'light') { + async function generateHtmlExport(exportTheme = 'light') { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; try { + const privateExport = isEncryptedSharingActive(); const { frontmatter, body } = parseFrontmatter(markdownEditor.value); const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : ''; const referenceData = extractReferenceDefinitions(body); @@ -28934,7 +28969,13 @@ ${selector} .arrowheadPath { ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); const tempContainer = document.createElement("div"); - tempContainer.innerHTML = sanitizedHtml; + tempContainer.innerHTML = prepareSharedPreviewHtml(sanitizedHtml); + await MarkdownShareMedia.resolve(tempContainer, { base: getShareApiBaseUrl(), inline: true }); + if (privateExport) { + const secureHtml = 'Shared document' + tempContainer.innerHTML + ''; + saveAs(new Blob([secureHtml], { type: 'text/html;charset=utf-8' }), getExportFilename('html', 'document.html')); + return; + } applyReferencePreviewLinks(tempContainer, referenceData.definitions); enhanceGitHubAlerts(tempContainer); tempContainer.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar').forEach(function(el) { @@ -29832,6 +29873,7 @@ ${selector} .arrowheadPath { function disposeExportRenderElement(root) { if (!root) return; + MarkdownShareMedia.release(root); root.querySelectorAll('.geojson-map, .topojson-map').forEach(disposeMapNode); root.querySelectorAll('.stl-viewer[id]').forEach(node => disposeStlView(node.id)); clearMathJaxPreviewState(root); @@ -29843,6 +29885,11 @@ ${selector} .arrowheadPath { } async function renderExportRichContent(root, markdown, exportTheme, state) { + await MarkdownShareMedia.resolve(root, { base: getShareApiBaseUrl() }); + if (isEncryptedSharingActive()) { + await waitForExportWork(state, waitForAllImages(root)); + return; + } const theme = getExportTheme(exportTheme); const context = { theme, @@ -29888,7 +29935,7 @@ ${selector} .arrowheadPath { root.className = `markdown-body pdf-export theme-${theme} export-render-root`; root.setAttribute('data-theme', theme); root.setAttribute('data-export-render-mode', mode); - root.innerHTML = html; + root.innerHTML = prepareSharedPreviewHtml(html); root.style.boxSizing = 'border-box'; root.style.position = 'fixed'; root.style.left = '-12000px'; @@ -30376,18 +30423,8 @@ ${selector} .arrowheadPath { const MAX_SHARE_URL_LENGTH = 32000; const MAX_LEGACY_SHARE_URL_LENGTH = 4096; const SERVER_SHARE_THRESHOLD_BYTES = 3000; - const SERVER_SHARE_ID_PATTERN = /^[a-z2-9]{6,20}$/; + const SERVER_SHARE_ID_PATTERN = /^(?:[a-z2-9]{6,20}|s2_[A-Za-z0-9_-]{24})$/; - function encodeMarkdownForShare(text) { - if (typeof pako === 'undefined') throw new Error('pako not loaded'); - const compressed = pako.deflate(new TextEncoder().encode(text)); - const chunkSize = 0x8000; - let binary = ''; - for (let i = 0; i < compressed.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize)); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - } function decodeMarkdownFromShare(encoded) { if (typeof pako === 'undefined') throw new Error('pako not loaded'); @@ -30431,13 +30468,14 @@ ${selector} .arrowheadPath { return cleanTitle || 'Shared Snapshot'; } - async function openShareSnapshotTab(content, title, mode) { + async function openShareSnapshotTab(content, title, mode, encrypted = false) { const shareMode = mode === 'edit' ? 'edit' : 'view'; const viewMode = shareMode === 'edit' ? 'split' : 'preview'; const snapshotTab = createTab(typeof content === 'string' ? content : '', getSafeShareSnapshotTitle(title), viewMode); snapshotTab.kind = SHARE_SNAPSHOT_TAB_KIND; snapshotTab.temporary = true; snapshotTab.shareSnapshotMode = shareMode; + snapshotTab.encryptedSharing = encrypted; tabs.push(snapshotTab); await switchTab(snapshotTab.id); applyShareSnapshotAccessMode(shareMode); @@ -30447,18 +30485,11 @@ ${selector} .arrowheadPath { return true; } - function getShareHashForMode(id, mode) { - return 'id=' + encodeURIComponent(id) + (mode === 'edit' ? '&edit=1' : ''); - } function getShareSnapshotTitleParam(title) { return '&title=' + encodeURIComponent(getSafeShareSnapshotTitle(title)); } - function shouldUseServerShare(markdownText, legacyUrl) { - const byteLength = new TextEncoder().encode(markdownText || '').length; - return byteLength >= SERVER_SHARE_THRESHOLD_BYTES || !legacyUrl || legacyUrl.length > MAX_LEGACY_SHARE_URL_LENGTH; - } // ============================================ // Share Modal @@ -30475,6 +30506,7 @@ ${selector} .arrowheadPath { const shareUrlInput = document.getElementById('share-url-input'); const shareGenerateBtn = document.getElementById('share-generate-btn'); const shareCopyBtn = document.getElementById('share-copy-btn'); + const shareDeleteBtn = document.getElementById('share-delete-btn'); const shareInviteState = document.getElementById('share-invite-state'); const shareModeView = document.getElementById('share-mode-view'); const shareModeEdit = document.getElementById('share-mode-edit'); @@ -30485,34 +30517,24 @@ ${selector} .arrowheadPath { let generatedShareSnapshotId = ''; let generatedShareSnapshotDeleteToken = ''; - function buildShareUrl(mode) { + async function buildShareUrl(mode) { generatedShareSnapshotExpiresIn = null; generatedShareSnapshotId = ''; generatedShareSnapshotDeleteToken = ''; - const markdownText = markdownEditor.value; - let encoded; - try { - encoded = encodeMarkdownForShare(markdownText); - } catch (e) { - console.error('Share encoding failed:', e); - return null; - } - const base = getPublicAppBaseUrl() + '#share=' + encoded + getShareSnapshotTitleParam(getActiveShareTitle()); - return mode === 'edit' ? base + '&edit=1' : base; + const key = MarkdownShareCrypto.random(); + const payload = { content: markdownEditor.value || '', title: getActiveShareTitle(), mode }; + const encrypted = await MarkdownShareCrypto.seal(payload, key, 'snapshot'); + const inlineUrl = getPublicAppBaseUrl() + '#share=v2.' + MarkdownShareCrypto.pack(encrypted) + '&key=' + key; + return { key, encrypted, inlineUrl, byteLength: new TextEncoder().encode(payload.content).length }; } - async function createStoredShareUrl(mode) { - const markdownText = markdownEditor.value || ''; + async function createStoredShareUrl(prepared) { const response = await fetch(getShareApiBaseUrl() + '/api/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: markdownText, - mode, - title: getActiveShareTitle() - }) + body: JSON.stringify({ encrypted: prepared.encrypted }) }); let payload = null; @@ -30523,14 +30545,14 @@ ${selector} .arrowheadPath { if (!response.ok) { throw new Error((payload && payload.error) || ('HTTP ' + response.status)); } - if (!payload || !SERVER_SHARE_ID_PATTERN.test(payload.id || '')) { + if (!payload || !/^s2_[A-Za-z0-9_-]{24}$/.test(payload.id || '')) { throw new Error('Invalid share id returned'); } generatedShareSnapshotExpiresIn = Number.isFinite(payload.expiresIn) ? payload.expiresIn : null; generatedShareSnapshotId = payload.id; generatedShareSnapshotDeleteToken = typeof payload.deleteToken === 'string' ? payload.deleteToken : ''; - return getPublicAppBaseUrl() + '#' + getShareHashForMode(payload.id, mode); + return getPublicAppBaseUrl() + '#id=' + payload.id + '&key=' + prepared.key; } function formatShareExpiry(seconds) { @@ -30562,6 +30584,7 @@ ${selector} .arrowheadPath { } function resetShareSnapshotLink() { + if (shareDeleteBtn) shareDeleteBtn.hidden = true; generatedShareSnapshotUrl = ''; generatedShareSnapshotExpiresIn = null; generatedShareSnapshotId = ''; @@ -30585,16 +30608,11 @@ ${selector} .arrowheadPath { async function createShareSnapshotLink() { const mode = getCurrentShareMode(); - const url = buildShareUrl(mode); - if (!url) { - shareUrlInput.value = 'Error generating link.'; - shareCopyBtn.disabled = true; - throw new Error('Unable to create share link'); - } - if (shouldUseServerShare(markdownEditor.value || '', url)) { - return await createStoredShareUrl(mode); + const prepared = await buildShareUrl(mode); + if (prepared.byteLength >= SERVER_SHARE_THRESHOLD_BYTES || prepared.inlineUrl.length > MAX_LEGACY_SHARE_URL_LENGTH) { + return await createStoredShareUrl(prepared); } - return url; + return prepared.inlineUrl; } function openShareModal() { @@ -30602,16 +30620,6 @@ ${selector} .arrowheadPath { if (isReleaseNotesActive()) return; if (blockTemporarySnapshotShare()) return; if (blockLiveDocumentShareSnapshot()) return; - // PERF-002: Lazy-load pako on first share - if (typeof pako === 'undefined') { - loadScript(CDN.pako).then(function() { - openShareModal(); - }).catch(function(e) { - console.error('Failed to load pako:', e); - alert('Failed to load sharing library. Please check your internet connection.'); - }); - return; - } // Reset to view-only by default each time shareModeView.checked = true; syncShareCardStyles(); @@ -30657,6 +30665,7 @@ ${selector} .arrowheadPath { const originalHTML = shareGenerateBtn.innerHTML; shareGenerateBtn.disabled = true; shareCopyBtn.disabled = true; + if (shareDeleteBtn) shareDeleteBtn.hidden = true; shareGenerateBtn.textContent = 'Sharing...'; if (shareInviteSection) { shareInviteSection.hidden = false; @@ -30669,6 +30678,7 @@ ${selector} .arrowheadPath { generatedShareSnapshotUrl = url; shareUrlInput.value = url; shareCopyBtn.disabled = false; + if (shareDeleteBtn) shareDeleteBtn.hidden = !generatedShareSnapshotId || !generatedShareSnapshotDeleteToken; if (shareInviteSection) { shareInviteSection.dataset.state = 'active'; } @@ -30712,6 +30722,24 @@ ${selector} .arrowheadPath { } }); + if (shareDeleteBtn) shareDeleteBtn.addEventListener('click', async function() { + const id = generatedShareSnapshotId, deleteToken = generatedShareSnapshotDeleteToken; + if (!id || !deleteToken || shareDeleteBtn.disabled) return; + shareDeleteBtn.disabled = true; + try { + const accepted = await showAppToastConfirmation('Delete this stored snapshot? Its link will stop working. Downloaded copies are unaffected.', + { title: 'Delete snapshot', confirmLabel: 'Delete', cancelLabel: 'Cancel' }); + if (!accepted) return; + const response = await fetch(getShareApiBaseUrl() + '/api/share/' + encodeURIComponent(id), { + method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deleteToken }) + }); + if (!response.ok) throw new Error('Deletion failed'); + if (generatedShareSnapshotId === id) resetShareSnapshotLink(); + showAppToast('Snapshot deleted.'); + } catch (_) { showShareErrorModal('The snapshot could not be deleted. Please try again.'); } + finally { shareDeleteBtn.disabled = false; } + }); + shareModalCloseX.addEventListener('click', closeShareModal); shareModalClose.addEventListener('click', closeShareModal); shareModal.addEventListener('click', function (e) { @@ -30928,6 +30956,7 @@ ${selector} .arrowheadPath { function getCurrentLiveAccessMode() { if (!liveCollaboration) return getSelectedLiveAccessMode(); + if (liveCollaboration.encryption && liveCollaboration.socketRole === 'view') return LIVE_SHARE_ACCESS_VIEW; return normalizeLiveAccessMode(liveCollaboration.accessMode || (liveCollaboration.sessionMap && liveCollaboration.sessionMap.get('accessMode'))); } @@ -31226,10 +31255,11 @@ ${selector} .arrowheadPath { function getLiveRoomSocketUrl(roomId, secret, auth) { const encodedRoom = encodeURIComponent(roomId); const params = new URLSearchParams(); - params.set('secret', secret); - if (auth && auth.role) params.set('role', normalizeLiveSocketRole(auth.role)); - if (auth && auth.capability) params.set('cap', auth.capability); - if (auth && auth.capabilities) { + if (auth && auth.encrypted) params.set('v', '2'); + else params.set('secret', secret); + if (auth && !auth.encrypted && auth.role) params.set('role', normalizeLiveSocketRole(auth.role)); + if (auth && !auth.encrypted && auth.capability) params.set('cap', auth.capability); + if (auth && !auth.encrypted && auth.capabilities) { params.set('hostCap', auth.capabilities.host || ''); params.set('editCap', auth.capabilities.edit || ''); params.set('viewCap', auth.capabilities.view || ''); @@ -31282,6 +31312,13 @@ ${selector} .arrowheadPath { } function createLiveRoomConnection(options) { + if (options.encryption) { + return MarkdownShareLive.connect(Object.assign({}, options, { + socketUrl: getLiveRoomSocketUrl(options.roomId, '', { encrypted: true }), + relayOrigin: LIVE_RELAY_ORIGIN, + reviewRelayOrigin: LIVE_REVIEW_RELAY_ORIGIN + })); + } const pendingMessages = []; const socketUrl = getLiveRoomSocketUrl(options.roomId, options.secret, { role: options.role, @@ -31461,7 +31498,7 @@ ${selector} .arrowheadPath { return (normalized || 'Live Share').slice(0, 120); } - function buildLiveInviteUrl(roomId, secret, title, accessMode, capabilities) { + function buildLiveInviteUrl(roomId, secret, title, accessMode, capabilities, encryption) { const isLocal = window.location.origin.includes('localhost') || window.location.origin.startsWith('file://') || typeof Neutralino !== 'undefined'; @@ -31470,6 +31507,14 @@ ${selector} .arrowheadPath { ? 'https://markdownviewer.pages.dev/' : window.location.origin + window.location.pathname; + if (encryption) { + const role = normalizeLiveAccessMode(accessMode); + if (!capabilities || !encryption.signing) return ''; + const params = new URLSearchParams({ live: roomId, v: '2', key: encryption.key, + sig: encryption.signing[role], pub: MarkdownShareCrypto.pack(encryption.config, 4096), + access: role, role, cap: capabilities[role] }); + return baseUrl + '#' + params.toString(); + } const base = baseUrl + '#live=' + encodeURIComponent(roomId + '.' + secret); const params = []; const normalizedAccess = normalizeLiveAccessMode(accessMode); @@ -31491,6 +31536,16 @@ ${selector} .arrowheadPath { function parseLiveHash() { const hash = window.location.hash || ''; if (!hash.startsWith('#live=')) return null; + const encryptedParams = new URLSearchParams(hash.slice(1)); + if (encryptedParams.has('v')) { + if (encryptedParams.get('v') !== '2' || hash.length > 8192) throw new Error('Unsupported encrypted invite.'); + const roomId = encryptedParams.get('live'); + const role = encryptedParams.get('role'); + if (!/^room2_[A-Za-z0-9_-]{24}$/.test(roomId || '') || !['view', 'edit'].includes(role)) throw new Error('Invalid encrypted invite.'); + const config = MarkdownShareCrypto.publicConfig(MarkdownShareCrypto.unpack(encryptedParams.get('pub'), 4096), roomId); + return { roomId, secret: '', title: 'Live Share', role, accessMode: role, capability: encryptedParams.get('cap'), + encryption: { key: encryptedParams.get('key'), config, signingKey: encryptedParams.get('sig') } }; + } const rest = hash.slice('#live='.length); const paramsIndex = rest.indexOf('&'); const roomSecretPart = decodeURIComponent(paramsIndex === -1 ? rest : rest.slice(0, paramsIndex)); @@ -31550,7 +31605,8 @@ ${selector} .arrowheadPath { liveCollaboration.secret, safeTitle, liveCollaboration.accessMode, - liveCollaboration.capabilities + liveCollaboration.capabilities, + liveCollaboration.encryption ); } if (liveCollaboration.tabId) { @@ -31696,9 +31752,10 @@ ${selector} .arrowheadPath { const liveTab = createTab(typeof markdown === 'string' ? markdown : '', getSafeLiveTitle(liveCollaboration.roomTitle), 'split'); liveTab.kind = 'live-share'; liveTab.temporary = true; + liveTab.encryptedSharing = Boolean(liveCollaboration.encryption); tabs.push(liveTab); - switchTab(liveTab.id); liveCollaboration.tabId = liveTab.id; + switchTab(liveTab.id); liveCollaboration.pendingJoinTab = false; liveCollaboration.removeTabOnLeave = true; if (liveCollaboration.joinTimeoutId) { @@ -32301,6 +32358,7 @@ ${selector} .arrowheadPath { if (!sender || sender === liveCollaboration.localParticipantId) return; if (message.type === 'sync-request') { + if (transport.publishControlState) transport.publishControlState(); transport.publishCurrentState(); transport.publishPresence(liveCollaboration.localCursor); return; @@ -32311,6 +32369,12 @@ ${selector} .arrowheadPath { return; } + if (message.type === 'control-state' && liveCollaboration.encryption && message.role === 'host' && message.update) { + try { liveCollaboration.Y.applyUpdate(liveCollaboration.controlDoc, decodeLiveBytes(message.update), LIVE_RELAY_ORIGIN); } + catch (_) { setLiveShareStatus('Unable to apply encrypted room controls.', 'error'); } + return; + } + if ((message.type === 'y-update' || message.type === 'sync-state') && message.update) { const hostParticipantId = liveCollaboration.sessionMap ? liveCollaboration.sessionMap.get('hostId') : null; if (getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_VIEW && hostParticipantId && sender !== hostParticipantId) { @@ -32409,6 +32473,9 @@ ${selector} .arrowheadPath { if (liveCollaboration.reviewDoc && typeof liveCollaboration.reviewDoc.destroy === 'function') { liveCollaboration.reviewDoc.destroy(); } + if (liveCollaboration.controlDoc && liveCollaboration.controlDoc !== liveCollaboration.ydoc && typeof liveCollaboration.controlDoc.destroy === 'function') { + liveCollaboration.controlDoc.destroy(); + } } catch (error) { console.warn('Failed to fully close live session:', error); } @@ -32437,7 +32504,7 @@ ${selector} .arrowheadPath { } } - function endLiveSessionForEveryone() { + async function endLiveSessionForEveryone() { if (!liveCollaboration) return; if (!liveCollaboration.isHost) { leaveLiveSession({ restoreOriginal: true }); @@ -32452,7 +32519,7 @@ ${selector} .arrowheadPath { endingSession.sessionMap.set('endedBy', endingSession.localParticipantId); }, LIVE_EDIT_ORIGIN); if (endingSession.connection) { - endingSession.connection.send({ type: 'session-end' }); + await endingSession.connection.send({ type: 'session-end' }); } } catch (error) { console.warn('Failed to broadcast live session end:', error); @@ -32469,15 +32536,17 @@ ${selector} .arrowheadPath { async function startLiveSession(options) { options = options || {}; const isHost = options.isHost !== false; - const roomId = options.roomId || ('room-' + getRandomBase64Url(9)); - const secret = options.secret || ('secret-' + getRandomBase64Url(32)); + const roomId = options.roomId || ('room2_' + getRandomBase64Url(18)); + const encryption = isHost ? await MarkdownShareCrypto.createRoom(roomId) : options.encryption || null; + const secret = encryption ? '' : options.secret; const displayName = getLiveDisplayName({ forceGeneratedName: options.forceGeneratedName === true }); const modules = await loadLiveCollaborationModules(); const ydoc = new modules.Y.Doc(); const yText = ydoc.getText('markdown'); - const sessionMap = ydoc.getMap('session'); + const controlDoc = encryption ? new modules.Y.Doc() : ydoc; + const sessionMap = controlDoc.getMap('session'); const reviewDoc = new modules.Y.Doc(); const reviewMap = reviewDoc.getMap('reviewThreads'); const hostActiveTab = tabs.find(function(t) { return t.id === activeTabId; }); @@ -32509,7 +32578,7 @@ ${selector} .arrowheadPath { const liveCapability = isHost ? liveCapabilities.host : (options.capability || ''); - const inviteUrl = buildLiveInviteUrl(roomId, secret, hostTitle, requestedAccessMode, liveCapabilities); + const inviteUrl = buildLiveInviteUrl(roomId, secret, hostTitle, requestedAccessMode, liveCapabilities, encryption); if (isHost && yText.length === 0) { yText.insert(0, markdownEditor.value || ''); } @@ -32553,6 +32622,10 @@ ${selector} .arrowheadPath { } const localParticipantId = 'participant-' + getRandomBase64Url(12); + if (encryption) { + encryption.transport = await MarkdownShareCrypto.liveSession({ roomId, sender: localParticipantId, role: liveRole, + key: encryption.key, config: encryption.config, signingKey: isHost ? encryption.signing.host : encryption.signingKey }); + } const localColor = getLiveParticipantColor(displayName + '-' + getRandomBase64Url(2)); const localParticipant = { name: displayName, @@ -32570,6 +32643,8 @@ ${selector} .arrowheadPath { liveCollaboration = { roomId, secret, + encryption, + controlDoc, inviteUrl, ydoc, Y: modules.Y, @@ -32674,6 +32749,8 @@ ${selector} .arrowheadPath { liveCollaboration.connection = createLiveRoomConnection({ roomId, secret, + encryption, + controlDoc, role: liveCollaboration.socketRole, capability: liveCollaboration.socketCapability, capabilities: liveCollaboration.isHost ? liveCollaboration.capabilities : null, @@ -32718,6 +32795,17 @@ ${selector} .arrowheadPath { publishLocalParticipant(); liveCollaboration.participantHeartbeatId = setInterval(publishLocalParticipant, 15000); + if (encryption) { + const activeSession = liveCollaboration; + try { await activeSession.connection.ready; } + catch (error) { + if (liveCollaboration === activeSession) disconnectLiveCollaboration({ restoreOriginal: true }); + throw error; + } + if (liveCollaboration !== activeSession) return; + if (activeSession.tabId) renderMarkdown({ reason: 'live-encrypted', force: true }); + } + if (liveShareUrlInput) { liveShareUrlInput.value = inviteUrl; } @@ -32787,7 +32875,9 @@ ${selector} .arrowheadPath { } async function loadFromLiveHash() { - const parsed = parseLiveHash(); + let parsed; + try { parsed = parseLiveHash(); } + catch (_) { alert('The encrypted invite is incomplete or unsupported.'); return; } if (!parsed) return; try { const originalTabSnapshot = captureLiveTabSnapshot(); @@ -32807,6 +32897,7 @@ ${selector} .arrowheadPath { accessMode: parsed.accessMode, role: parsed.role, capability: parsed.capability, + encryption: parsed.encryption, isHost: false, forceGeneratedName: true, originalTabSnapshot, @@ -32970,6 +33061,14 @@ ${selector} .arrowheadPath { return; } + if (id.startsWith('s2_')) { + const params = new URLSearchParams(ampIdx === -1 ? '' : rest.slice(ampIdx + 1)); + const decrypted = await MarkdownShareCrypto.open(payload && payload.encrypted, params.get('key'), 'snapshot'); + if (!decrypted || typeof decrypted.content !== 'string' || typeof decrypted.title !== 'string' || + !['view', 'edit'].includes(decrypted.mode)) throw new Error('Invalid encrypted snapshot.'); + await openShareSnapshotTab(decrypted.content, decrypted.title, decrypted.mode, true); + return; + } const shareMode = payload && payload.mode === 'edit' ? 'edit' : 'view'; await openShareSnapshotTab( payload && typeof payload.content === 'string' ? payload.content : '', @@ -32977,8 +33076,7 @@ ${selector} .arrowheadPath { shareMode ); } catch (e) { - console.error('Failed to load stored shared content:', e); - alert('Network error while loading shared content.'); + alert(id.startsWith('s2_') ? 'Unable to open the encrypted snapshot. Check the complete link and connection.' : 'Network error while loading shared content.'); } } @@ -32990,6 +33088,18 @@ ${selector} .arrowheadPath { } if (!hash.startsWith('#share=')) return; + if (hash.startsWith('#share=v2.')) { + try { + const params = new URLSearchParams(hash.slice(1)); + const encrypted = MarkdownShareCrypto.unpack(params.get('share').slice(3), MAX_SHARE_URL_LENGTH); + const payload = await MarkdownShareCrypto.open(encrypted, params.get('key'), 'snapshot'); + if (!payload || typeof payload.content !== 'string' || typeof payload.title !== 'string' || + !['view', 'edit'].includes(payload.mode)) throw new Error('Invalid encrypted snapshot.'); + await openShareSnapshotTab(payload.content, payload.title, payload.mode, true); + } catch (_) { alert('Unable to open the encrypted snapshot. Check that the complete link is present.'); } + return; + } + // PERF-002: Lazy-load pako when loading shared URL content if (typeof pako === 'undefined') { loadScript(CDN.pako).then(function() { diff --git a/desktop-app/resources/js/sharing-crypto.js b/desktop-app/resources/js/sharing-crypto.js new file mode 100644 index 00000000..76e68f23 --- /dev/null +++ b/desktop-app/resources/js/sharing-crypto.js @@ -0,0 +1,207 @@ +/* Shared browser/Worker protocol. No content keys are held by the service. */ +(function(root) { + 'use strict'; + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + const SNAPSHOT_LIMIT = 8 * 1024 * 1024; + const MEDIA_LIMIT = 15 * 1024 * 1024; + const LIVE_LIMIT = 8 * 1024 * 1024; + const MAX_SEQUENCE = 0xffffffff; + const TOKEN = /^[A-Za-z0-9_-]{16,160}$/; + const TYPES = ['hello', 'presence', 'sync-request', 'sync-state', 'y-update', + 'review-sync-request', 'review-sync-state', 'review-update', 'control-state', 'leave', 'session-end']; + + function cryptoApi() { + if (!root.crypto || !root.crypto.subtle) throw new Error('Encrypted sharing requires a secure connection and Web Crypto.'); + return root.crypto; + } + function encode(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + function decode(value, limit) { + if (typeof value !== 'string' || !value || value.length > Math.ceil(limit * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error('Invalid encrypted sharing data.'); + } + const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')); + if (binary.length > limit) throw new Error('Encrypted sharing data is too large.'); + const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0)); + if (encode(bytes) !== value) throw new Error('Non-canonical encrypted sharing data.'); + return bytes; + } + function random(bytes = 32) { return encode(cryptoApi().getRandomValues(new Uint8Array(bytes))); } + function jsonBytes(value, limit) { + const bytes = encoder.encode(JSON.stringify(value)); + if (bytes.length > limit) throw new Error('The content is too large for encrypted sharing.'); + return bytes; + } + function exactKeys(value, keys) { + return value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + } + function envelope(value, limit) { + if (!exactKeys(value, ['v', 'alg', 'iv', 'ct']) || value.v !== 2 || value.alg !== 'A256GCM' || + typeof value.iv !== 'string' || value.iv.length !== 16 || typeof value.ct !== 'string' || + value.ct.length < 23 || value.ct.length > Math.ceil((limit + 16) * 4 / 3) || + !/^[A-Za-z0-9_-]+$/.test(value.iv) || !/^[A-Za-z0-9_-]+$/.test(value.ct)) { + throw new Error('A supported encrypted sharing envelope is required.'); + } + return { v: 2, alg: 'A256GCM', iv: value.iv, ct: value.ct }; + } + async function importAes(key) { + const bytes = decode(key, 32); + if (bytes.length !== 32) throw new Error('The share key is missing or invalid.'); + return cryptoApi().subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt', 'decrypt']); + } + async function seal(value, key, purpose, limit = SNAPSHOT_LIMIT) { + const bytes = jsonBytes(value, limit); + const iv = cryptoApi().getRandomValues(new Uint8Array(12)); + const ct = await cryptoApi().subtle.encrypt({ name: 'AES-GCM', iv, + additionalData: encoder.encode('markdown-viewer:v2:' + purpose), tagLength: 128 }, + await importAes(key), bytes); + return { v: 2, alg: 'A256GCM', iv: encode(iv), ct: encode(new Uint8Array(ct)) }; + } + async function open(value, key, purpose, limit = SNAPSHOT_LIMIT) { + const record = envelope(value, limit); + try { + const bytes = await cryptoApi().subtle.decrypt({ name: 'AES-GCM', iv: decode(record.iv, 12), + additionalData: encoder.encode('markdown-viewer:v2:' + purpose), tagLength: 128 }, + await importAes(key), decode(record.ct, limit + 16)); + if (bytes.byteLength > limit) throw new Error(); + return JSON.parse(decoder.decode(bytes)); + } catch (_) { throw new Error('Unable to decrypt this share. Check that the complete link is present.'); } + } + function pack(value, limit = SNAPSHOT_LIMIT * 2) { return encode(jsonBytes(value, limit)); } + function unpack(value, limit = SNAPSHOT_LIMIT * 2) { return JSON.parse(decoder.decode(decode(value, limit))); } + async function hash(value) { return encode(new Uint8Array(await cryptoApi().subtle.digest('SHA-256', encoder.encode(value)))); } + function canSend(role, type) { + if (!TYPES.includes(type)) return false; + if (role === 'host') return true; + if (role !== 'edit' && role !== 'view') return false; + if (['session-end', 'control-state', 'review-sync-state'].includes(type)) return false; + return role === 'edit' || !['sync-state', 'y-update'].includes(type); + } + async function signingPair() { + const pair = await cryptoApi().subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + return { + privateKey: encode(new Uint8Array(await cryptoApi().subtle.exportKey('pkcs8', pair.privateKey))), + publicKey: encode(new Uint8Array(await cryptoApi().subtle.exportKey('raw', pair.publicKey))) + }; + } + function publicConfig(config, roomId) { + if (!exactKeys(config, ['v', 'roomId', 'expiresAt', 'host', 'edit', 'view']) || config.v !== 2 || + config.roomId !== roomId || typeof roomId !== 'string' || !TOKEN.test(roomId) || + !Number.isSafeInteger(config.expiresAt) || config.expiresAt <= 0 || + !['host', 'edit', 'view'].every(role => typeof config[role] === 'string' && decode(config[role], 65).length === 65)) { + throw new Error('Invalid encrypted room configuration.'); + } + return { v: 2, roomId, expiresAt: config.expiresAt, host: config.host, edit: config.edit, view: config.view }; + } + async function createRoom(roomId) { + const host = await signingPair(), edit = await signingPair(), view = await signingPair(); + return { key: random(), config: { v: 2, roomId, expiresAt: Date.now() + 86400000, host: host.publicKey, edit: edit.publicKey, view: view.publicKey }, + signing: { host: host.privateKey, edit: edit.privateKey, view: view.privateKey } }; + } + function header(frame) { + return JSON.stringify(['markdown-viewer:live', 2, frame.roomId, frame.sender, frame.session, frame.seq, frame.type, frame.role]); + } + function signedBytes(frame) { return encoder.encode(JSON.stringify([header(frame), frame.iv, frame.ct])); } + function liveFrame(frame) { + if (!exactKeys(frame, ['v', 'roomId', 'sender', 'session', 'seq', 'type', 'role', 'iv', 'ct', 'sig']) || + frame.v !== 2 || ![frame.roomId, frame.sender, frame.session].every(value => typeof value === 'string' && TOKEN.test(value)) || + !Number.isSafeInteger(frame.seq) || frame.seq < 1 || frame.seq > MAX_SEQUENCE || !canSend(frame.role, frame.type)) { + throw new Error('Invalid encrypted live frame.'); + } + envelope({ v: 2, alg: 'A256GCM', iv: frame.iv, ct: frame.ct }, LIVE_LIMIT); + if (typeof frame.sig !== 'string' || decode(frame.sig, 64).length !== 64) throw new Error('Invalid live signature.'); + return frame; + } + async function verifyFrame(frame, config) { + liveFrame(frame); + publicConfig(config, frame.roomId); + const key = await cryptoApi().subtle.importKey('raw', decode(config[frame.role], 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + if (!await cryptoApi().subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, decode(frame.sig, 64), signedBytes(frame))) { + throw new Error('Invalid live signature.'); + } + return true; + } + async function liveSession(options) { + const config = publicConfig(options.config, options.roomId); + if (config.expiresAt <= Date.now()) throw new Error('This encrypted room has expired.'); + const rootBytes = decode(options.key, 32); + if (rootBytes.length !== 32 || typeof options.sender !== 'string' || !TOKEN.test(options.sender)) throw new Error('Invalid encrypted room key.'); + const rootKey = await cryptoApi().subtle.importKey('raw', rootBytes, 'HKDF', false, ['deriveKey']); + const signer = await cryptoApi().subtle.importKey('pkcs8', decode(options.signingKey, 256), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + const verifier = await cryptoApi().subtle.importKey('raw', decode(config[options.role], 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + const challenge = encoder.encode('markdown-viewer:live:role-check'); + const proof = await cryptoApi().subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signer, challenge); + if (!await cryptoApi().subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifier, proof, challenge)) throw new Error('Invalid live role key.'); + const session = random(16); + let sequence = 0; + const received = new Map(); + async function derive(frame) { + return cryptoApi().subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: decode(frame.session, 32), + info: encoder.encode(JSON.stringify(['markdown-viewer:live:v2', frame.roomId, frame.sender])) }, + rootKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + const ownKey = await derive({ session, roomId: options.roomId, sender: options.sender }); + return { + session, + async encrypt(message) { + if (!canSend(options.role, message.type) || sequence >= MAX_SEQUENCE) throw new Error('Live message is not permitted.'); + const frame = { v: 2, roomId: options.roomId, sender: options.sender, session, + seq: ++sequence, type: message.type, role: options.role }; + // A fresh session has a fresh HKDF key. Its counter IV is never reused. + const iv = new Uint8Array(12); + new DataView(iv.buffer).setUint32(8, frame.seq); + const ciphertext = await cryptoApi().subtle.encrypt({ name: 'AES-GCM', iv, + additionalData: encoder.encode(header(frame)), tagLength: 128 }, ownKey, jsonBytes(message, LIVE_LIMIT)); + frame.iv = encode(iv); + frame.ct = encode(new Uint8Array(ciphertext)); + frame.sig = encode(new Uint8Array(await cryptoApi().subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signer, signedBytes(frame)))); + // Detect a mismatched/tampered invitation before sending any room data. + await verifyFrame(frame, config); + return frame; + }, + async decrypt(frame) { + if (frame.roomId !== options.roomId) throw new Error('Wrong live room.'); + await verifyFrame(frame, config); + const peer = frame.role + ':' + frame.sender + ':' + frame.session; + if (frame.seq <= (received.get(peer) || 0)) throw new Error('Replayed live frame.'); + if (!received.has(peer) && received.size >= 1024) throw new Error('Too many live sessions.'); + const plaintext = await cryptoApi().subtle.decrypt({ name: 'AES-GCM', iv: decode(frame.iv, 12), + additionalData: encoder.encode(header(frame)), tagLength: 128 }, await derive(frame), decode(frame.ct, LIVE_LIMIT + 16)); + const message = JSON.parse(decoder.decode(plaintext)); + if (!message || message.type !== frame.type) throw new Error('Wrong live channel.'); + received.set(peer, frame.seq); + return Object.assign({}, message, { sender: frame.sender, roomId: frame.roomId, role: frame.role }); + } + }; + } + async function readJson(request, limit) { + if (!request.body || Number(request.headers.get('Content-Length')) > limit) throw new Error('Request is too large.'); + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > limit) { await reader.cancel(); throw new Error('Request is too large.'); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return JSON.parse(decoder.decode(bytes)); + } + const api = { SNAPSHOT_LIMIT, MEDIA_LIMIT, LIVE_LIMIT, MAX_SEQUENCE, encode, decode, random, pack, unpack, + seal, open, envelope, exactKeys, hash, canSend, publicConfig, createRoom, liveFrame, verifyFrame, liveSession, readJson }; + root.MarkdownShareCrypto = Object.freeze(api); + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(globalThis); diff --git a/desktop-app/resources/js/sharing-live.js b/desktop-app/resources/js/sharing-live.js new file mode 100644 index 00000000..ed50ed6a --- /dev/null +++ b/desktop-app/resources/js/sharing-live.js @@ -0,0 +1,129 @@ +(function(root) { + 'use strict'; + function connect(options) { + const C = root.MarkdownShareCrypto; + const cryptoSession = options.encryption.transport; + const socket = new WebSocket(options.socketUrl); + let opened = false, destroyed = false, failed = false; + let sendChain = Promise.resolve(), receiveChain = Promise.resolve(); + let queuedBytes = 0, queuedCount = 0, incomingBytes = 0, incomingCount = 0; + const waiting = []; + let resolveReady, rejectReady; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + // A rejected connection is also reported through onStatus; callers may await ready. + ready.catch(() => {}); + const timer = setTimeout(() => fail('Encrypted room connection timed out.'), 8000); + function fail(message) { + if (destroyed || failed) return; + failed = true; + clearTimeout(timer); + waiting.length = 0; + options.onStatus(message, 'error'); + rejectReady(new Error(message)); + socket.close(); + } + function send(message) { + if (destroyed || failed || !C.canSend(options.role, message.type)) return; + let copy; + try { copy = JSON.stringify(message); } catch (_) { fail('Unable to encrypt a live update.'); return; } + if (copy.length > C.LIVE_LIMIT || queuedCount >= 100 || queuedBytes + copy.length > C.LIVE_LIMIT * 2) { + fail('Live update queue is full. Rejoin the room to synchronize.'); return; + } + queuedCount += 1; queuedBytes += copy.length; + sendChain = sendChain.then(async () => { + if (destroyed || failed) return; + const frame = JSON.stringify(await cryptoSession.encrypt(JSON.parse(copy))); + if (destroyed || failed) return; + if (opened) { + if (socket.bufferedAmount > C.LIVE_LIMIT * 2) throw new Error('Backpressure'); + socket.send(frame); + } else { + if (waiting.length >= 100 || waiting.reduce((n, value) => n + value.length, 0) + frame.length > C.LIVE_LIMIT * 2) throw new Error('Backpressure'); + waiting.push(frame); + } + }).catch(() => fail('Unable to send an encrypted live update. Rejoin the room.')) + .finally(() => { queuedCount -= 1; queuedBytes -= copy.length; }); + return sendChain; + } + function publishCurrentState() { + if (options.canPublishState && !options.canPublishState()) return; + send({ type: 'sync-state', update: C.encode(options.Y.encodeStateAsUpdate(options.ydoc)) }); + } + function publishReviewState() { + if (!options.reviewDoc || (options.canPublishReviewState && !options.canPublishReviewState())) return; + send({ type: 'review-sync-state', update: C.encode(options.Y.encodeStateAsUpdate(options.reviewDoc)) }); + } + function publishControlState() { + if (options.role === 'host') send({ type: 'control-state', update: C.encode(options.Y.encodeStateAsUpdate(options.controlDoc)) }); + } + function publishPresence(cursor) { + send({ type: 'presence', participant: options.getParticipant(), cursor: cursor || null }); + } + const transport = { publishCurrentState, publishReviewState, publishControlState, publishPresence }; + const update = (bytes, origin) => { + if (origin === options.relayOrigin || (options.canSendUpdate && !options.canSendUpdate())) return; + send({ type: 'y-update', update: C.encode(bytes) }); + }; + const reviewUpdate = (bytes, origin) => { + if (origin === options.reviewRelayOrigin || (options.canSendReviewUpdate && !options.canSendReviewUpdate())) return; + send({ type: 'review-update', update: C.encode(bytes) }); + }; + options.ydoc.on('update', update); + options.reviewDoc.on('update', reviewUpdate); + options.controlDoc.on('update', publishControlState); + socket.addEventListener('open', () => { + if (destroyed || failed) return; + socket.send(JSON.stringify({ v: 2, type: 'authenticate', role: options.role, + capability: options.capability, capabilities: options.role === 'host' ? options.capabilities : undefined, + sender: options.participantId, session: cryptoSession.session, config: options.encryption.config })); + }); + socket.addEventListener('message', event => { + if (destroyed || failed) return; + if (typeof event.data !== 'string' || event.data.length > Math.ceil((C.LIVE_LIMIT + 16) * 4 / 3) + 4096 || + incomingCount >= 100 || incomingBytes + event.data.length > C.LIVE_LIMIT * 3) { + fail('Encrypted room receive limit exceeded.'); return; + } + incomingCount += 1; incomingBytes += event.data.length; + receiveChain = receiveChain.then(async () => { + if (destroyed || failed) return; + const frame = JSON.parse(event.data); + if (!opened && frame.type === 'ready' && frame.v === 2 && Object.keys(frame).length === 2) { + opened = true; + clearTimeout(timer); + waiting.splice(0).forEach(value => socket.send(value)); + send({ type: 'hello', participant: options.getParticipant() }); + send({ type: 'sync-request' }); + send({ type: 'review-sync-request' }); + publishControlState(); publishCurrentState(); publishReviewState(); publishPresence(options.getCursor()); + options.onStatus('Connected to encrypted live room', 'connected'); + resolveReady(); + return; + } + if (!opened) throw new Error('Authentication required'); + const message = await cryptoSession.decrypt(frame); + if (destroyed || failed || message.sender === options.participantId) return; + options.onMessage(message, transport); + }).catch(() => fail('An encrypted live message could not be verified. Rejoin with the complete invite link.')) + .finally(() => { incomingCount -= 1; incomingBytes -= event.data.length; }); + }); + socket.addEventListener('close', () => fail('Live room disconnected.')); + socket.addEventListener('error', () => fail('Encrypted live room connection failed.')); + return Object.assign({ ready, send, destroy() { + if (destroyed) return; + destroyed = true; + clearTimeout(timer); + rejectReady(new Error('Live room closed.')); + options.ydoc.off('update', update); + options.reviewDoc.off('update', reviewUpdate); + options.controlDoc.off('update', publishControlState); + waiting.length = 0; + // Complete queued cryptography before the signed leave so sequence numbers stay ordered. + sendChain.then(async () => { + if (opened && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(await cryptoSession.encrypt({ type: 'leave' }))); + } + }).catch(() => {}).finally(() => socket.close()); + } }, transport); + } + root.MarkdownShareLive = Object.freeze({ connect }); +})(globalThis); diff --git a/desktop-app/resources/js/sharing-media.js b/desktop-app/resources/js/sharing-media.js new file mode 100644 index 00000000..d8366e59 --- /dev/null +++ b/desktop-app/resources/js/sharing-media.js @@ -0,0 +1,139 @@ +/* Client-only encrypted media resolution. Keys are removed before fetch. */ +(function(root) { + 'use strict'; + const states = new WeakMap(); + const MEDIA_URL = /https?:\/\/[^\s<>"')]+\/api\/(?:image|media)\/e2_[A-Za-z0-9_-]{24}#key=[A-Za-z0-9_-]{43}/g; + function reference(value, base) { + try { + const url = new URL(value, base); + const expected = new URL(base); + if (url.origin !== expected.origin || url.search || !/^\/api\/(?:image|media)\/e2_[A-Za-z0-9_-]{24}$/.test(url.pathname) || + !/^#key=[A-Za-z0-9_-]{43}$/.test(url.hash)) return null; + return { url: url.origin + url.pathname, key: url.hash.slice(5) }; + } catch (_) { return null; } + } + function validateDataUrl(dataUrl) { + if (typeof dataUrl !== 'string' || dataUrl.length > 14_100_000) throw new Error('Invalid encrypted media.'); + const match = /^data:((?:image\/(?:avif|bmp|gif|jpeg|png|webp))|(?:video\/(?:mp4|ogg|webm)));base64,([A-Za-z0-9+/]+={0,2})$/.exec(dataUrl); + if (!match) throw new Error('Unsupported encrypted media type.'); + const mime = match[1]; + const limit = mime.startsWith('video/') ? 10 * 1024 * 1024 : mime === 'image/gif' ? 5 * 1024 * 1024 : 300 * 1024; + if (match[2].length > Math.ceil(limit / 3) * 4) throw new Error('Encrypted media is too large.'); + const bytes = Uint8Array.from(atob(match[2]), ch => ch.charCodeAt(0)); + const ascii = (a, b) => String.fromCharCode.apply(null, bytes.subarray(a, b)); + const prefix = values => values.every((value, index) => bytes[index] === value); + const valid = { + 'image/png': () => prefix([137, 80, 78, 71, 13, 10, 26, 10]), + 'image/jpeg': () => prefix([255, 216, 255]), + 'image/gif': () => ['GIF87a', 'GIF89a'].includes(ascii(0, 6)), + 'image/webp': () => ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP', + 'image/bmp': () => ascii(0, 2) === 'BM', + 'image/avif': () => ascii(4, 8) === 'ftyp' && ['avif', 'avis'].includes(ascii(8, 12)), + 'video/mp4': () => ascii(4, 8) === 'ftyp', + 'video/ogg': () => ascii(0, 4) === 'OggS', + 'video/webm': () => prefix([26, 69, 223, 163]) + }[mime]; + if (!bytes.length || bytes.length > limit || !valid()) throw new Error('Invalid encrypted media bytes.'); + return { mime, bytes }; + } + async function decrypt(ref, signal) { + const response = await fetch(ref.url, { signal, cache: 'no-store', referrerPolicy: 'no-referrer', credentials: 'omit' }); + if (!response.ok) throw new Error('Encrypted media is unavailable or expired.'); + const body = await MarkdownShareCrypto.readJson(response, Math.ceil((MarkdownShareCrypto.MEDIA_LIMIT + 16) * 4 / 3) + 1024); + const payload = await MarkdownShareCrypto.open(body.encrypted, ref.key, 'media', MarkdownShareCrypto.MEDIA_LIMIT); + if (!MarkdownShareCrypto.exactKeys(payload, ['dataUrl'])) throw new Error('Invalid encrypted media.'); + const parsed = validateDataUrl(payload.dataUrl); + return { dataUrl: payload.dataUrl, mime: parsed.mime, bytes: parsed.bytes }; + } + function prepareHtml(html, options) { + const template = document.createElement('template'); + template.innerHTML = html; + const content = template.content; + // Template contents are inert: remove remote URLs before nodes enter a live document. + content.querySelectorAll('img, video, source').forEach(node => { + const value = node.getAttribute('src'); + const ref = reference(value || '', options.base); + if (ref) { + node.removeAttribute('src'); + node.removeAttribute('srcset'); + node.setAttribute('data-encrypted-media', MarkdownShareCrypto.pack(ref, 2048)); + } + }); + if (options.privateContent) { + content.querySelectorAll('*').forEach(node => { + node.removeAttribute('style'); + node.removeAttribute('srcset'); + node.removeAttribute('poster'); + node.removeAttribute('background'); + node.removeAttribute('ping'); + for (const name of ['src', 'href', 'xlink:href']) { + if (name === 'href' && node.tagName === 'A') continue; + const value = node.getAttribute(name); + if (value && !value.startsWith('#') && !/^data:(?:image\/(?:png|jpeg|gif|webp|avif|bmp)|video\/(?:mp4|ogg|webm));base64,/.test(value)) { + node.removeAttribute(name); + if (node.tagName === 'IMG') node.setAttribute('alt', (node.getAttribute('alt') || 'External image') + ' (external resource blocked)'); + } + } + }); + content.querySelectorAll('style, link, iframe, object, embed').forEach(node => node.remove()); + // Render source locally rather than allowing renderer-specific remote assets/fallbacks. + content.querySelectorAll('.mermaid, .plantuml-diagram, .d2-diagram, .graphviz-diagram, .kroki-diagram, .geojson-map, .topojson-map, .stl-viewer, .abc-notation').forEach(node => { + const pre = document.createElement('pre'); + const code = document.createElement('code'); + let source = node.getAttribute('data-original-code'); + try { source = source ? decodeURIComponent(source) : node.textContent; } catch (_) { source = node.textContent; } + code.textContent = source || ''; + pre.appendChild(code); + const shell = node.closest('.diagram-viewer, .mermaid-container, .geojson-container, .topojson-container, .stl-container, .abc-container'); + (shell || node).replaceWith(pre); + }); + } + return template.innerHTML; + } + function release(container) { + const state = states.get(container); + if (!state) return; + state.controller.abort(); + state.urls.forEach(url => URL.revokeObjectURL(url)); + states.delete(container); + } + async function resolve(container, options = {}) { + release(container); + const state = { controller: new AbortController(), urls: [] }; + states.set(container, state); + for (const node of container.querySelectorAll('[data-encrypted-media]')) { + if (states.get(container) !== state) return; + const timeout = setTimeout(() => state.controller.abort(), 15000); + try { + const decoded = MarkdownShareCrypto.unpack(node.getAttribute('data-encrypted-media'), 2048); + const ref = reference(decoded.url + '#key=' + decoded.key, options.base); + if (!ref) throw new Error('Invalid encrypted media reference.'); + const media = await decrypt(ref, state.controller.signal); + if (states.get(container) !== state || !container.contains(node)) return; + const url = options.inline ? media.dataUrl : URL.createObjectURL(new Blob([media.bytes], { type: media.mime })); + if (!options.inline) state.urls.push(url); + node.setAttribute('src', url); + if (options.inline) node.removeAttribute('data-encrypted-media'); + const video = node.tagName === 'VIDEO' ? node : node.closest('video'); + if (video) video.load(); + } catch (_) { + if (options.inline) throw new Error('An encrypted attachment could not be exported. Check the connection and expiry.'); + if (states.get(container) === state) node.setAttribute('title', 'Encrypted media is unavailable. Check the complete link and connection.'); + } finally { clearTimeout(timeout); } + } + } + async function inlineMarkdown(markdown, base) { + let output = markdown; + for (const url of new Set(markdown.match(MEDIA_URL) || [])) { + const ref = reference(url, base); + if (!ref) continue; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { output = output.split(url).join((await decrypt(ref, controller.signal)).dataUrl); } + finally { clearTimeout(timer); } + } + return output; + } + root.MarkdownShareMedia = Object.freeze({ reference, validateDataUrl, prepareHtml, resolve, release, inlineMarkdown }); + if (typeof module !== 'undefined' && module.exports) module.exports = root.MarkdownShareMedia; +})(globalThis); diff --git a/docs/ENCRYPTED_SHARING.md b/docs/ENCRYPTED_SHARING.md new file mode 100644 index 00000000..9f2f6be5 --- /dev/null +++ b/docs/ENCRYPTED_SHARING.md @@ -0,0 +1,45 @@ +# Encrypted sharing protocol and rollout + +New snapshots, managed attachments, and live rooms use version 2 client-side encryption. The implementation uses browser Web Crypto and locally bundled Yjs. No server encryption secret or key-management service is required. + +## Snapshot and attachment envelopes + +`sharing-crypto.js` generates independent 32-byte random keys. AES-256-GCM encrypts UTF-8 JSON with a random 12-byte IV, a 128-bit tag, and additional authenticated data `markdown-viewer:v2:snapshot` or `markdown-viewer:v2:media`. The exact envelope fields are `v: 2`, `alg: "A256GCM"`, `iv`, and `ct`; binary fields use unpadded canonical base64url. `ct` includes the authentication tag. The content key never appears in the envelope or API request. + +Snapshot plaintext is `{content,title,mode}`. Managed-media plaintext is `{dataUrl}` including the MIME type and base64 file bytes. JSON limits are 8 MiB and 15 MiB respectively; encoded request limits allow only the corresponding bounded overhead. The client verifies media signatures, allowed raster/video types, and decoded sizes before upload and after decryption. The server validates envelope shape, lengths, and version without decrypting content. + +New ids use 18 random bytes: `s2_` and `e2_`. Storage prefixes are `share-v2:` and `managed-media-v2:`. Snapshot records contain ciphertext, creation time, version, and a deletion-token hash. Media records contain ciphertext, creation/expiry times, and version. Both have a fixed 90-day TTL. Identical media uploads create independent records without refreshing older records. + +Complete links carry `key` in the fragment. Inline snapshots pack the encrypted envelope into `#share=v2.&key=...`; stored snapshots use `#id=s2_...&key=...`. Attachments use `/api/image/e2_...#key=...` or `/api/media/e2_...#key=...`. The client removes fragments before requests. Snapshot deletion uses an independent random token; only its SHA-256 hash is stored. The recipient link does not include it. + +## Live protocol + +Rooms use independent read keys and ECDSA P-256 host/edit/view signing pairs. The public configuration contains exactly `v`, `roomId`, `expiresAt`, `host`, `edit`, `view`. Public keys are raw P-256 points; role private keys are PKCS8 and remain in clients or the role-specific invite fragment. The host key is never included in guest invites. Each role also has an independent connection capability. The first WebSocket frame authenticates that capability over TLS; the relay stores SHA-256 capability hashes, public configuration, expiry, and ended status. + +Each connection generates a random 16-byte session salt and derives a session AES-256-GCM key from the read key with HKDF-SHA-256. HKDF info binds room and sender. A 12-byte IV contains a zero prefix and a monotonically increasing 32-bit big-endian sequence counter. A session stops before counter exhaustion. New session salts yield independent keys. Frames contain exactly `v,roomId,sender,session,seq,type,role,iv,ct,sig`. + +The canonical authenticated header is the JSON array `["markdown-viewer:live", 2, roomId, sender, session, seq, type, role]`. ECDSA-SHA-256 signs UTF-8 JSON `[headerString, iv, ct]` as defined in `header`/`signedBytes`. Relay and recipients verify signatures against the public role key and reject unauthorized channels. Recipients authenticate AES-GCM before applying payloads and track per-role/sender/session sequence numbers to reject duplicates and reordering. Queues, frame sizes, session histories, sockets, and authentication time are bounded. Processing is serialized per connection. + +Document, Review, and host control state use separate channels/documents. Names, cursors, presence, sync content, leave and end events are encrypted. Only host signatures authorize room controls, full Review state, and session end. Viewers may contribute Review changes but cannot publish document changes. Editors cannot publish host controls. Same-role participants share role authority; signatures are not individual identity certificates. + +New rooms last at most 24 hours. End persists an ended marker before closing sockets, preventing rejoining. Expiry alarms close sockets and remove authentication state. The relay has no document history. Capability authentication and public signatures do not give the relay content-decryption keys. + +## Client rendering and exports + +Decrypted content is sanitized before rendering. Inert templates remove external resource attributes before nodes enter the live DOM. Encrypted attachments resolve to local blob URLs, revoked on rerender/disposal. Private shared previews do not invoke remote diagram engines, external maps, or automatic resource loading; advanced blocks display source. Explicit user navigation remains possible. Web Markdown exports inline decrypted attachments. Private HTML exports are static, include inline media, and use a restrictive CSP. Exported files contain readable content. + +## Deployment and compatibility + +1. Build with `npm ci` and `npm run build`; prepare desktop resources with `node desktop-app/prepare.js` when packaging desktop. +2. Deploy the updated Durable Object Worker, then the Pages Functions and matching browser assets as one coordinated rollout. Preserve the existing KV namespace, Durable Object binding, and class migration. Include all three `sharing-*.js` files and `assets/yjs.mjs`. +3. Refresh clients. The service worker cache version changes. Older clients attempting new snapshot/media or room creation receive an upgrade/failure response; they must not retry with an older content format. +4. Existing snapshot/media reads and provisioned legacy rooms retain their compatibility paths. No migration, bulk deletion, or TTL refresh is performed. Naturally expiring records keep their timeline; legacy records without an expiry are not assigned one implicitly. +5. Validate synthetic snapshot round trips, encrypted media, host/editor/viewer collaboration, end/expiry behavior, and legacy reads in the target environment before promoting a release. Never log complete links, content keys, role private keys, decrypted content, or raw capabilities. + +This change does not re-encrypt existing links. Rollback must retain v2 readers and relay support for already-created encrypted links; reverting to a build without these readers makes them unavailable. Do not restore plaintext creation as an automatic fallback. + +## Security scope and verification + +Operational metadata remains visible: identifiers, roles, frame types, timing, sizes, and network addresses. Complete links are bearer credentials. Recipients may copy content. Client/application delivery compromise, extensions, exported files, lost keys, and forward secrecy are outside the protection provided here. TLS is still required. There is no claim of an independent cryptographic audit. + +Unit tests cover authenticated encryption, malformed envelopes, wrong keys, tampering, context separation, role forgery, replays, record opacity, legacy retention, and bounded requests. Local Miniflare/workerd tests exercise the real relay and storage. Browser tests cover snapshots, media/export, private rendering, and real Yjs collaboration with synthetic local services. diff --git a/functions/api/image/[[id]].js b/functions/api/image/[[id]].js index c32de696..1a23d64d 100644 --- a/functions/api/image/[[id]].js +++ b/functions/api/image/[[id]].js @@ -1,12 +1,15 @@ +import '../../../sharing-crypto.js'; +const Sharing = globalThis.MarkdownShareCrypto; const MAX_IMAGE_BYTES = 300 * 1024; const MAX_GIF_BYTES = 5 * 1024 * 1024; const MAX_VIDEO_BYTES = 10 * 1024 * 1024; -const MAX_DATA_URL_CHARS = 14_100_000; const MEDIA_TTL_SECONDS = 60 * 60 * 24 * 90; const MEDIA_TTL_MILLISECONDS = MEDIA_TTL_SECONDS * 1000; const MEDIA_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; // Keep the established prefix so links created before video support remain valid. const MEDIA_KEY_PREFIX = "managed-image-v1:"; +const ENCRYPTED_MEDIA_ID = /^e2_[A-Za-z0-9_-]{24}$/; +const ENCRYPTED_MEDIA_PREFIX = 'managed-media-v2:'; const ALLOWED_ORIGINS = new Set([ "https://markdownviewer.pages.dev", "null" @@ -104,27 +107,7 @@ function getMediaByteLimit(mimeType) { return MAX_IMAGE_BYTES; } -function parseMediaDataUrl(value) { - if (typeof value !== "string" || value.length > MAX_DATA_URL_CHARS) return null; - const match = value.match(/^data:((?:image\/(?:avif|bmp|gif|jpeg|png|webp))|(?:video\/(?:mp4|ogg|webm)));base64,([A-Za-z0-9+/]+={0,2})$/i); - if (!match) return null; - const mimeType = match[1].toLowerCase(); - if (!ALLOWED_MEDIA_TYPES.has(mimeType)) return null; - const bytes = decodeBase64(match[2]); - if (!bytes || bytes.byteLength > getMediaByteLimit(mimeType) || !hasValidMediaSignature(mimeType, bytes)) return null; - return { mimeType, base64: match[2], bytes }; -} -async function createMediaId(mimeType, bytes) { - const typeBytes = new TextEncoder().encode(mimeType + "\0"); - const input = new Uint8Array(typeBytes.length + bytes.length); - input.set(typeBytes, 0); - input.set(bytes, typeBytes.length); - const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); - let binary = ""; - digest.forEach(byte => { binary += String.fromCharCode(byte); }); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").slice(0, 24); -} function getPublicMediaUrl(request, id) { const url = new URL(request.url); @@ -156,29 +139,25 @@ export async function onRequest({ request, env, params }) { if (request.method === "POST" && !id) { const origin = request.headers.get("Origin") || ""; if (!isAllowedOrigin(origin)) return jsonResponse({ error: "origin not allowed" }, { status: 403 }, request); - const contentLength = Number(request.headers.get("Content-Length")); - if (Number.isFinite(contentLength) && contentLength > MAX_DATA_URL_CHARS + 1024) { - return jsonResponse({ error: "media upload is too large" }, { status: 413 }, request); - } let body; try { - body = await request.json(); + body = await Sharing.readJson(request, Math.ceil((Sharing.MEDIA_LIMIT + 16) * 4 / 3) + 1024); } catch (_) { return jsonResponse({ error: "invalid json" }, { status: 400 }, request); } - const media = parseMediaDataUrl(body && body.dataUrl); - if (!media) return jsonResponse({ error: "invalid or oversized image, GIF, or video" }, { status: 400 }, request); - - const mediaId = await createMediaId(media.mimeType, media.bytes); - const storageKey = MEDIA_KEY_PREFIX + mediaId; - const existing = await env.SHARE_KV.get(storageKey); + if (!Sharing.exactKeys(body, ['encrypted'])) { + return jsonResponse({ error: 'Update Markdown Viewer to upload encrypted media.' }, { status: 426 }, request); + } + let encrypted; + try { encrypted = Sharing.envelope(body.encrypted, Sharing.MEDIA_LIMIT); } + catch (_) { return jsonResponse({ error: 'invalid encrypted media' }, { status: 400 }, request); } + const mediaId = 'e2_' + Sharing.random(18); + const storageKey = ENCRYPTED_MEDIA_PREFIX + mediaId; const createdAt = Date.now(); const expiresAt = createdAt + MEDIA_TTL_MILLISECONDS; await env.SHARE_KV.put(storageKey, JSON.stringify({ - version: 2, - mimeType: media.mimeType, - base64: media.base64, - size: media.bytes.byteLength, + version: 3, + encrypted, createdAt, expiresAt }), { expirationTtl: MEDIA_TTL_SECONDS }); @@ -186,10 +165,22 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ id: mediaId, url: getPublicMediaUrl(request, mediaId), - mimeType: media.mimeType, - size: media.bytes.byteLength, expiresAt - }, { status: existing ? 200 : 201 }, request); + }, { status: 201 }, request); + } + + if ((request.method === 'GET' || request.method === 'HEAD') && ENCRYPTED_MEDIA_ID.test(id)) { + const raw = await env.SHARE_KV.get(ENCRYPTED_MEDIA_PREFIX + id); + if (!raw) return jsonResponse({ error: 'media not found' }, { status: 404 }, request); + try { + const record = JSON.parse(raw); + if (!Number.isFinite(record.expiresAt) || record.expiresAt <= Date.now()) { + return jsonResponse({ error: 'media expired' }, { status: 404 }, request); + } + const response = jsonResponse({ encrypted: Sharing.envelope(record.encrypted, Sharing.MEDIA_LIMIT) }, {}, request); + response.headers.set('Access-Control-Allow-Origin', '*'); + return request.method === 'HEAD' ? new Response(null, { headers: response.headers }) : response; + } catch (_) { return jsonResponse({ error: 'media unavailable' }, { status: 500 }, request); } } if ((request.method === "GET" || request.method === "HEAD") && MEDIA_ID_PATTERN.test(id)) { @@ -213,15 +204,10 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ error: "media unavailable" }, { status: 500 }, request); } const remainingTtl = Math.floor((expiresAt - Date.now()) / 1000); - if (remainingTtl < 60) { - await env.SHARE_KV.delete(MEDIA_KEY_PREFIX + id); + if (remainingTtl <= 0) { return jsonResponse({ error: "media expired" }, { status: 404 }, request); } - if (!Number.isFinite(storedExpiresAt) || storedExpiresAt <= 0) { - record.version = 2; - record.expiresAt = expiresAt; - await env.SHARE_KV.put(MEDIA_KEY_PREFIX + id, JSON.stringify(record), { expirationTtl: remainingTtl }); - } + // Legacy media keeps its original expiry; reading never extends retention. const bytes = decodeBase64(record.base64); if (!bytes || bytes.byteLength > getMediaByteLimit(record.mimeType) || !hasValidMediaSignature(record.mimeType, bytes)) { return jsonResponse({ error: "media unavailable" }, { status: 500 }, request); diff --git a/functions/api/share/[[id]].js b/functions/api/share/[[id]].js index 5ec1829a..de5f9487 100644 --- a/functions/api/share/[[id]].js +++ b/functions/api/share/[[id]].js @@ -1,9 +1,9 @@ +import '../../../sharing-crypto.js'; +const Sharing = globalThis.MarkdownShareCrypto; const SHARE_TTL_SECONDS = 60 * 60 * 24 * 90; -const MAX_CONTENT_CHARS = 8000000; -const SHARE_ID_LENGTH = 10; const SHARE_DELETE_TOKEN_BYTES = 18; -const SHARE_ID_PATTERN = /^[a-z2-9]{6,20}$/; -const SHARE_ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +const SHARE_ID_PATTERN = /^(?:[a-z2-9]{6,20}|s2_[A-Za-z0-9_-]{24})$/; +const storageKey = id => id.startsWith('s2_') ? 'share-v2:' + id : id; const ALLOWED_ORIGINS = new Set([ "https://markdownviewer.pages.dev", "null" @@ -68,14 +68,7 @@ function getShareId(params) { return typeof raw === "string" ? raw : ""; } -function generateShareId(length = SHARE_ID_LENGTH) { - const bytes = crypto.getRandomValues(new Uint8Array(length)); - let id = ""; - for (let i = 0; i < bytes.length; i += 1) { - id += SHARE_ID_ALPHABET[bytes[i] % SHARE_ID_ALPHABET.length]; - } - return id; -} +function generateShareId() { return 's2_' + Sharing.random(18); } function generateShareDeleteToken() { const bytes = crypto.getRandomValues(new Uint8Array(SHARE_DELETE_TOKEN_BYTES)); @@ -99,7 +92,7 @@ async function hashDeleteToken(token) { async function createUniqueShareId(kv) { for (let attempt = 0; attempt < 5; attempt += 1) { const id = generateShareId(); - const existing = await kv.get(id); + const existing = await kv.get(storageKey(id)); if (!existing) return id; } throw new Error("Unable to allocate share id"); @@ -131,31 +124,28 @@ export async function onRequest({ request, env, params }) { if (request.method === "POST" && !id) { let body; try { - body = await request.json(); + body = await Sharing.readJson(request, Math.ceil((Sharing.SNAPSHOT_LIMIT + 16) * 4 / 3) + 1024); } catch (_) { return jsonResponse({ error: "invalid json" }, { status: 400 }, request); } - const content = body && body.content; - if (typeof content !== "string" || content.length === 0) { - return jsonResponse({ error: "content required" }, { status: 400 }, request); - } - if (content.length > MAX_CONTENT_CHARS) { - return jsonResponse({ error: "content too large" }, { status: 413 }, request); + if (!Sharing.exactKeys(body, ['encrypted'])) { + return jsonResponse({ error: 'Update Markdown Viewer to create an encrypted share.' }, { status: 426 }, request); } + let encrypted; + try { encrypted = Sharing.envelope(body.encrypted, Sharing.SNAPSHOT_LIMIT); } + catch (_) { return jsonResponse({ error: 'invalid encrypted snapshot' }, { status: 400 }, request); } const shareId = await createUniqueShareId(env.SHARE_KV); const deleteToken = generateShareDeleteToken(); const record = { - content, - mode: sanitizeMode(body.mode), - title: sanitizeTitle(body.title), + version: 2, + encrypted, createdAt: Date.now(), - size: content.length, deleteTokenHash: await hashDeleteToken(deleteToken) }; - await env.SHARE_KV.put(shareId, JSON.stringify(record), { + await env.SHARE_KV.put(storageKey(shareId), JSON.stringify(record), { expirationTtl: SHARE_TTL_SECONDS }); @@ -173,7 +163,7 @@ export async function onRequest({ request, env, params }) { let body; try { - body = await request.json(); + body = await Sharing.readJson(request, 1024); } catch (_) { return jsonResponse({ error: "invalid json" }, { status: 400 }, request); } @@ -183,7 +173,7 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ error: "delete token required" }, { status: 400 }, request); } - const raw = await env.SHARE_KV.get(id); + const raw = await env.SHARE_KV.get(storageKey(id)); if (!raw) { return jsonResponse({ error: "not found" }, { status: 404 }, request); } @@ -199,7 +189,7 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ error: "not found" }, { status: 404 }, request); } - await env.SHARE_KV.delete(id); + await env.SHARE_KV.delete(storageKey(id)); return jsonResponse({ deleted: true }, {}, request); } @@ -208,7 +198,7 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ error: "invalid id" }, { status: 400 }, request); } - const raw = await env.SHARE_KV.get(id); + const raw = await env.SHARE_KV.get(storageKey(id)); if (!raw) { return jsonResponse({ error: "not found" }, { status: 404 }, request); } @@ -220,6 +210,11 @@ export async function onRequest({ request, env, params }) { return jsonResponse({ error: "invalid record" }, { status: 500 }, request); } + if (id.startsWith('s2_')) { + try { + return jsonResponse({ encrypted: Sharing.envelope(record.encrypted, Sharing.SNAPSHOT_LIMIT) }, {}, request); + } catch (_) { return jsonResponse({ error: 'invalid encrypted snapshot' }, { status: 500 }, request); } + } return jsonResponse({ content: record.content || "", mode: sanitizeMode(record.mode), diff --git a/functions/live-room/[[room]].js b/functions/live-room/[[room]].js index 0c5adf7c..91d50d0f 100644 --- a/functions/live-room/[[room]].js +++ b/functions/live-room/[[room]].js @@ -37,12 +37,18 @@ export async function onRequest(context) { if (!roomName || roomName.length > 160) { return new Response("Invalid live room", { status: 400 }); } - const secret = new URL(request.url).searchParams.get("secret") || ""; - if (!secret || secret.length > 256) { + const url = new URL(request.url); + const encrypted = url.searchParams.get('v') === '2'; + if ((url.searchParams.has('v') && !encrypted) || (encrypted && url.searchParams.size !== 1)) { + return new Response('Unsupported live room protocol', { status: 400 }); + } + const secret = url.searchParams.get("secret") || ""; + if (!encrypted && (!secret || secret.length > 256)) { return new Response("Invalid live room credentials", { status: 400 }); } - const id = env.LIVE_ROOMS.idFromName(roomName + ":" + secret); + if (encrypted && !/^room2_[A-Za-z0-9_-]{24}$/.test(roomName)) return new Response('Invalid encrypted room', { status: 400 }); + const id = env.LIVE_ROOMS.idFromName(encrypted ? 'encrypted-v2:' + roomName : roomName + ":" + secret); return env.LIVE_ROOMS.get(id).fetch(request); } diff --git a/index.html b/index.html index 016f44f9..2c5d8bca 100644 --- a/index.html +++ b/index.html @@ -1265,6 +1265,8 @@

Markdown Viewer

Copy
+

Encrypted sharing. Keep the complete link private; it includes the decryption key.

+
@@ -2452,6 +2454,9 @@

No comments yet

+ + +
diff --git a/package-lock.json b/package-lock.json index f598b7f5..479c63bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,10 @@ "mathjax": "4.0.0-beta.7", "mathjax-asana-font": "4.0.0-beta.7", "mathjax-stix2-font": "4.0.0-beta.7", + "miniflare": "4.20260730.0", "pako": "2.1.0", - "pdfjs-dist": "5.4.624" + "pdfjs-dist": "5.4.624", + "yjs": "13.6.31" }, "engines": { "node": ">=22 <27" @@ -44,6 +46,115 @@ "node": ">=6.9.0" } }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -361,139 +472,742 @@ "license": "MIT", "optional": true, "os": [ - "netbsd" + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@highlightjs/cdn-assets": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/@highlightjs/cdn-assets/-/cdn-assets-11.9.0.tgz", + "integrity": "sha512-F1vJKVAkLwj2Uz2ik1PDc+mDbkrecLI6gcBlAxSRUjyDpMPJjeDBanT9Y2B+xpNe1MT6zSG204Ohm/+nUMCApQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ - "x64" + "wasm32" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@highlightjs/cdn-assets": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/@highlightjs/cdn-assets/-/cdn-assets-11.9.0.tgz", - "integrity": "sha512-F1vJKVAkLwj2Uz2ik1PDc+mDbkrecLI6gcBlAxSRUjyDpMPJjeDBanT9Y2B+xpNe1MT6zSG204Ohm/+nUMCApQ==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.3.tgz", + "integrity": "sha512-lhTtjtoEArWBjismtfS761S9Hlyn+nIZqOZVYoZ1Xn82PMNMVnk+jj/NhC4fAXGTs7ydONcJiR5vyQBTMDu27g==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@napi-rs/canvas": { @@ -786,6 +1500,55 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -877,6 +1640,20 @@ "node": ">=10.0.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js": { "version": "3.51.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", @@ -910,6 +1687,16 @@ "utrie": "^1.0.2" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dompurify": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", @@ -940,6 +1727,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1095,6 +1892,17 @@ "dev": true, "license": "MIT" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -1155,6 +1963,38 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.120", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.120.tgz", + "integrity": "sha512-/a2eRImR8sZbmMVKe2we1/KukDQkuPyPf8yol6epvCu2PH6hurjcRI3mdGFFAi0uGoRJGyns99JquZgxwW1tQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -1211,6 +2051,27 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/miniflare": { + "version": "4.20260730.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260730.0.tgz", + "integrity": "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260730.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/node-readable-to-web-readable-stream": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", @@ -1340,6 +2201,19 @@ "dev": true, "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -1347,6 +2221,56 @@ "dev": true, "license": "MIT" }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -1368,6 +2292,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -1389,6 +2326,24 @@ "utrie": "^1.0.2" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1412,6 +2367,92 @@ "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==", "dev": true, "license": "MIT" + }, + "node_modules/workerd": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260730.1", + "@cloudflare/workerd-darwin-arm64": "1.20260730.1", + "@cloudflare/workerd-linux-64": "1.20260730.1", + "@cloudflare/workerd-linux-arm64": "1.20260730.1", + "@cloudflare/workerd-windows-64": "1.20260730.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } } } } diff --git a/package.json b/package.json index 72c499a8..92d2fa71 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,17 @@ "overrides": { "jspdf": { "dompurify": "3.4.17" + }, + "miniflare": { + "sharp": "0.35.5", + "undici": "7.30.1" } }, "scripts": { "start": "node tests/helpers/static-server.mjs", "dev": "node tests/helpers/static-server.mjs", - "build": "npm run build:toc && npm run check:static && npm run check:seo", + "build": "npm run build:sharing && npm run build:toc && npm run check:static && npm run check:seo", + "build:sharing": "node scripts/build-sharing.mjs", "build:toc": "node scripts/build-document-toc.mjs", "check:static": "node tests/helpers/static-build-check.mjs", "check:seo": "node seo/generate-sitemap.mjs --check", @@ -50,7 +55,9 @@ "mathjax": "4.0.0-beta.7", "mathjax-asana-font": "4.0.0-beta.7", "mathjax-stix2-font": "4.0.0-beta.7", + "miniflare": "4.20260730.0", "pako": "2.1.0", - "pdfjs-dist": "5.4.624" + "pdfjs-dist": "5.4.624", + "yjs": "13.6.31" } } diff --git a/script.js b/script.js index 5927d52b..400aea36 100644 --- a/script.js +++ b/script.js @@ -247,7 +247,7 @@ document.addEventListener("DOMContentLoaded", async function () { markmapLib: 'https://cdn.jsdelivr.net/npm/markmap-lib@0.18.12/dist/browser/index.iife.js', markmapView: 'https://cdn.jsdelivr.net/npm/markmap-view@0.18.12/dist/browser/index.js', jszip: 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', - yjs: 'https://esm.sh/yjs@13.6.10/es2022/yjs.mjs' + yjs: './assets/yjs.mjs' }; // Resolve local paths for desktop (Neutralinojs) offline support @@ -270,6 +270,7 @@ document.addEventListener("DOMContentLoaded", async function () { CDN.markmapLib = '/libs/markmap-lib.iife.js'; CDN.markmapView = '/libs/markmap-view.js'; CDN.jszip = '/libs/jszip.min.js'; + CDN.yjs = '/assets/yjs.mjs'; } // Active WebGL / Three.js 3D STL renderers Map for memory cleanup @@ -545,6 +546,16 @@ document.addEventListener("DOMContentLoaded", async function () { return Boolean(activeTabId && isShareSnapshotTabId(activeTabId)); } + function isEncryptedSharingActive() { + const tab = tabs.find(item => item.id === activeTabId); + return Boolean((tab && tab.encryptedSharing) || + (liveCollaboration && liveCollaboration.encryption && liveCollaboration.tabId === activeTabId)); + } + + function prepareSharedPreviewHtml(html) { + return MarkdownShareMedia.prepareHtml(html, { base: getShareApiBaseUrl(), privateContent: isEncryptedSharingActive() }); + } + function isReleaseNotesTab(tab) { return Boolean(tab && tab.kind === RELEASE_NOTES_TAB_KIND); } @@ -1059,7 +1070,6 @@ document.addEventListener("DOMContentLoaded", async function () { const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; const MANAGED_GIF_MAX_BYTES = 5 * 1024 * 1024; const MANAGED_VIDEO_MAX_BYTES = 10 * 1024 * 1024; - const MANAGED_IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; const managedImageMigrationOfferedTabIds = new Set(); const managedImageMigrationInProgressTabIds = new Set(); const EMOJI_API_URL = 'https://api.github.com/emojis'; @@ -3533,6 +3543,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function fetchDiagramSvgRequest(url, options, engine, attempts = DIAGRAM_REQUEST_RETRIES) { + if (isEncryptedSharingActive()) throw new Error('Remote rendering is disabled for encrypted shared documents.'); let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const controller = new AbortController(); @@ -14992,6 +15003,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function disposeDocumentSplitPreviewResources() { + if (documentSplitPreview) MarkdownShareMedia.release(documentSplitPreview); if (!documentSplitPreview) return; documentSplitPreview.querySelectorAll('.geojson-map, .topojson-map').forEach(function(node) { disposeMapNode(node); @@ -15023,14 +15035,17 @@ document.addEventListener("DOMContentLoaded", async function () { const referenceData = extractReferenceDefinitions(parsed.body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); disposeDocumentSplitPreviewResources(); - documentSplitPreview.innerHTML = sanitizePreviewHtml(html); + const privateContent = Boolean(tab.encryptedSharing || (liveCollaboration && liveCollaboration.encryption && liveCollaboration.tabId === tab.id)); + documentSplitPreview.innerHTML = MarkdownShareMedia.prepareHtml(sanitizePreviewHtml(html), { base: getShareApiBaseUrl(), privateContent }); + MarkdownShareMedia.resolve(documentSplitPreview, { base: getShareApiBaseUrl() }).catch(function() {}); applyReferencePreviewLinks(documentSplitPreview, referenceData.definitions); enhanceGitHubAlerts(documentSplitPreview); enhancePreviewCodeBlocks(documentSplitPreview); - processEmojis(documentSplitPreview); + if (!privateContent) processEmojis(documentSplitPreview); namespaceSplitPreviewIds(documentSplitPreview, tab.id); documentSplitPreviewTabId = tab.id; documentSplitPreviewContent = content; + if (privateContent) return; const context = { isCurrent: function() { return ( @@ -16471,6 +16486,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context) { + sanitizedHtml = prepareSharedPreviewHtml(sanitizedHtml); + const hasEncryptedMedia = sanitizedHtml.includes('data-encrypted-media=') || Boolean(markdownPreview.querySelector('[data-encrypted-media]')); + MarkdownShareMedia.release(markdownPreview); const renderedDocumentId = previewLastRenderedTabId; const renderedScrollSnapshot = captureRenderedPreviewScroll(renderedDocumentId); if (renderedScrollSnapshot) { @@ -16483,13 +16501,14 @@ document.addEventListener("DOMContentLoaded", async function () { mathJaxTypesetRunId += 1; clearMathJaxPreviewState(markdownPreview); const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { - reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, + reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force && !hasEncryptedMedia && !isEncryptedSharingActive(), }); invalidateReviewTargetSourceSnapshots( patchResult && patchResult.fullReplace ? [markdownPreview] : ((patchResult && patchResult.updatedNodes) || []) ); applyReferencePreviewLinks(markdownPreview, referenceData.definitions); enhanceGitHubAlerts(markdownPreview); + MarkdownShareMedia.resolve(markdownPreview, { base: getShareApiBaseUrl() }).catch(() => {}); _lastRenderedContent = rawVal; previewHasCommittedRender = true; @@ -17706,6 +17725,7 @@ ${selector} .arrowheadPath { } async function renderThemeSensitivePreviewContentForPrint() { + if (isEncryptedSharingActive()) return; const context = { renderId: previewRenderGeneration, previewDocumentId: getActivePreviewDocumentId(), @@ -17980,6 +18000,14 @@ ${selector} .arrowheadPath { function postProcessPreview(rawVal, context, patchResult) { const roots = getPreviewPostProcessRoots(patchResult, context); + if (isEncryptedSharingActive()) { + roots.forEach(root => { enhancePreviewCodeBlocks(root); enhanceGitHubAlerts(root); }); + refreshDocumentOutline(); + decorateReviewTargets(); + updateFindHighlights(); + scheduleLineNumberUpdate(); + return; + } // Clean up orphaned STL views that are no longer present in the document activeStlViews.forEach((view, id) => { @@ -20793,7 +20821,7 @@ ${selector} .arrowheadPath { async function requestManagedImageUploadConsent() { if (loadGlobalState().managedImageUploadAcknowledged === true) return true; const accepted = await showAppToastConfirmation( - 'Images, GIFs, and videos are uploaded to Markdown Viewer public media storage for up to 90 days. Anyone with a media URL can view it during that time.', + 'Images, GIFs, and videos are encrypted on this device before upload and kept for up to 90 days. Keep the complete media link private; it includes the key needed to open the media.', { title: 'Upload media?', confirmLabel: 'Continue', cancelLabel: 'Cancel', dedupeKey: 'managed-media-consent' } ); if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); @@ -20802,12 +20830,15 @@ ${selector} .arrowheadPath { async function uploadManagedImageDataUrl(dataUrl, endpoint) { const apiPath = endpoint === '/api/media' ? '/api/media' : '/api/image'; + MarkdownShareMedia.validateDataUrl(dataUrl); + const mediaKey = MarkdownShareCrypto.random(); + const encrypted = await MarkdownShareCrypto.seal({ dataUrl }, mediaKey, 'media', MarkdownShareCrypto.MEDIA_LIMIT); let response; try { response = await fetch(getShareApiBaseUrl() + apiPath, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dataUrl: dataUrl }) + body: JSON.stringify({ encrypted }) }); } catch (_) { throw new Error('The media file could not be uploaded. Check your connection and try again, or use External Media (URL).'); @@ -20820,7 +20851,7 @@ ${selector} .arrowheadPath { if (!response.ok) { throw new Error((payload && payload.error) || 'The media host rejected this file. Try a smaller file or use External Media (URL).'); } - if (!payload || !MANAGED_IMAGE_ID_PATTERN.test(payload.id || '')) { + if (!payload || !/^e2_[A-Za-z0-9_-]{24}$/.test(payload.id || '')) { throw new Error('The media host returned an invalid short link. Please try again.'); } @@ -20835,6 +20866,8 @@ ${selector} .arrowheadPath { if (managedUrl.origin !== expectedBase.origin || managedUrl.pathname !== apiPath + '/' + payload.id) { throw new Error('The media host returned an invalid short link. Please try again.'); } + managedUrl.search = ''; + managedUrl.hash = 'key=' + mediaKey; return managedUrl.toString(); } @@ -28847,7 +28880,7 @@ ${selector} .arrowheadPath { } }); - exportMd.addEventListener("click", function () { + exportMd.addEventListener("click", async function () { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; @@ -28856,7 +28889,8 @@ ${selector} .arrowheadPath { return; } try { - const blob = new Blob([markdownEditor.value], { + const portableMarkdown = await MarkdownShareMedia.inlineMarkdown(markdownEditor.value, getShareApiBaseUrl()); + const blob = new Blob([portableMarkdown], { type: "text/markdown;charset=utf-8", }); saveAs(blob, getExportFilename("md", "document.md")); @@ -28920,10 +28954,11 @@ ${selector} .arrowheadPath { generateHtmlExport(selectedTheme); }); - function generateHtmlExport(exportTheme = 'light') { + async function generateHtmlExport(exportTheme = 'light') { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; try { + const privateExport = isEncryptedSharingActive(); const { frontmatter, body } = parseFrontmatter(markdownEditor.value); const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : ''; const referenceData = extractReferenceDefinitions(body); @@ -28934,7 +28969,13 @@ ${selector} .arrowheadPath { ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); const tempContainer = document.createElement("div"); - tempContainer.innerHTML = sanitizedHtml; + tempContainer.innerHTML = prepareSharedPreviewHtml(sanitizedHtml); + await MarkdownShareMedia.resolve(tempContainer, { base: getShareApiBaseUrl(), inline: true }); + if (privateExport) { + const secureHtml = 'Shared document' + tempContainer.innerHTML + ''; + saveAs(new Blob([secureHtml], { type: 'text/html;charset=utf-8' }), getExportFilename('html', 'document.html')); + return; + } applyReferencePreviewLinks(tempContainer, referenceData.definitions); enhanceGitHubAlerts(tempContainer); tempContainer.querySelectorAll('.diagram-status, .diagram-toolbar, .mermaid-toolbar, .abc-toolbar').forEach(function(el) { @@ -29832,6 +29873,7 @@ ${selector} .arrowheadPath { function disposeExportRenderElement(root) { if (!root) return; + MarkdownShareMedia.release(root); root.querySelectorAll('.geojson-map, .topojson-map').forEach(disposeMapNode); root.querySelectorAll('.stl-viewer[id]').forEach(node => disposeStlView(node.id)); clearMathJaxPreviewState(root); @@ -29843,6 +29885,11 @@ ${selector} .arrowheadPath { } async function renderExportRichContent(root, markdown, exportTheme, state) { + await MarkdownShareMedia.resolve(root, { base: getShareApiBaseUrl() }); + if (isEncryptedSharingActive()) { + await waitForExportWork(state, waitForAllImages(root)); + return; + } const theme = getExportTheme(exportTheme); const context = { theme, @@ -29888,7 +29935,7 @@ ${selector} .arrowheadPath { root.className = `markdown-body pdf-export theme-${theme} export-render-root`; root.setAttribute('data-theme', theme); root.setAttribute('data-export-render-mode', mode); - root.innerHTML = html; + root.innerHTML = prepareSharedPreviewHtml(html); root.style.boxSizing = 'border-box'; root.style.position = 'fixed'; root.style.left = '-12000px'; @@ -30376,18 +30423,8 @@ ${selector} .arrowheadPath { const MAX_SHARE_URL_LENGTH = 32000; const MAX_LEGACY_SHARE_URL_LENGTH = 4096; const SERVER_SHARE_THRESHOLD_BYTES = 3000; - const SERVER_SHARE_ID_PATTERN = /^[a-z2-9]{6,20}$/; + const SERVER_SHARE_ID_PATTERN = /^(?:[a-z2-9]{6,20}|s2_[A-Za-z0-9_-]{24})$/; - function encodeMarkdownForShare(text) { - if (typeof pako === 'undefined') throw new Error('pako not loaded'); - const compressed = pako.deflate(new TextEncoder().encode(text)); - const chunkSize = 0x8000; - let binary = ''; - for (let i = 0; i < compressed.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize)); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - } function decodeMarkdownFromShare(encoded) { if (typeof pako === 'undefined') throw new Error('pako not loaded'); @@ -30431,13 +30468,14 @@ ${selector} .arrowheadPath { return cleanTitle || 'Shared Snapshot'; } - async function openShareSnapshotTab(content, title, mode) { + async function openShareSnapshotTab(content, title, mode, encrypted = false) { const shareMode = mode === 'edit' ? 'edit' : 'view'; const viewMode = shareMode === 'edit' ? 'split' : 'preview'; const snapshotTab = createTab(typeof content === 'string' ? content : '', getSafeShareSnapshotTitle(title), viewMode); snapshotTab.kind = SHARE_SNAPSHOT_TAB_KIND; snapshotTab.temporary = true; snapshotTab.shareSnapshotMode = shareMode; + snapshotTab.encryptedSharing = encrypted; tabs.push(snapshotTab); await switchTab(snapshotTab.id); applyShareSnapshotAccessMode(shareMode); @@ -30447,18 +30485,11 @@ ${selector} .arrowheadPath { return true; } - function getShareHashForMode(id, mode) { - return 'id=' + encodeURIComponent(id) + (mode === 'edit' ? '&edit=1' : ''); - } function getShareSnapshotTitleParam(title) { return '&title=' + encodeURIComponent(getSafeShareSnapshotTitle(title)); } - function shouldUseServerShare(markdownText, legacyUrl) { - const byteLength = new TextEncoder().encode(markdownText || '').length; - return byteLength >= SERVER_SHARE_THRESHOLD_BYTES || !legacyUrl || legacyUrl.length > MAX_LEGACY_SHARE_URL_LENGTH; - } // ============================================ // Share Modal @@ -30475,6 +30506,7 @@ ${selector} .arrowheadPath { const shareUrlInput = document.getElementById('share-url-input'); const shareGenerateBtn = document.getElementById('share-generate-btn'); const shareCopyBtn = document.getElementById('share-copy-btn'); + const shareDeleteBtn = document.getElementById('share-delete-btn'); const shareInviteState = document.getElementById('share-invite-state'); const shareModeView = document.getElementById('share-mode-view'); const shareModeEdit = document.getElementById('share-mode-edit'); @@ -30485,34 +30517,24 @@ ${selector} .arrowheadPath { let generatedShareSnapshotId = ''; let generatedShareSnapshotDeleteToken = ''; - function buildShareUrl(mode) { + async function buildShareUrl(mode) { generatedShareSnapshotExpiresIn = null; generatedShareSnapshotId = ''; generatedShareSnapshotDeleteToken = ''; - const markdownText = markdownEditor.value; - let encoded; - try { - encoded = encodeMarkdownForShare(markdownText); - } catch (e) { - console.error('Share encoding failed:', e); - return null; - } - const base = getPublicAppBaseUrl() + '#share=' + encoded + getShareSnapshotTitleParam(getActiveShareTitle()); - return mode === 'edit' ? base + '&edit=1' : base; + const key = MarkdownShareCrypto.random(); + const payload = { content: markdownEditor.value || '', title: getActiveShareTitle(), mode }; + const encrypted = await MarkdownShareCrypto.seal(payload, key, 'snapshot'); + const inlineUrl = getPublicAppBaseUrl() + '#share=v2.' + MarkdownShareCrypto.pack(encrypted) + '&key=' + key; + return { key, encrypted, inlineUrl, byteLength: new TextEncoder().encode(payload.content).length }; } - async function createStoredShareUrl(mode) { - const markdownText = markdownEditor.value || ''; + async function createStoredShareUrl(prepared) { const response = await fetch(getShareApiBaseUrl() + '/api/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: markdownText, - mode, - title: getActiveShareTitle() - }) + body: JSON.stringify({ encrypted: prepared.encrypted }) }); let payload = null; @@ -30523,14 +30545,14 @@ ${selector} .arrowheadPath { if (!response.ok) { throw new Error((payload && payload.error) || ('HTTP ' + response.status)); } - if (!payload || !SERVER_SHARE_ID_PATTERN.test(payload.id || '')) { + if (!payload || !/^s2_[A-Za-z0-9_-]{24}$/.test(payload.id || '')) { throw new Error('Invalid share id returned'); } generatedShareSnapshotExpiresIn = Number.isFinite(payload.expiresIn) ? payload.expiresIn : null; generatedShareSnapshotId = payload.id; generatedShareSnapshotDeleteToken = typeof payload.deleteToken === 'string' ? payload.deleteToken : ''; - return getPublicAppBaseUrl() + '#' + getShareHashForMode(payload.id, mode); + return getPublicAppBaseUrl() + '#id=' + payload.id + '&key=' + prepared.key; } function formatShareExpiry(seconds) { @@ -30562,6 +30584,7 @@ ${selector} .arrowheadPath { } function resetShareSnapshotLink() { + if (shareDeleteBtn) shareDeleteBtn.hidden = true; generatedShareSnapshotUrl = ''; generatedShareSnapshotExpiresIn = null; generatedShareSnapshotId = ''; @@ -30585,16 +30608,11 @@ ${selector} .arrowheadPath { async function createShareSnapshotLink() { const mode = getCurrentShareMode(); - const url = buildShareUrl(mode); - if (!url) { - shareUrlInput.value = 'Error generating link.'; - shareCopyBtn.disabled = true; - throw new Error('Unable to create share link'); - } - if (shouldUseServerShare(markdownEditor.value || '', url)) { - return await createStoredShareUrl(mode); + const prepared = await buildShareUrl(mode); + if (prepared.byteLength >= SERVER_SHARE_THRESHOLD_BYTES || prepared.inlineUrl.length > MAX_LEGACY_SHARE_URL_LENGTH) { + return await createStoredShareUrl(prepared); } - return url; + return prepared.inlineUrl; } function openShareModal() { @@ -30602,16 +30620,6 @@ ${selector} .arrowheadPath { if (isReleaseNotesActive()) return; if (blockTemporarySnapshotShare()) return; if (blockLiveDocumentShareSnapshot()) return; - // PERF-002: Lazy-load pako on first share - if (typeof pako === 'undefined') { - loadScript(CDN.pako).then(function() { - openShareModal(); - }).catch(function(e) { - console.error('Failed to load pako:', e); - alert('Failed to load sharing library. Please check your internet connection.'); - }); - return; - } // Reset to view-only by default each time shareModeView.checked = true; syncShareCardStyles(); @@ -30657,6 +30665,7 @@ ${selector} .arrowheadPath { const originalHTML = shareGenerateBtn.innerHTML; shareGenerateBtn.disabled = true; shareCopyBtn.disabled = true; + if (shareDeleteBtn) shareDeleteBtn.hidden = true; shareGenerateBtn.textContent = 'Sharing...'; if (shareInviteSection) { shareInviteSection.hidden = false; @@ -30669,6 +30678,7 @@ ${selector} .arrowheadPath { generatedShareSnapshotUrl = url; shareUrlInput.value = url; shareCopyBtn.disabled = false; + if (shareDeleteBtn) shareDeleteBtn.hidden = !generatedShareSnapshotId || !generatedShareSnapshotDeleteToken; if (shareInviteSection) { shareInviteSection.dataset.state = 'active'; } @@ -30712,6 +30722,24 @@ ${selector} .arrowheadPath { } }); + if (shareDeleteBtn) shareDeleteBtn.addEventListener('click', async function() { + const id = generatedShareSnapshotId, deleteToken = generatedShareSnapshotDeleteToken; + if (!id || !deleteToken || shareDeleteBtn.disabled) return; + shareDeleteBtn.disabled = true; + try { + const accepted = await showAppToastConfirmation('Delete this stored snapshot? Its link will stop working. Downloaded copies are unaffected.', + { title: 'Delete snapshot', confirmLabel: 'Delete', cancelLabel: 'Cancel' }); + if (!accepted) return; + const response = await fetch(getShareApiBaseUrl() + '/api/share/' + encodeURIComponent(id), { + method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deleteToken }) + }); + if (!response.ok) throw new Error('Deletion failed'); + if (generatedShareSnapshotId === id) resetShareSnapshotLink(); + showAppToast('Snapshot deleted.'); + } catch (_) { showShareErrorModal('The snapshot could not be deleted. Please try again.'); } + finally { shareDeleteBtn.disabled = false; } + }); + shareModalCloseX.addEventListener('click', closeShareModal); shareModalClose.addEventListener('click', closeShareModal); shareModal.addEventListener('click', function (e) { @@ -30928,6 +30956,7 @@ ${selector} .arrowheadPath { function getCurrentLiveAccessMode() { if (!liveCollaboration) return getSelectedLiveAccessMode(); + if (liveCollaboration.encryption && liveCollaboration.socketRole === 'view') return LIVE_SHARE_ACCESS_VIEW; return normalizeLiveAccessMode(liveCollaboration.accessMode || (liveCollaboration.sessionMap && liveCollaboration.sessionMap.get('accessMode'))); } @@ -31226,10 +31255,11 @@ ${selector} .arrowheadPath { function getLiveRoomSocketUrl(roomId, secret, auth) { const encodedRoom = encodeURIComponent(roomId); const params = new URLSearchParams(); - params.set('secret', secret); - if (auth && auth.role) params.set('role', normalizeLiveSocketRole(auth.role)); - if (auth && auth.capability) params.set('cap', auth.capability); - if (auth && auth.capabilities) { + if (auth && auth.encrypted) params.set('v', '2'); + else params.set('secret', secret); + if (auth && !auth.encrypted && auth.role) params.set('role', normalizeLiveSocketRole(auth.role)); + if (auth && !auth.encrypted && auth.capability) params.set('cap', auth.capability); + if (auth && !auth.encrypted && auth.capabilities) { params.set('hostCap', auth.capabilities.host || ''); params.set('editCap', auth.capabilities.edit || ''); params.set('viewCap', auth.capabilities.view || ''); @@ -31282,6 +31312,13 @@ ${selector} .arrowheadPath { } function createLiveRoomConnection(options) { + if (options.encryption) { + return MarkdownShareLive.connect(Object.assign({}, options, { + socketUrl: getLiveRoomSocketUrl(options.roomId, '', { encrypted: true }), + relayOrigin: LIVE_RELAY_ORIGIN, + reviewRelayOrigin: LIVE_REVIEW_RELAY_ORIGIN + })); + } const pendingMessages = []; const socketUrl = getLiveRoomSocketUrl(options.roomId, options.secret, { role: options.role, @@ -31461,7 +31498,7 @@ ${selector} .arrowheadPath { return (normalized || 'Live Share').slice(0, 120); } - function buildLiveInviteUrl(roomId, secret, title, accessMode, capabilities) { + function buildLiveInviteUrl(roomId, secret, title, accessMode, capabilities, encryption) { const isLocal = window.location.origin.includes('localhost') || window.location.origin.startsWith('file://') || typeof Neutralino !== 'undefined'; @@ -31470,6 +31507,14 @@ ${selector} .arrowheadPath { ? 'https://markdownviewer.pages.dev/' : window.location.origin + window.location.pathname; + if (encryption) { + const role = normalizeLiveAccessMode(accessMode); + if (!capabilities || !encryption.signing) return ''; + const params = new URLSearchParams({ live: roomId, v: '2', key: encryption.key, + sig: encryption.signing[role], pub: MarkdownShareCrypto.pack(encryption.config, 4096), + access: role, role, cap: capabilities[role] }); + return baseUrl + '#' + params.toString(); + } const base = baseUrl + '#live=' + encodeURIComponent(roomId + '.' + secret); const params = []; const normalizedAccess = normalizeLiveAccessMode(accessMode); @@ -31491,6 +31536,16 @@ ${selector} .arrowheadPath { function parseLiveHash() { const hash = window.location.hash || ''; if (!hash.startsWith('#live=')) return null; + const encryptedParams = new URLSearchParams(hash.slice(1)); + if (encryptedParams.has('v')) { + if (encryptedParams.get('v') !== '2' || hash.length > 8192) throw new Error('Unsupported encrypted invite.'); + const roomId = encryptedParams.get('live'); + const role = encryptedParams.get('role'); + if (!/^room2_[A-Za-z0-9_-]{24}$/.test(roomId || '') || !['view', 'edit'].includes(role)) throw new Error('Invalid encrypted invite.'); + const config = MarkdownShareCrypto.publicConfig(MarkdownShareCrypto.unpack(encryptedParams.get('pub'), 4096), roomId); + return { roomId, secret: '', title: 'Live Share', role, accessMode: role, capability: encryptedParams.get('cap'), + encryption: { key: encryptedParams.get('key'), config, signingKey: encryptedParams.get('sig') } }; + } const rest = hash.slice('#live='.length); const paramsIndex = rest.indexOf('&'); const roomSecretPart = decodeURIComponent(paramsIndex === -1 ? rest : rest.slice(0, paramsIndex)); @@ -31550,7 +31605,8 @@ ${selector} .arrowheadPath { liveCollaboration.secret, safeTitle, liveCollaboration.accessMode, - liveCollaboration.capabilities + liveCollaboration.capabilities, + liveCollaboration.encryption ); } if (liveCollaboration.tabId) { @@ -31696,9 +31752,10 @@ ${selector} .arrowheadPath { const liveTab = createTab(typeof markdown === 'string' ? markdown : '', getSafeLiveTitle(liveCollaboration.roomTitle), 'split'); liveTab.kind = 'live-share'; liveTab.temporary = true; + liveTab.encryptedSharing = Boolean(liveCollaboration.encryption); tabs.push(liveTab); - switchTab(liveTab.id); liveCollaboration.tabId = liveTab.id; + switchTab(liveTab.id); liveCollaboration.pendingJoinTab = false; liveCollaboration.removeTabOnLeave = true; if (liveCollaboration.joinTimeoutId) { @@ -32301,6 +32358,7 @@ ${selector} .arrowheadPath { if (!sender || sender === liveCollaboration.localParticipantId) return; if (message.type === 'sync-request') { + if (transport.publishControlState) transport.publishControlState(); transport.publishCurrentState(); transport.publishPresence(liveCollaboration.localCursor); return; @@ -32311,6 +32369,12 @@ ${selector} .arrowheadPath { return; } + if (message.type === 'control-state' && liveCollaboration.encryption && message.role === 'host' && message.update) { + try { liveCollaboration.Y.applyUpdate(liveCollaboration.controlDoc, decodeLiveBytes(message.update), LIVE_RELAY_ORIGIN); } + catch (_) { setLiveShareStatus('Unable to apply encrypted room controls.', 'error'); } + return; + } + if ((message.type === 'y-update' || message.type === 'sync-state') && message.update) { const hostParticipantId = liveCollaboration.sessionMap ? liveCollaboration.sessionMap.get('hostId') : null; if (getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_VIEW && hostParticipantId && sender !== hostParticipantId) { @@ -32409,6 +32473,9 @@ ${selector} .arrowheadPath { if (liveCollaboration.reviewDoc && typeof liveCollaboration.reviewDoc.destroy === 'function') { liveCollaboration.reviewDoc.destroy(); } + if (liveCollaboration.controlDoc && liveCollaboration.controlDoc !== liveCollaboration.ydoc && typeof liveCollaboration.controlDoc.destroy === 'function') { + liveCollaboration.controlDoc.destroy(); + } } catch (error) { console.warn('Failed to fully close live session:', error); } @@ -32437,7 +32504,7 @@ ${selector} .arrowheadPath { } } - function endLiveSessionForEveryone() { + async function endLiveSessionForEveryone() { if (!liveCollaboration) return; if (!liveCollaboration.isHost) { leaveLiveSession({ restoreOriginal: true }); @@ -32452,7 +32519,7 @@ ${selector} .arrowheadPath { endingSession.sessionMap.set('endedBy', endingSession.localParticipantId); }, LIVE_EDIT_ORIGIN); if (endingSession.connection) { - endingSession.connection.send({ type: 'session-end' }); + await endingSession.connection.send({ type: 'session-end' }); } } catch (error) { console.warn('Failed to broadcast live session end:', error); @@ -32469,15 +32536,17 @@ ${selector} .arrowheadPath { async function startLiveSession(options) { options = options || {}; const isHost = options.isHost !== false; - const roomId = options.roomId || ('room-' + getRandomBase64Url(9)); - const secret = options.secret || ('secret-' + getRandomBase64Url(32)); + const roomId = options.roomId || ('room2_' + getRandomBase64Url(18)); + const encryption = isHost ? await MarkdownShareCrypto.createRoom(roomId) : options.encryption || null; + const secret = encryption ? '' : options.secret; const displayName = getLiveDisplayName({ forceGeneratedName: options.forceGeneratedName === true }); const modules = await loadLiveCollaborationModules(); const ydoc = new modules.Y.Doc(); const yText = ydoc.getText('markdown'); - const sessionMap = ydoc.getMap('session'); + const controlDoc = encryption ? new modules.Y.Doc() : ydoc; + const sessionMap = controlDoc.getMap('session'); const reviewDoc = new modules.Y.Doc(); const reviewMap = reviewDoc.getMap('reviewThreads'); const hostActiveTab = tabs.find(function(t) { return t.id === activeTabId; }); @@ -32509,7 +32578,7 @@ ${selector} .arrowheadPath { const liveCapability = isHost ? liveCapabilities.host : (options.capability || ''); - const inviteUrl = buildLiveInviteUrl(roomId, secret, hostTitle, requestedAccessMode, liveCapabilities); + const inviteUrl = buildLiveInviteUrl(roomId, secret, hostTitle, requestedAccessMode, liveCapabilities, encryption); if (isHost && yText.length === 0) { yText.insert(0, markdownEditor.value || ''); } @@ -32553,6 +32622,10 @@ ${selector} .arrowheadPath { } const localParticipantId = 'participant-' + getRandomBase64Url(12); + if (encryption) { + encryption.transport = await MarkdownShareCrypto.liveSession({ roomId, sender: localParticipantId, role: liveRole, + key: encryption.key, config: encryption.config, signingKey: isHost ? encryption.signing.host : encryption.signingKey }); + } const localColor = getLiveParticipantColor(displayName + '-' + getRandomBase64Url(2)); const localParticipant = { name: displayName, @@ -32570,6 +32643,8 @@ ${selector} .arrowheadPath { liveCollaboration = { roomId, secret, + encryption, + controlDoc, inviteUrl, ydoc, Y: modules.Y, @@ -32674,6 +32749,8 @@ ${selector} .arrowheadPath { liveCollaboration.connection = createLiveRoomConnection({ roomId, secret, + encryption, + controlDoc, role: liveCollaboration.socketRole, capability: liveCollaboration.socketCapability, capabilities: liveCollaboration.isHost ? liveCollaboration.capabilities : null, @@ -32718,6 +32795,17 @@ ${selector} .arrowheadPath { publishLocalParticipant(); liveCollaboration.participantHeartbeatId = setInterval(publishLocalParticipant, 15000); + if (encryption) { + const activeSession = liveCollaboration; + try { await activeSession.connection.ready; } + catch (error) { + if (liveCollaboration === activeSession) disconnectLiveCollaboration({ restoreOriginal: true }); + throw error; + } + if (liveCollaboration !== activeSession) return; + if (activeSession.tabId) renderMarkdown({ reason: 'live-encrypted', force: true }); + } + if (liveShareUrlInput) { liveShareUrlInput.value = inviteUrl; } @@ -32787,7 +32875,9 @@ ${selector} .arrowheadPath { } async function loadFromLiveHash() { - const parsed = parseLiveHash(); + let parsed; + try { parsed = parseLiveHash(); } + catch (_) { alert('The encrypted invite is incomplete or unsupported.'); return; } if (!parsed) return; try { const originalTabSnapshot = captureLiveTabSnapshot(); @@ -32807,6 +32897,7 @@ ${selector} .arrowheadPath { accessMode: parsed.accessMode, role: parsed.role, capability: parsed.capability, + encryption: parsed.encryption, isHost: false, forceGeneratedName: true, originalTabSnapshot, @@ -32970,6 +33061,14 @@ ${selector} .arrowheadPath { return; } + if (id.startsWith('s2_')) { + const params = new URLSearchParams(ampIdx === -1 ? '' : rest.slice(ampIdx + 1)); + const decrypted = await MarkdownShareCrypto.open(payload && payload.encrypted, params.get('key'), 'snapshot'); + if (!decrypted || typeof decrypted.content !== 'string' || typeof decrypted.title !== 'string' || + !['view', 'edit'].includes(decrypted.mode)) throw new Error('Invalid encrypted snapshot.'); + await openShareSnapshotTab(decrypted.content, decrypted.title, decrypted.mode, true); + return; + } const shareMode = payload && payload.mode === 'edit' ? 'edit' : 'view'; await openShareSnapshotTab( payload && typeof payload.content === 'string' ? payload.content : '', @@ -32977,8 +33076,7 @@ ${selector} .arrowheadPath { shareMode ); } catch (e) { - console.error('Failed to load stored shared content:', e); - alert('Network error while loading shared content.'); + alert(id.startsWith('s2_') ? 'Unable to open the encrypted snapshot. Check the complete link and connection.' : 'Network error while loading shared content.'); } } @@ -32990,6 +33088,18 @@ ${selector} .arrowheadPath { } if (!hash.startsWith('#share=')) return; + if (hash.startsWith('#share=v2.')) { + try { + const params = new URLSearchParams(hash.slice(1)); + const encrypted = MarkdownShareCrypto.unpack(params.get('share').slice(3), MAX_SHARE_URL_LENGTH); + const payload = await MarkdownShareCrypto.open(encrypted, params.get('key'), 'snapshot'); + if (!payload || typeof payload.content !== 'string' || typeof payload.title !== 'string' || + !['view', 'edit'].includes(payload.mode)) throw new Error('Invalid encrypted snapshot.'); + await openShareSnapshotTab(payload.content, payload.title, payload.mode, true); + } catch (_) { alert('Unable to open the encrypted snapshot. Check that the complete link is present.'); } + return; + } + // PERF-002: Lazy-load pako when loading shared URL content if (typeof pako === 'undefined') { loadScript(CDN.pako).then(function() { diff --git a/scripts/build-sharing.mjs b/scripts/build-sharing.mjs new file mode 100644 index 00000000..e6277792 --- /dev/null +++ b/scripts/build-sharing.mjs @@ -0,0 +1,7 @@ +import { build } from 'esbuild'; +import { readFileSync, writeFileSync } from 'node:fs'; + +await build({ entryPoints: ['yjs'], bundle: true, format: 'esm', platform: 'browser', + outfile: 'assets/yjs.mjs', minify: true, legalComments: 'inline' }); +writeFileSync('assets/yjs.LICENSE.txt', 'Yjs\n' + readFileSync('node_modules/yjs/LICENSE', 'utf8') + + '\nlib0\n' + readFileSync('node_modules/lib0/LICENSE', 'utf8')); diff --git a/sharing-crypto.js b/sharing-crypto.js new file mode 100644 index 00000000..76e68f23 --- /dev/null +++ b/sharing-crypto.js @@ -0,0 +1,207 @@ +/* Shared browser/Worker protocol. No content keys are held by the service. */ +(function(root) { + 'use strict'; + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + const SNAPSHOT_LIMIT = 8 * 1024 * 1024; + const MEDIA_LIMIT = 15 * 1024 * 1024; + const LIVE_LIMIT = 8 * 1024 * 1024; + const MAX_SEQUENCE = 0xffffffff; + const TOKEN = /^[A-Za-z0-9_-]{16,160}$/; + const TYPES = ['hello', 'presence', 'sync-request', 'sync-state', 'y-update', + 'review-sync-request', 'review-sync-state', 'review-update', 'control-state', 'leave', 'session-end']; + + function cryptoApi() { + if (!root.crypto || !root.crypto.subtle) throw new Error('Encrypted sharing requires a secure connection and Web Crypto.'); + return root.crypto; + } + function encode(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + function decode(value, limit) { + if (typeof value !== 'string' || !value || value.length > Math.ceil(limit * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error('Invalid encrypted sharing data.'); + } + const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')); + if (binary.length > limit) throw new Error('Encrypted sharing data is too large.'); + const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0)); + if (encode(bytes) !== value) throw new Error('Non-canonical encrypted sharing data.'); + return bytes; + } + function random(bytes = 32) { return encode(cryptoApi().getRandomValues(new Uint8Array(bytes))); } + function jsonBytes(value, limit) { + const bytes = encoder.encode(JSON.stringify(value)); + if (bytes.length > limit) throw new Error('The content is too large for encrypted sharing.'); + return bytes; + } + function exactKeys(value, keys) { + return value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + } + function envelope(value, limit) { + if (!exactKeys(value, ['v', 'alg', 'iv', 'ct']) || value.v !== 2 || value.alg !== 'A256GCM' || + typeof value.iv !== 'string' || value.iv.length !== 16 || typeof value.ct !== 'string' || + value.ct.length < 23 || value.ct.length > Math.ceil((limit + 16) * 4 / 3) || + !/^[A-Za-z0-9_-]+$/.test(value.iv) || !/^[A-Za-z0-9_-]+$/.test(value.ct)) { + throw new Error('A supported encrypted sharing envelope is required.'); + } + return { v: 2, alg: 'A256GCM', iv: value.iv, ct: value.ct }; + } + async function importAes(key) { + const bytes = decode(key, 32); + if (bytes.length !== 32) throw new Error('The share key is missing or invalid.'); + return cryptoApi().subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt', 'decrypt']); + } + async function seal(value, key, purpose, limit = SNAPSHOT_LIMIT) { + const bytes = jsonBytes(value, limit); + const iv = cryptoApi().getRandomValues(new Uint8Array(12)); + const ct = await cryptoApi().subtle.encrypt({ name: 'AES-GCM', iv, + additionalData: encoder.encode('markdown-viewer:v2:' + purpose), tagLength: 128 }, + await importAes(key), bytes); + return { v: 2, alg: 'A256GCM', iv: encode(iv), ct: encode(new Uint8Array(ct)) }; + } + async function open(value, key, purpose, limit = SNAPSHOT_LIMIT) { + const record = envelope(value, limit); + try { + const bytes = await cryptoApi().subtle.decrypt({ name: 'AES-GCM', iv: decode(record.iv, 12), + additionalData: encoder.encode('markdown-viewer:v2:' + purpose), tagLength: 128 }, + await importAes(key), decode(record.ct, limit + 16)); + if (bytes.byteLength > limit) throw new Error(); + return JSON.parse(decoder.decode(bytes)); + } catch (_) { throw new Error('Unable to decrypt this share. Check that the complete link is present.'); } + } + function pack(value, limit = SNAPSHOT_LIMIT * 2) { return encode(jsonBytes(value, limit)); } + function unpack(value, limit = SNAPSHOT_LIMIT * 2) { return JSON.parse(decoder.decode(decode(value, limit))); } + async function hash(value) { return encode(new Uint8Array(await cryptoApi().subtle.digest('SHA-256', encoder.encode(value)))); } + function canSend(role, type) { + if (!TYPES.includes(type)) return false; + if (role === 'host') return true; + if (role !== 'edit' && role !== 'view') return false; + if (['session-end', 'control-state', 'review-sync-state'].includes(type)) return false; + return role === 'edit' || !['sync-state', 'y-update'].includes(type); + } + async function signingPair() { + const pair = await cryptoApi().subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + return { + privateKey: encode(new Uint8Array(await cryptoApi().subtle.exportKey('pkcs8', pair.privateKey))), + publicKey: encode(new Uint8Array(await cryptoApi().subtle.exportKey('raw', pair.publicKey))) + }; + } + function publicConfig(config, roomId) { + if (!exactKeys(config, ['v', 'roomId', 'expiresAt', 'host', 'edit', 'view']) || config.v !== 2 || + config.roomId !== roomId || typeof roomId !== 'string' || !TOKEN.test(roomId) || + !Number.isSafeInteger(config.expiresAt) || config.expiresAt <= 0 || + !['host', 'edit', 'view'].every(role => typeof config[role] === 'string' && decode(config[role], 65).length === 65)) { + throw new Error('Invalid encrypted room configuration.'); + } + return { v: 2, roomId, expiresAt: config.expiresAt, host: config.host, edit: config.edit, view: config.view }; + } + async function createRoom(roomId) { + const host = await signingPair(), edit = await signingPair(), view = await signingPair(); + return { key: random(), config: { v: 2, roomId, expiresAt: Date.now() + 86400000, host: host.publicKey, edit: edit.publicKey, view: view.publicKey }, + signing: { host: host.privateKey, edit: edit.privateKey, view: view.privateKey } }; + } + function header(frame) { + return JSON.stringify(['markdown-viewer:live', 2, frame.roomId, frame.sender, frame.session, frame.seq, frame.type, frame.role]); + } + function signedBytes(frame) { return encoder.encode(JSON.stringify([header(frame), frame.iv, frame.ct])); } + function liveFrame(frame) { + if (!exactKeys(frame, ['v', 'roomId', 'sender', 'session', 'seq', 'type', 'role', 'iv', 'ct', 'sig']) || + frame.v !== 2 || ![frame.roomId, frame.sender, frame.session].every(value => typeof value === 'string' && TOKEN.test(value)) || + !Number.isSafeInteger(frame.seq) || frame.seq < 1 || frame.seq > MAX_SEQUENCE || !canSend(frame.role, frame.type)) { + throw new Error('Invalid encrypted live frame.'); + } + envelope({ v: 2, alg: 'A256GCM', iv: frame.iv, ct: frame.ct }, LIVE_LIMIT); + if (typeof frame.sig !== 'string' || decode(frame.sig, 64).length !== 64) throw new Error('Invalid live signature.'); + return frame; + } + async function verifyFrame(frame, config) { + liveFrame(frame); + publicConfig(config, frame.roomId); + const key = await cryptoApi().subtle.importKey('raw', decode(config[frame.role], 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + if (!await cryptoApi().subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, decode(frame.sig, 64), signedBytes(frame))) { + throw new Error('Invalid live signature.'); + } + return true; + } + async function liveSession(options) { + const config = publicConfig(options.config, options.roomId); + if (config.expiresAt <= Date.now()) throw new Error('This encrypted room has expired.'); + const rootBytes = decode(options.key, 32); + if (rootBytes.length !== 32 || typeof options.sender !== 'string' || !TOKEN.test(options.sender)) throw new Error('Invalid encrypted room key.'); + const rootKey = await cryptoApi().subtle.importKey('raw', rootBytes, 'HKDF', false, ['deriveKey']); + const signer = await cryptoApi().subtle.importKey('pkcs8', decode(options.signingKey, 256), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + const verifier = await cryptoApi().subtle.importKey('raw', decode(config[options.role], 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + const challenge = encoder.encode('markdown-viewer:live:role-check'); + const proof = await cryptoApi().subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signer, challenge); + if (!await cryptoApi().subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifier, proof, challenge)) throw new Error('Invalid live role key.'); + const session = random(16); + let sequence = 0; + const received = new Map(); + async function derive(frame) { + return cryptoApi().subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: decode(frame.session, 32), + info: encoder.encode(JSON.stringify(['markdown-viewer:live:v2', frame.roomId, frame.sender])) }, + rootKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + const ownKey = await derive({ session, roomId: options.roomId, sender: options.sender }); + return { + session, + async encrypt(message) { + if (!canSend(options.role, message.type) || sequence >= MAX_SEQUENCE) throw new Error('Live message is not permitted.'); + const frame = { v: 2, roomId: options.roomId, sender: options.sender, session, + seq: ++sequence, type: message.type, role: options.role }; + // A fresh session has a fresh HKDF key. Its counter IV is never reused. + const iv = new Uint8Array(12); + new DataView(iv.buffer).setUint32(8, frame.seq); + const ciphertext = await cryptoApi().subtle.encrypt({ name: 'AES-GCM', iv, + additionalData: encoder.encode(header(frame)), tagLength: 128 }, ownKey, jsonBytes(message, LIVE_LIMIT)); + frame.iv = encode(iv); + frame.ct = encode(new Uint8Array(ciphertext)); + frame.sig = encode(new Uint8Array(await cryptoApi().subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signer, signedBytes(frame)))); + // Detect a mismatched/tampered invitation before sending any room data. + await verifyFrame(frame, config); + return frame; + }, + async decrypt(frame) { + if (frame.roomId !== options.roomId) throw new Error('Wrong live room.'); + await verifyFrame(frame, config); + const peer = frame.role + ':' + frame.sender + ':' + frame.session; + if (frame.seq <= (received.get(peer) || 0)) throw new Error('Replayed live frame.'); + if (!received.has(peer) && received.size >= 1024) throw new Error('Too many live sessions.'); + const plaintext = await cryptoApi().subtle.decrypt({ name: 'AES-GCM', iv: decode(frame.iv, 12), + additionalData: encoder.encode(header(frame)), tagLength: 128 }, await derive(frame), decode(frame.ct, LIVE_LIMIT + 16)); + const message = JSON.parse(decoder.decode(plaintext)); + if (!message || message.type !== frame.type) throw new Error('Wrong live channel.'); + received.set(peer, frame.seq); + return Object.assign({}, message, { sender: frame.sender, roomId: frame.roomId, role: frame.role }); + } + }; + } + async function readJson(request, limit) { + if (!request.body || Number(request.headers.get('Content-Length')) > limit) throw new Error('Request is too large.'); + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > limit) { await reader.cancel(); throw new Error('Request is too large.'); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return JSON.parse(decoder.decode(bytes)); + } + const api = { SNAPSHOT_LIMIT, MEDIA_LIMIT, LIVE_LIMIT, MAX_SEQUENCE, encode, decode, random, pack, unpack, + seal, open, envelope, exactKeys, hash, canSend, publicConfig, createRoom, liveFrame, verifyFrame, liveSession, readJson }; + root.MarkdownShareCrypto = Object.freeze(api); + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(globalThis); diff --git a/sharing-live.js b/sharing-live.js new file mode 100644 index 00000000..ed50ed6a --- /dev/null +++ b/sharing-live.js @@ -0,0 +1,129 @@ +(function(root) { + 'use strict'; + function connect(options) { + const C = root.MarkdownShareCrypto; + const cryptoSession = options.encryption.transport; + const socket = new WebSocket(options.socketUrl); + let opened = false, destroyed = false, failed = false; + let sendChain = Promise.resolve(), receiveChain = Promise.resolve(); + let queuedBytes = 0, queuedCount = 0, incomingBytes = 0, incomingCount = 0; + const waiting = []; + let resolveReady, rejectReady; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + // A rejected connection is also reported through onStatus; callers may await ready. + ready.catch(() => {}); + const timer = setTimeout(() => fail('Encrypted room connection timed out.'), 8000); + function fail(message) { + if (destroyed || failed) return; + failed = true; + clearTimeout(timer); + waiting.length = 0; + options.onStatus(message, 'error'); + rejectReady(new Error(message)); + socket.close(); + } + function send(message) { + if (destroyed || failed || !C.canSend(options.role, message.type)) return; + let copy; + try { copy = JSON.stringify(message); } catch (_) { fail('Unable to encrypt a live update.'); return; } + if (copy.length > C.LIVE_LIMIT || queuedCount >= 100 || queuedBytes + copy.length > C.LIVE_LIMIT * 2) { + fail('Live update queue is full. Rejoin the room to synchronize.'); return; + } + queuedCount += 1; queuedBytes += copy.length; + sendChain = sendChain.then(async () => { + if (destroyed || failed) return; + const frame = JSON.stringify(await cryptoSession.encrypt(JSON.parse(copy))); + if (destroyed || failed) return; + if (opened) { + if (socket.bufferedAmount > C.LIVE_LIMIT * 2) throw new Error('Backpressure'); + socket.send(frame); + } else { + if (waiting.length >= 100 || waiting.reduce((n, value) => n + value.length, 0) + frame.length > C.LIVE_LIMIT * 2) throw new Error('Backpressure'); + waiting.push(frame); + } + }).catch(() => fail('Unable to send an encrypted live update. Rejoin the room.')) + .finally(() => { queuedCount -= 1; queuedBytes -= copy.length; }); + return sendChain; + } + function publishCurrentState() { + if (options.canPublishState && !options.canPublishState()) return; + send({ type: 'sync-state', update: C.encode(options.Y.encodeStateAsUpdate(options.ydoc)) }); + } + function publishReviewState() { + if (!options.reviewDoc || (options.canPublishReviewState && !options.canPublishReviewState())) return; + send({ type: 'review-sync-state', update: C.encode(options.Y.encodeStateAsUpdate(options.reviewDoc)) }); + } + function publishControlState() { + if (options.role === 'host') send({ type: 'control-state', update: C.encode(options.Y.encodeStateAsUpdate(options.controlDoc)) }); + } + function publishPresence(cursor) { + send({ type: 'presence', participant: options.getParticipant(), cursor: cursor || null }); + } + const transport = { publishCurrentState, publishReviewState, publishControlState, publishPresence }; + const update = (bytes, origin) => { + if (origin === options.relayOrigin || (options.canSendUpdate && !options.canSendUpdate())) return; + send({ type: 'y-update', update: C.encode(bytes) }); + }; + const reviewUpdate = (bytes, origin) => { + if (origin === options.reviewRelayOrigin || (options.canSendReviewUpdate && !options.canSendReviewUpdate())) return; + send({ type: 'review-update', update: C.encode(bytes) }); + }; + options.ydoc.on('update', update); + options.reviewDoc.on('update', reviewUpdate); + options.controlDoc.on('update', publishControlState); + socket.addEventListener('open', () => { + if (destroyed || failed) return; + socket.send(JSON.stringify({ v: 2, type: 'authenticate', role: options.role, + capability: options.capability, capabilities: options.role === 'host' ? options.capabilities : undefined, + sender: options.participantId, session: cryptoSession.session, config: options.encryption.config })); + }); + socket.addEventListener('message', event => { + if (destroyed || failed) return; + if (typeof event.data !== 'string' || event.data.length > Math.ceil((C.LIVE_LIMIT + 16) * 4 / 3) + 4096 || + incomingCount >= 100 || incomingBytes + event.data.length > C.LIVE_LIMIT * 3) { + fail('Encrypted room receive limit exceeded.'); return; + } + incomingCount += 1; incomingBytes += event.data.length; + receiveChain = receiveChain.then(async () => { + if (destroyed || failed) return; + const frame = JSON.parse(event.data); + if (!opened && frame.type === 'ready' && frame.v === 2 && Object.keys(frame).length === 2) { + opened = true; + clearTimeout(timer); + waiting.splice(0).forEach(value => socket.send(value)); + send({ type: 'hello', participant: options.getParticipant() }); + send({ type: 'sync-request' }); + send({ type: 'review-sync-request' }); + publishControlState(); publishCurrentState(); publishReviewState(); publishPresence(options.getCursor()); + options.onStatus('Connected to encrypted live room', 'connected'); + resolveReady(); + return; + } + if (!opened) throw new Error('Authentication required'); + const message = await cryptoSession.decrypt(frame); + if (destroyed || failed || message.sender === options.participantId) return; + options.onMessage(message, transport); + }).catch(() => fail('An encrypted live message could not be verified. Rejoin with the complete invite link.')) + .finally(() => { incomingCount -= 1; incomingBytes -= event.data.length; }); + }); + socket.addEventListener('close', () => fail('Live room disconnected.')); + socket.addEventListener('error', () => fail('Encrypted live room connection failed.')); + return Object.assign({ ready, send, destroy() { + if (destroyed) return; + destroyed = true; + clearTimeout(timer); + rejectReady(new Error('Live room closed.')); + options.ydoc.off('update', update); + options.reviewDoc.off('update', reviewUpdate); + options.controlDoc.off('update', publishControlState); + waiting.length = 0; + // Complete queued cryptography before the signed leave so sequence numbers stay ordered. + sendChain.then(async () => { + if (opened && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(await cryptoSession.encrypt({ type: 'leave' }))); + } + }).catch(() => {}).finally(() => socket.close()); + } }, transport); + } + root.MarkdownShareLive = Object.freeze({ connect }); +})(globalThis); diff --git a/sharing-media.js b/sharing-media.js new file mode 100644 index 00000000..d8366e59 --- /dev/null +++ b/sharing-media.js @@ -0,0 +1,139 @@ +/* Client-only encrypted media resolution. Keys are removed before fetch. */ +(function(root) { + 'use strict'; + const states = new WeakMap(); + const MEDIA_URL = /https?:\/\/[^\s<>"')]+\/api\/(?:image|media)\/e2_[A-Za-z0-9_-]{24}#key=[A-Za-z0-9_-]{43}/g; + function reference(value, base) { + try { + const url = new URL(value, base); + const expected = new URL(base); + if (url.origin !== expected.origin || url.search || !/^\/api\/(?:image|media)\/e2_[A-Za-z0-9_-]{24}$/.test(url.pathname) || + !/^#key=[A-Za-z0-9_-]{43}$/.test(url.hash)) return null; + return { url: url.origin + url.pathname, key: url.hash.slice(5) }; + } catch (_) { return null; } + } + function validateDataUrl(dataUrl) { + if (typeof dataUrl !== 'string' || dataUrl.length > 14_100_000) throw new Error('Invalid encrypted media.'); + const match = /^data:((?:image\/(?:avif|bmp|gif|jpeg|png|webp))|(?:video\/(?:mp4|ogg|webm)));base64,([A-Za-z0-9+/]+={0,2})$/.exec(dataUrl); + if (!match) throw new Error('Unsupported encrypted media type.'); + const mime = match[1]; + const limit = mime.startsWith('video/') ? 10 * 1024 * 1024 : mime === 'image/gif' ? 5 * 1024 * 1024 : 300 * 1024; + if (match[2].length > Math.ceil(limit / 3) * 4) throw new Error('Encrypted media is too large.'); + const bytes = Uint8Array.from(atob(match[2]), ch => ch.charCodeAt(0)); + const ascii = (a, b) => String.fromCharCode.apply(null, bytes.subarray(a, b)); + const prefix = values => values.every((value, index) => bytes[index] === value); + const valid = { + 'image/png': () => prefix([137, 80, 78, 71, 13, 10, 26, 10]), + 'image/jpeg': () => prefix([255, 216, 255]), + 'image/gif': () => ['GIF87a', 'GIF89a'].includes(ascii(0, 6)), + 'image/webp': () => ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP', + 'image/bmp': () => ascii(0, 2) === 'BM', + 'image/avif': () => ascii(4, 8) === 'ftyp' && ['avif', 'avis'].includes(ascii(8, 12)), + 'video/mp4': () => ascii(4, 8) === 'ftyp', + 'video/ogg': () => ascii(0, 4) === 'OggS', + 'video/webm': () => prefix([26, 69, 223, 163]) + }[mime]; + if (!bytes.length || bytes.length > limit || !valid()) throw new Error('Invalid encrypted media bytes.'); + return { mime, bytes }; + } + async function decrypt(ref, signal) { + const response = await fetch(ref.url, { signal, cache: 'no-store', referrerPolicy: 'no-referrer', credentials: 'omit' }); + if (!response.ok) throw new Error('Encrypted media is unavailable or expired.'); + const body = await MarkdownShareCrypto.readJson(response, Math.ceil((MarkdownShareCrypto.MEDIA_LIMIT + 16) * 4 / 3) + 1024); + const payload = await MarkdownShareCrypto.open(body.encrypted, ref.key, 'media', MarkdownShareCrypto.MEDIA_LIMIT); + if (!MarkdownShareCrypto.exactKeys(payload, ['dataUrl'])) throw new Error('Invalid encrypted media.'); + const parsed = validateDataUrl(payload.dataUrl); + return { dataUrl: payload.dataUrl, mime: parsed.mime, bytes: parsed.bytes }; + } + function prepareHtml(html, options) { + const template = document.createElement('template'); + template.innerHTML = html; + const content = template.content; + // Template contents are inert: remove remote URLs before nodes enter a live document. + content.querySelectorAll('img, video, source').forEach(node => { + const value = node.getAttribute('src'); + const ref = reference(value || '', options.base); + if (ref) { + node.removeAttribute('src'); + node.removeAttribute('srcset'); + node.setAttribute('data-encrypted-media', MarkdownShareCrypto.pack(ref, 2048)); + } + }); + if (options.privateContent) { + content.querySelectorAll('*').forEach(node => { + node.removeAttribute('style'); + node.removeAttribute('srcset'); + node.removeAttribute('poster'); + node.removeAttribute('background'); + node.removeAttribute('ping'); + for (const name of ['src', 'href', 'xlink:href']) { + if (name === 'href' && node.tagName === 'A') continue; + const value = node.getAttribute(name); + if (value && !value.startsWith('#') && !/^data:(?:image\/(?:png|jpeg|gif|webp|avif|bmp)|video\/(?:mp4|ogg|webm));base64,/.test(value)) { + node.removeAttribute(name); + if (node.tagName === 'IMG') node.setAttribute('alt', (node.getAttribute('alt') || 'External image') + ' (external resource blocked)'); + } + } + }); + content.querySelectorAll('style, link, iframe, object, embed').forEach(node => node.remove()); + // Render source locally rather than allowing renderer-specific remote assets/fallbacks. + content.querySelectorAll('.mermaid, .plantuml-diagram, .d2-diagram, .graphviz-diagram, .kroki-diagram, .geojson-map, .topojson-map, .stl-viewer, .abc-notation').forEach(node => { + const pre = document.createElement('pre'); + const code = document.createElement('code'); + let source = node.getAttribute('data-original-code'); + try { source = source ? decodeURIComponent(source) : node.textContent; } catch (_) { source = node.textContent; } + code.textContent = source || ''; + pre.appendChild(code); + const shell = node.closest('.diagram-viewer, .mermaid-container, .geojson-container, .topojson-container, .stl-container, .abc-container'); + (shell || node).replaceWith(pre); + }); + } + return template.innerHTML; + } + function release(container) { + const state = states.get(container); + if (!state) return; + state.controller.abort(); + state.urls.forEach(url => URL.revokeObjectURL(url)); + states.delete(container); + } + async function resolve(container, options = {}) { + release(container); + const state = { controller: new AbortController(), urls: [] }; + states.set(container, state); + for (const node of container.querySelectorAll('[data-encrypted-media]')) { + if (states.get(container) !== state) return; + const timeout = setTimeout(() => state.controller.abort(), 15000); + try { + const decoded = MarkdownShareCrypto.unpack(node.getAttribute('data-encrypted-media'), 2048); + const ref = reference(decoded.url + '#key=' + decoded.key, options.base); + if (!ref) throw new Error('Invalid encrypted media reference.'); + const media = await decrypt(ref, state.controller.signal); + if (states.get(container) !== state || !container.contains(node)) return; + const url = options.inline ? media.dataUrl : URL.createObjectURL(new Blob([media.bytes], { type: media.mime })); + if (!options.inline) state.urls.push(url); + node.setAttribute('src', url); + if (options.inline) node.removeAttribute('data-encrypted-media'); + const video = node.tagName === 'VIDEO' ? node : node.closest('video'); + if (video) video.load(); + } catch (_) { + if (options.inline) throw new Error('An encrypted attachment could not be exported. Check the connection and expiry.'); + if (states.get(container) === state) node.setAttribute('title', 'Encrypted media is unavailable. Check the complete link and connection.'); + } finally { clearTimeout(timeout); } + } + } + async function inlineMarkdown(markdown, base) { + let output = markdown; + for (const url of new Set(markdown.match(MEDIA_URL) || [])) { + const ref = reference(url, base); + if (!ref) continue; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { output = output.split(url).join((await decrypt(ref, controller.signal)).dataUrl); } + finally { clearTimeout(timer); } + } + return output; + } + root.MarkdownShareMedia = Object.freeze({ reference, validateDataUrl, prepareHtml, resolve, release, inlineMarkdown }); + if (typeof module !== 'undefined' && module.exports) module.exports = root.MarkdownShareMedia; +})(globalThis); diff --git a/sw.js b/sw.js index 574ea6aa..fb69b390 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'markdown-viewer-cache-v3.10.6'; +const CACHE_NAME = 'markdown-viewer-cache-v3.10.6-encrypted-sharing-v2'; // PERF-011: Split precache into critical (local files) and lazy (CDN libraries) // Critical assets are precached during SW install for instant offline startup @@ -9,6 +9,10 @@ const CRITICAL_ASSETS = [ './visual-settings-adapters.js', './visual-settings-store.js', './script.js', + './sharing-crypto.js', + './sharing-media.js', + './sharing-live.js', + './assets/yjs.mjs', './browser-drop-source.js', './preview-worker.js', './styles.css', @@ -37,6 +41,10 @@ const NETWORK_FIRST_LOCAL_PATHS = new Set([ '/visual-settings-adapters.js', '/visual-settings-store.js', '/script.js', + '/sharing-crypto.js', + '/sharing-media.js', + '/sharing-live.js', + '/assets/yjs.mjs', '/browser-drop-source.js', '/preview-worker.js', '/styles.css', @@ -70,6 +78,9 @@ self.addEventListener('activate', event => { self.addEventListener('fetch', event => { const url = new URL(event.request.url); + // Sharing has its own expiry/revocation rules. CacheStorage ignores HTTP + // no-store directives, so these requests must bypass the offline asset cache. + if (event.request.method !== 'GET' || url.pathname.startsWith('/api/') || url.pathname.startsWith('/live-room/')) return; const isLocal = url.origin === self.location.origin; const isCDN = CDN_ORIGINS.some(origin => url.hostname.includes(origin)); diff --git a/tests/e2e/encrypted-sharing.spec.js b/tests/e2e/encrypted-sharing.spec.js new file mode 100644 index 00000000..3f595f36 --- /dev/null +++ b/tests/e2e/encrypted-sharing.spec.js @@ -0,0 +1,161 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); +const C = require('../../sharing-crypto'); +const pako = require('pako'); + +test.describe.configure({ timeout: 180_000 }); +let runtime, kv, liveBase; +test.beforeAll(async () => { + test.setTimeout(120_000); + runtime = await (await import('../helpers/encrypted-sharing-runtime.mjs')).sharingRuntime(); + kv = await runtime.getKVNamespace('SHARE_KV'); + liveBase = (await runtime.ready).origin + '/live-room'; +}); +test.afterAll(async () => { await runtime?.dispose(); }); + +async function localServices(context, writes = []) { + await context.addInitScript(base => { window.MARKDOWN_VIEWER_LIVE_ROOM_URL = base; }, liveBase); + await context.route('https://markdownviewer.pages.dev/api/**', async route => { + const request = route.request(); + if (request.method() === 'POST') writes.push({ url: request.url(), body: request.postDataJSON() }); + const response = await runtime.dispatchFetch(request.url(), { method: request.method(), + headers: { Origin: 'http://127.0.0.1:4173', 'Content-Type': 'application/json' }, + ...(request.postData() ? { body: request.postData() } : {}) }); + await route.fulfill({ status: response.status, headers: Object.fromEntries(response.headers), body: Buffer.from(await response.arrayBuffer()) }); + }); +} +const localLink = (link, base) => base + '/' + new URL(link).hash; + +test('stored snapshots keep payloads opaque, require a key, and retain legacy reads', async ({ page, browser, baseURL }) => { + const writes = []; + await localServices(page.context(), writes); + await openApp(page); + const markdown = '# Opaque snapshot browser fixture\n\n' + 'Private Unicode content हिन्दी 😀. '.repeat(130); + await setEditorContent(page, markdown); + await page.locator('#share-button').click(); + await page.locator('#share-mode-edit').check(); + await page.locator('#share-generate-btn').click(); + await expect(page.locator('#share-url-input')).toHaveValue(/#id=s2_.*&key=/); + const link = await page.locator('#share-url-input').inputValue(); + const params = new URLSearchParams(new URL(link).hash.slice(1)); + const stored = await kv.get('share-v2:' + params.get('id')); + expect(stored).not.toContain('Opaque snapshot'); + expect(stored).not.toContain(params.get('key')); + expect(writes[0].body).toEqual({ encrypted: expect.objectContaining({ v: 2, alg: 'A256GCM' }) }); + expect(JSON.stringify(writes)).not.toContain('Private Unicode'); + const recipient = await browser.newContext(); + await localServices(recipient); + const guest = await recipient.newPage(); + await openApp(guest, localLink(link, baseURL)); + await expect(guest.locator('#markdown-editor')).toHaveValue(markdown); + const invalid = await recipient.newPage(); + await invalid.addInitScript(() => { + window.__messages = []; + new MutationObserver(() => document.querySelectorAll('.app-toast-message').forEach(node => window.__messages.push(node.textContent))) + .observe(document, { childList: true, subtree: true }); + }); + await openApp(invalid, baseURL + '/#id=' + params.get('id'), { expectEditorVisible: false }); + await expect.poll(() => invalid.evaluate(() => window.__messages.join(' '))).toContain('complete link'); + await expect(invalid.locator('#markdown-preview')).not.toContainText('Opaque snapshot'); + const legacyId = 'abcdefghjk'; + const legacy = JSON.stringify({ content: '# Existing snapshot', title: 'Existing', mode: 'edit', createdAt: Date.now() }); + await kv.put(legacyId, legacy, { expirationTtl: 600 }); + const legacyPage = await recipient.newPage(); + await openApp(legacyPage, baseURL + '/#id=' + legacyId); + await expect(legacyPage.locator('#markdown-editor')).toHaveValue('# Existing snapshot'); + expect(await kv.get(legacyId)).toBe(legacy); + const inline = Buffer.from(pako.deflate('# Existing inline')).toString('base64url'); + await legacyPage.goto('about:blank'); + await openApp(legacyPage, baseURL + '/#share=' + inline + '&edit=1'); + await expect(legacyPage.locator('#markdown-editor')).toHaveValue('# Existing inline'); + await expect(page.locator('#share-delete-btn')).toBeVisible(); + await page.locator('#share-delete-btn').click(); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect.poll(() => kv.get('share-v2:' + params.get('id'))).toBeNull(); + await recipient.close(); +}); + +test('managed uploads decrypt in preview and export without sending the media key', async ({ page, browser, baseURL }) => { + const writes = []; + await localServices(page.context(), writes); + await openApp(page); + await setEditorContent(page, ''); + const editor = page.locator('#markdown-editor'); + await editor.pressSequentially('/image'); + await editor.press('Enter'); + await page.locator('#image-source-upload').check(); + const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); + await page.locator('#image-modal-file').setInputFiles({ name: 'private.gif', mimeType: 'image/gif', buffer: gif }); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await expect(editor).toHaveValue(/e2_.*#key=/); + await expect(page.locator('#markdown-preview img')).toHaveAttribute('src', /^blob:/); + const markdown = await editor.inputValue(); + const key = markdown.match(/#key=([A-Za-z0-9_-]{43})/)[1]; + expect(JSON.stringify(writes)).not.toContain(key); + expect(JSON.stringify(writes)).not.toContain(gif.toString('base64')); + await page.locator('#share-button').click(); + await page.locator('#share-mode-edit').check(); + await page.locator('#share-generate-btn').click(); + await expect(page.locator('#share-url-input')).toHaveValue(/#share=v2/); + const recipient = await browser.newContext(); + await localServices(recipient); + const guest = await recipient.newPage(); + await openApp(guest, localLink(await page.locator('#share-url-input').inputValue(), baseURL)); + await expect(guest.locator('#markdown-preview img')).toHaveAttribute('src', /^blob:/); + await guest.evaluate(() => { window.__exports = []; window.saveAs = async blob => window.__exports.push(await blob.text()); }); + await guest.locator('#export-html').dispatchEvent('click'); + await guest.locator('#html-export-confirm').click(); + await expect.poll(() => guest.evaluate(() => window.__exports.length)).toBe(1); + const html = await guest.evaluate(() => window.__exports[0]); + expect(html).toContain('data:image/gif;base64,'); + expect(html).not.toContain(key); + expect(html).not.toContain(' { + const requests = []; + await page.route(/private-resource\.invalid|kroki\.io|plantuml\.com|mermaid\.ink/, async route => { requests.push(route.request().url()); await route.abort(); }); + const key = C.random(); + const content = '# Private rendering\n\n![external](https://private-resource.invalid/pixel)\n\n```plantuml\n@startuml\nAlice -> Bob: private diagram\n@enduml\n```'; + const encrypted = await C.seal({ content, title: 'Private', mode: 'edit' }, key, 'snapshot'); + await openApp(page, baseURL + '/#share=v2.' + C.pack(encrypted) + '&key=' + key); + await expect(page.locator('#markdown-preview')).toContainText('private diagram'); + await expect(page.locator('#markdown-preview img')).not.toHaveAttribute('src'); + expect(requests).toEqual([]); +}); + +test('real encrypted relay synchronizes host and editor with content-free wire frames', async ({ page, browser, baseURL }) => { + const frames = [], urls = []; + const capture = target => target.on('websocket', socket => { + urls.push(socket.url()); socket.on('framesent', event => frames.push(String(event.payload))); + }); + capture(page); + await localServices(page.context()); + await openApp(page); + await setEditorContent(page, '# Real encrypted live fixture\n\nHost text.'); + await page.locator('#live-share-button').click(); + await page.locator('input[name="live-share-access-mode"][value="edit"]').check(); + await page.locator('#live-share-display-name').fill('Confidential host name'); + await page.locator('#live-share-start-btn').click(); + await expect(page.locator('#live-share-url-input')).toHaveValue(/#live=room2_/); + const invite = await page.locator('#live-share-url-input').inputValue(); + await page.locator('#live-share-modal-close-icon').click(); + const recipient = await browser.newContext(); + await localServices(recipient); + const guest = await recipient.newPage(); capture(guest); + await openApp(guest, localLink(invite, baseURL)); + await expect(guest.locator('#markdown-editor')).toHaveValue(/Host text/); + await setEditorContent(guest, '# Real encrypted live fixture\n\nEditor update confidential.'); + await expect(page.locator('#markdown-editor')).toHaveValue(/Editor update confidential/); + const params = new URLSearchParams(new URL(invite).hash.slice(1)); + expect(urls.every(url => /\?v=2$/.test(url))).toBe(true); + const wire = frames.join('\n'); + for (const value of ['Host text', 'Confidential host name', 'Editor update confidential', params.get('key'), params.get('sig')]) expect(wire).not.toContain(value); + expect(frames.some(frame => JSON.parse(frame).type === 'y-update' && JSON.parse(frame).ct)).toBe(true); + await page.locator('#live-share-button').click(); + await page.locator('#live-share-end-btn').click(); + await expect(guest.locator('#live-share-button')).toHaveAttribute('aria-pressed', 'false'); + await recipient.close(); +}); diff --git a/tests/e2e/native-drop-safety.spec.js b/tests/e2e/native-drop-safety.spec.js index 8e2f132d..1b7d69d3 100644 --- a/tests/e2e/native-drop-safety.spec.js +++ b/tests/e2e/native-drop-safety.spec.js @@ -44,8 +44,9 @@ test('cancelling a mixed drop links no folder and reads or uploads no media', as test('native GIF uses the bounded reader and existing consent/upload flow', async ({ page }) => { let uploads = 0; - await page.route('**/api/image', route => { uploads++; return route.fulfill({ json: { id: 'reviewNativeMedia00000001' } }); }); - await page.route('**/api/image/*', route => route.fulfill({ contentType: 'image/gif', body: Buffer.from(gif, 'base64') })); + let encryptedUpload; + await page.route('**/api/image', route => { uploads++; encryptedUpload = route.request().postDataJSON(); return route.fulfill({ json: { id: 'e2_' + 'N'.repeat(24) } }); }); + await page.route('**/api/image/*', route => route.fulfill({ json: encryptedUpload })); await seedMedia(page); await page.evaluate(() => { window.__drop = NL_HANDLE_NATIVE_DROP(['C:/native.gif']); }); await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeVisible(); @@ -54,7 +55,7 @@ test('native GIF uses the bounded reader and existing consent/upload flow', asyn expect(await page.evaluate(() => __drop)).toBe(1); expect(uploads).toBe(1); expect(await page.evaluate(() => __binaryLimit)).toBe(5 * 1024 * 1024 + 1); - await expect(page.locator('#markdown-editor')).toHaveValue(/reviewNativeMedia00000001/); + await expect(page.locator('#markdown-editor')).toHaveValue(/e2_N{24}#key=/); }); test('oversized native GIF is rejected before any binary read', async ({ page }) => { diff --git a/tests/e2e/share-live.spec.js b/tests/e2e/share-live.spec.js index afa2b9df..0fb1668b 100644 --- a/tests/e2e/share-live.spec.js +++ b/tests/e2e/share-live.spec.js @@ -80,7 +80,7 @@ test('Share Snapshot edit links open with editable source', async ({ browser, pa await page.locator('#share-button').click(); await page.locator('#share-mode-edit').check(); await page.locator('#share-generate-btn').click(); - await expect(page.locator('#share-url-input')).toHaveValue(/edit=1/); + await expect(page.locator('#share-url-input')).toHaveValue(/#share=v2\..*&key=/); const generatedUrl = await page.locator('#share-url-input').inputValue(); const sharedContext = await browser.newContext(); diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js index ced20df8..269de21b 100644 --- a/tests/e2e/slash-command-actions.spec.js +++ b/tests/e2e/slash-command-actions.spec.js @@ -2,7 +2,7 @@ const { test, expect } = require('@playwright/test'); const { openApp, setEditorContent, stubRemoteDiagramServices, stubLazyRendererLibraries, storedDocuments, waitForAppReady } = require('../helpers/app'); test.use({ timezoneId: 'UTC' }); -const mediaId = 'slashAuditMedia0000000001'; +const mediaId = 'e2_' + 'A'.repeat(24); const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); async function chooseUpload(page, editor) { @@ -12,7 +12,11 @@ async function chooseUpload(page, editor) { } async function mockMediaDownload(page) { - await page.route('**/api/image/' + mediaId, route => route.fulfill({ contentType: 'image/gif', body: gif })); + let uploaded; + page.on('request', request => { + if (request.method() === 'POST' && request.url().endsWith('/api/image')) uploaded = request.postDataJSON(); + }); + await page.route('**/api/image/' + mediaId, route => route.fulfill({ json: uploaded })); } async function expectPersistedAfterReload(page, content) { @@ -166,7 +170,7 @@ for (const pane of ['primary', 'secondary']) { await chooseUpload(page, editor); await page.getByRole('button', { name: 'Continue', exact: true }).click(); await expect(page.locator('#image-modal')).toBeHidden(); - await expect(editor).toHaveValue(new RegExp('^Before\\n\\n!\\[alt text\\]\\(https?://[^)]+/api/image/' + mediaId + '\\)\\n\\nAfter$')); + await expect(editor).toHaveValue(new RegExp('^Before\\n\\n!\\[alt text\\]\\(https?://[^)]+/api/image/' + mediaId + '#key=[A-Za-z0-9_-]{43}\\)\\n\\nAfter$')); const insertedValue = await editor.inputValue(); await expectPersistedAfterReload(page, insertedValue); }); diff --git a/tests/helpers/app.js b/tests/helpers/app.js index 31ae1a6e..7908ada0 100644 --- a/tests/helpers/app.js +++ b/tests/helpers/app.js @@ -574,7 +574,7 @@ async function stubExportLibraries(page) { } async function stubLiveShareRuntime(page) { - await page.route('https://esm.sh/yjs@13.6.10/es2022/yjs.mjs', route => { + await page.route('**/assets/yjs.mjs', route => { route.fulfill({ contentType: 'application/javascript', body: ` @@ -652,6 +652,13 @@ async function stubLiveShareRuntime(page) { }, 0); } send(payload) { + const message = JSON.parse(payload); + window.__liveWireMessages = window.__liveWireMessages || []; + window.__liveWireMessages.push(message); + if (message.type === 'authenticate') { + setTimeout(() => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ type: 'ready', v: 2 }) })), 0); + return; + } this.channel.postMessage({ sender: this.id, payload }); } close() { diff --git a/tests/helpers/encrypted-sharing-runtime.mjs b/tests/helpers/encrypted-sharing-runtime.mjs new file mode 100644 index 00000000..b987eff2 --- /dev/null +++ b/tests/helpers/encrypted-sharing-runtime.mjs @@ -0,0 +1,14 @@ +import { build } from 'esbuild'; +import { Miniflare, Log, LogLevel } from 'miniflare'; +import { fileURLToPath } from 'node:url'; + +export async function sharingRuntime() { + const result = await build({ entryPoints: [fileURLToPath(new URL('./encrypted-sharing-worker.mjs', import.meta.url))], + bundle: true, write: false, format: 'esm', platform: 'browser', logLevel: 'silent' }); + const runtime = new Miniflare({ modules: true, script: result.outputFiles[0].text, compatibilityDate: '2026-07-02', + host: '127.0.0.1', port: 0, kvNamespaces: ['SHARE_KV'], + durableObjects: { LIVE_ROOMS: { className: 'TestLiveRoom', useSQLite: true } }, + log: new Log(LogLevel.ERROR) }); + await runtime.ready; + return runtime; +} diff --git a/tests/helpers/encrypted-sharing-worker.mjs b/tests/helpers/encrypted-sharing-worker.mjs new file mode 100644 index 00000000..ed5db08d --- /dev/null +++ b/tests/helpers/encrypted-sharing-worker.mjs @@ -0,0 +1,26 @@ +// Local synthetic integration harness only; this is not a deployment entry point. +import liveWorker, { LiveRoom } from '../../workers/live-room-worker.js'; +import { onRequest as share } from '../../functions/api/share/[[id]].js'; +import { onRequest as media } from '../../functions/api/image/[[id]].js'; + +export class TestLiveRoom extends LiveRoom { + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/__test/inspect') return Response.json(await this.state.storage.get('live-room-auth-v2') || null); + if (url.pathname === '/__test/alarm') return Response.json(await this.state.storage.getAlarm()); + if (url.pathname === '/__test/expire') { await super.alarm(); return Response.json({ expired: true }); } + if (url.pathname === '/__test/legacy') { + await this.state.storage.put('live-room-auth-v1', await request.json()); + return Response.json({ seeded: true }); + } + return super.fetch(request); + } +} +export default { + fetch(request, env) { + const url = new URL(request.url); + const match = /^\/api\/(share|image|media)(?:\/([^/]+))?$/.exec(url.pathname); + if (match) return (match[1] === 'share' ? share : media)({ request, env, params: { id: match[2] || '' } }); + return liveWorker.fetch(request, env); + } +}; diff --git a/tests/helpers/static-build-check.mjs b/tests/helpers/static-build-check.mjs index a4037587..29e123cf 100644 --- a/tests/helpers/static-build-check.mjs +++ b/tests/helpers/static-build-check.mjs @@ -17,6 +17,10 @@ const requiredFiles = [ 'visual-settings-adapters.js', 'visual-settings-store.js', 'script.js', + 'sharing-crypto.js', + 'sharing-media.js', + 'sharing-live.js', + 'assets/yjs.mjs', 'browser-drop-source.js', 'preview-worker.js', 'styles.css', @@ -48,6 +52,9 @@ for (const relativePath of requiredFiles) { } const syntaxCheckedFiles = [ + 'sharing-crypto.js', + 'sharing-media.js', + 'sharing-live.js', 'src/document-toc.mjs', 'browser-drop-source.js', 'assets/document-toc.js', @@ -77,12 +84,19 @@ for (const relativePath of syntaxCheckedFiles) { } const syncedDesktopFiles = [ + ['sharing-crypto.js', 'desktop-app/resources/js/sharing-crypto.js'], + ['sharing-media.js', 'desktop-app/resources/js/sharing-media.js'], + ['sharing-live.js', 'desktop-app/resources/js/sharing-live.js'], ['script.js', 'desktop-app/resources/js/script.js'], ['browser-drop-source.js', 'desktop-app/resources/js/browser-drop-source.js'], ['workspace-storage.js', 'desktop-app/resources/js/workspace-storage.js'], ['preview-worker.js', 'desktop-app/resources/js/preview-worker.js'], ['styles.css', 'desktop-app/resources/styles.css'] ]; +// Bundled desktop assets are generated/ignored and absent in a fresh checkout. +if (fs.existsSync(path.join(rootDir, 'desktop-app/resources/assets/yjs.mjs'))) { + syncedDesktopFiles.push(['assets/yjs.mjs', 'desktop-app/resources/assets/yjs.mjs']); +} if (fs.existsSync(path.join(rootDir, 'visual-settings-store.js'))) { syncedDesktopFiles.push(['visual-settings-store.js', 'desktop-app/resources/js/visual-settings-store.js']); } @@ -105,6 +119,13 @@ for (const relativePath of ['manifest.json', 'vercel.json', ...fs.readdirSync(pa } const indexHtml = fs.readFileSync(path.join(rootDir, 'index.html'), 'utf8'); +const desktopHtml = fs.readFileSync(path.join(rootDir, 'desktop-app/resources/index.html'), 'utf8'); +let previousDesktopScript = -1; +for (const name of ['neutralino', 'main', 'workspace-storage', 'visual-settings-adapters', 'visual-settings-store', 'sharing-crypto', 'sharing-media', 'sharing-live', 'script']) { + const position = desktopHtml.indexOf(`src="/js/${name}.js"`); + if (position <= previousDesktopScript) throw new Error(`Missing or out-of-order desktop script: ${name}`); + previousDesktopScript = position; +} const appJs = fs.readFileSync(path.join(rootDir, 'script.js'), 'utf8'); const storageJs = fs.readFileSync(path.join(rootDir, 'workspace-storage.js'), 'utf8'); const sitemapXml = fs.readFileSync(path.join(rootDir, 'sitemap.xml'), 'utf8'); @@ -271,7 +292,7 @@ const requiredScriptMarkers = [ 'exportHtml.addEventListener', 'exportPdf.addEventListener', 'exportPng.addEventListener', - 'function encodeMarkdownForShare', + 'function buildShareUrl', 'function startLiveSession', 'function loadFromShareHash', 'function loadFromLiveHash' diff --git a/tests/unit/sharing-encryption.test.mjs b/tests/unit/sharing-encryption.test.mjs new file mode 100644 index 00000000..4d44f2e9 --- /dev/null +++ b/tests/unit/sharing-encryption.test.mjs @@ -0,0 +1,198 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { build } from 'esbuild'; +const require = createRequire(import.meta.url); +const C = require('../../sharing-crypto.js'); +const M = require('../../sharing-media.js'); + +test('offline asset caching never intercepts share/media APIs, live rooms, or mutations', () => { + const listeners = new Map(); + runInNewContext(readFileSync('sw.js', 'utf8'), { + URL, self: { location: { origin: 'https://markdownviewer.pages.dev' }, addEventListener: (type, handler) => listeners.set(type, handler) }, + caches: { open() { assert.fail('Sharing requests must not open an offline cache'); } } + }); + for (const [path, method] of [['/api/share/s2_test', 'GET'], ['/api/image/e2_test', 'GET'], ['/api/media/e2_test', 'HEAD'], + ['/live-room/room2_test?v=2', 'GET'], ['/api/share', 'POST'], ['/api/share/s2_test', 'DELETE']]) { + listeners.get('fetch')({ request: { url: 'https://markdownviewer.pages.dev' + path, method }, + respondWith() { assert.fail('Sharing requests must use the network and its retention policy'); } }); + } +}); + +async function api(path) { + const result = await build({ entryPoints: [path], bundle: true, write: false, format: 'esm', platform: 'node', logLevel: 'silent' }); + return (await import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64'))).onRequest; +} +const snapshotApi = await api('functions/api/share/[[id]].js'); +const mediaApi = await api('functions/api/image/[[id]].js'); +function kv() { + const values = new Map(), writes = []; + return { values, writes, async get(id) { return values.get(id) || null; }, + async put(id, value, options) { writes.push({ id, value, options }); values.set(id, value); }, + async delete(id) { values.delete(id); } }; +} +function request(method, body, id = '', kind = 'share') { + return new Request('https://markdownviewer.pages.dev/api/' + kind + (id ? '/' + id : ''), { + method, headers: { Origin: 'https://markdownviewer.pages.dev', 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); +} +const call = (handler, store, method, body, id = '') => handler({ request: request(method, body, id), env: { SHARE_KV: store }, params: { id } }); + +test('snapshots encrypt all fields with fresh IVs and preserve Unicode', async () => { + const value = { content: '# private\nहिन्दी 😀', title: 'secret title', mode: 'edit' }, key = C.random(); + const first = await C.seal(value, key, 'snapshot'), second = await C.seal(value, key, 'snapshot'); + assert.notEqual(first.iv, second.iv); + assert.notEqual(first.ct, second.ct); + assert.deepEqual(await C.open(first, key, 'snapshot'), value); + assert.equal(JSON.stringify(first).includes('secret'), false); + assert.equal(Buffer.from(C.decode(first.ct, 1000)).includes(Buffer.from(value.content)), false); + assert.deepEqual(C.unpack(C.pack(first)), first); +}); + +test('snapshot authentication rejects wrong keys, contexts, tags, IVs and unsupported versions', async () => { + const key = C.random(), encrypted = await C.seal({ content: 'sensitive' }, key, 'snapshot'); + await assert.rejects(C.open(encrypted, C.random(), 'snapshot')); + await assert.rejects(C.open(encrypted, key, 'media')); + await assert.rejects(C.open(encrypted, '', 'snapshot')); + for (const field of ['iv', 'ct']) { + const bytes = C.decode(encrypted[field], 1000); bytes[0] ^= 1; + await assert.rejects(C.open({ ...encrypted, [field]: C.encode(bytes) }, key, 'snapshot')); + } + await assert.rejects(C.open({ ...encrypted, v: 3 }, key, 'snapshot')); + await assert.rejects(C.open({ ...encrypted, key }, key, 'snapshot')); + await assert.rejects(C.seal('too large', key, 'snapshot', 3)); + assert.throws(() => C.decode('abc=', 10)); + assert.throws(() => C.decode('AB', 10)); +}); + +test('snapshot API stores only an envelope and separates creator deletion authority', async () => { + const store = kv(), key = C.random(), content = 'PRIVATE API MARKER'; + const encrypted = await C.seal({ content, title: 'CONFIDENTIAL TITLE', mode: 'view' }, key, 'snapshot'); + const created = await call(snapshotApi, store, 'POST', { encrypted }); + assert.equal(created.status, 201); + const { id, deleteToken, expiresIn } = await created.json(); + assert.match(id, /^s2_[A-Za-z0-9_-]{24}$/); + assert.equal(expiresIn, 90 * 86400); + const record = JSON.parse(store.values.get('share-v2:' + id)); + assert.deepEqual(Object.keys(record).sort(), ['createdAt', 'deleteTokenHash', 'encrypted', 'version']); + assert.equal(store.writes[0].options.expirationTtl, 90 * 86400); + for (const secret of [key, content, deleteToken, 'CONFIDENTIAL TITLE']) assert.equal(JSON.stringify(record).includes(secret), false); + const fetched = await call(snapshotApi, store, 'GET', undefined, id); + assert.equal(fetched.headers.get('Cache-Control'), 'no-store'); + assert.deepEqual(await fetched.json(), { encrypted }); + assert.equal((await call(snapshotApi, store, 'DELETE', { deleteToken: key }, id)).status, 404); + assert.equal((await call(snapshotApi, store, 'DELETE', { deleteToken }, id)).status, 200); + assert.equal((await call(snapshotApi, store, 'GET', undefined, id)).status, 404); +}); + +test('legacy snapshots remain readable without rewriting or extending their TTL', async () => { + const store = kv(), record = JSON.stringify({ content: 'legacy', title: 'Old title', mode: 'edit', createdAt: 1 }); + store.values.set('abcdefghjk', record); + const response = await call(snapshotApi, store, 'GET', undefined, 'abcdefghjk'); + assert.equal(response.status, 200); + assert.equal((await response.json()).content, 'legacy'); + assert.equal(store.values.get('abcdefghjk'), record); + assert.equal(store.writes.length, 0); + assert.equal((await call(snapshotApi, store, 'POST', { content: 'new plaintext' })).status, 426); + assert.equal(store.writes.length, 0); +}); + +test('snapshot API rejects unencrypted fields, invalid framing and missing bindings', async () => { + const store = kv(), encrypted = await C.seal({ content: 'test' }, C.random(), 'snapshot'); + assert.equal((await call(snapshotApi, store, 'POST', { encrypted, key: C.random() })).status, 426); + assert.equal((await call(snapshotApi, store, 'POST', { encrypted: { ...encrypted, ct: 'plaintext!' } })).status, 400); + assert.equal((await snapshotApi({ request: request('GET'), env: {}, params: {} })).status, 503); + assert.equal(store.writes.length, 0); +}); + +const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3ioAAAAASUVORK5CYII='; +test('managed media uses encrypted random records; legacy media is not refreshed', async () => { + const store = kv(), key = C.random(), encrypted = await C.seal({ dataUrl: png }, key, 'media', C.MEDIA_LIMIT); + const response = await call(mediaApi, store, 'POST', { encrypted }); + assert.equal(response.status, 201); + const result = await response.json(); + assert.match(result.id, /^e2_[A-Za-z0-9_-]{24}$/); + const raw = store.values.get('managed-media-v2:' + result.id); + for (const secret of [png, key, 'image/png']) assert.equal(raw.includes(secret), false); + const fetched = await call(mediaApi, store, 'GET', undefined, result.id); + assert.deepEqual(await C.open((await fetched.json()).encrypted, key, 'media', C.MEDIA_LIMIT), { dataUrl: png }); + const duplicate = await (await call(mediaApi, store, 'POST', { encrypted })).json(); + assert.notEqual(duplicate.id, result.id); + const id = 'abcdefgh1234567890123456'; + const legacy = JSON.stringify({ mimeType: 'image/png', base64: png.split(',')[1], createdAt: Date.now() - 86400000 }); + store.values.set('managed-image-v1:' + id, legacy); + const writes = store.writes.length; + assert.equal((await call(mediaApi, store, 'GET', undefined, id)).status, 200); + assert.equal(store.values.get('managed-image-v1:' + id), legacy); + assert.equal((await call(mediaApi, store, 'POST', { dataUrl: png })).status, 426); + assert.equal(store.writes.length, writes); +}); + +test('media decryption validates signatures and never accepts active image types', () => { + assert.equal(M.validateDataUrl(png).mime, 'image/png'); + assert.throws(() => M.validateDataUrl('data:image/png;base64,YmFk')); + assert.throws(() => M.validateDataUrl('data:image/svg+xml;base64,PHN2Zy8+')); + const key = C.random(), url = 'https://markdownviewer.pages.dev/api/image/e2_' + C.random(18) + '#key=' + key; + const ref = M.reference(url, 'https://markdownviewer.pages.dev'); + assert.equal(ref.key, key); + assert.equal(ref.url.includes('#'), false); + assert.equal(M.reference(url.replace('markdownviewer.pages.dev', 'evil.example'), 'https://markdownviewer.pages.dev'), null); +}); + +async function roomPeers() { + const roomId = 'room2_' + C.random(18), keys = await C.createRoom(roomId); + const peer = role => C.liveSession({ roomId, config: keys.config, key: keys.key, role, + sender: 'participant-' + C.random(12), signingKey: keys.signing[role] }); + return { roomId, keys, peer, host: await peer('host'), edit: await peer('edit'), view: await peer('view') }; +} +test('live document, review, control and presence payloads are encrypted and role authenticated', async () => { + const { host, edit, view } = await roomPeers(); + for (const [sender, type] of [[host, 'control-state'], [edit, 'y-update'], [view, 'review-update'], [view, 'presence']]) { + const message = { type, update: 'PRIVATE DOCUMENT OR COMMENT', participant: { name: 'PRIVATE NAME' } }; + const frame = await sender.encrypt(message); + assert.equal(JSON.stringify(frame).includes('PRIVATE'), false); + const decoded = await host.decrypt(frame); + assert.equal(decoded.update, message.update); + assert.equal(decoded.participant.name, 'PRIVATE NAME'); + } + await assert.rejects(view.encrypt({ type: 'y-update' })); + await assert.rejects(view.encrypt({ type: 'session-end' })); + await assert.rejects(edit.encrypt({ type: 'control-state' })); +}); + +test('live rejects signature/header tampering, role escalation, replay and cross-room frames', async () => { + const { roomId, keys, host, edit, view } = await roomPeers(); + const frame = await edit.encrypt({ type: 'y-update', update: 'bytes' }); + for (const patch of [{ role: 'host' }, { type: 'control-state' }, { sender: 'participant-' + C.random(12) }, { seq: 999 }, { ct: C.random(32) }]) { + await assert.rejects(view.decrypt({ ...frame, ...patch })); + } + await view.decrypt(frame); + await assert.rejects(view.decrypt(frame), /Replayed/); + const other = await roomPeers(); + await assert.rejects(other.host.decrypt(frame), /Wrong live room/); + await assert.rejects(C.liveSession({ roomId, key: keys.key, config: keys.config, role: 'host', sender: 'participant-' + C.random(12), signingKey: keys.signing.view }), /role key/); + const second = await edit.encrypt({ type: 'y-update', update: 'new' }); + await host.decrypt(second); + await assert.rejects(host.decrypt(frame), /Replayed/); +}); + +test('reconnections derive fresh keys, and ciphertext does not decrypt with a different room key', async () => { + const { roomId, keys, host, view, peer } = await roomPeers(); + const replacement = await peer('host'); + const one = await host.encrypt({ type: 'presence' }), two = await replacement.encrypt({ type: 'presence' }); + assert.notEqual(one.session, two.session); + assert.notEqual(one.ct, two.ct); + await view.decrypt(one); await view.decrypt(two); + const wrong = await C.liveSession({ roomId, config: keys.config, key: C.random(), role: 'view', sender: 'participant-' + C.random(12), signingKey: keys.signing.view }); + await assert.rejects(wrong.decrypt(one)); +}); + +test('streamed request limits work without a Content-Length header', async () => { + const request = new Request('https://example.test', { method: 'POST', body: new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(100)); controller.close(); } + }), duplex: 'half' }); + await assert.rejects(C.readJson(request, 10), /too large/); +}); diff --git a/tests/unit/sharing-relay.test.mjs b/tests/unit/sharing-relay.test.mjs new file mode 100644 index 00000000..5596d1d7 --- /dev/null +++ b/tests/unit/sharing-relay.test.mjs @@ -0,0 +1,94 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { sharingRuntime } from '../helpers/encrypted-sharing-runtime.mjs'; +const C = createRequire(import.meta.url)('../../sharing-crypto.js'); +const origin = 'https://markdownviewer.pages.dev'; +function next(socket, type = 'message') { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { socket.removeEventListener(type, handler); reject(new Error('Timed out waiting for ' + type)); }, 5000); + const handler = event => { clearTimeout(timer); socket.removeEventListener(type, handler); resolve(type === 'message' ? JSON.parse(event.data) : event); }; + socket.addEventListener(type, handler); + }); +} + +test('real Durable Object relays only signed encrypted frames and stores no content keys or raw capabilities', { timeout: 30000 }, async () => { + const runtime = await sharingRuntime(); + const sockets = []; + try { + const roomId = 'room2_' + C.random(18), keys = await C.createRoom(roomId); + const caps = { host: C.random(), edit: C.random(), view: C.random() }; + async function join(role) { + const sender = 'participant-' + C.random(12); + const crypto = await C.liveSession({ roomId, config: keys.config, key: keys.key, role, sender, signingKey: keys.signing[role] }); + const response = await runtime.dispatchFetch(origin + '/live-room/' + roomId + '?v=2', { headers: { Origin: origin, Upgrade: 'websocket' } }); + assert.equal(response.status, 101); + const socket = response.webSocket; sockets.push(socket); socket.accept(); + const ready = next(socket); + socket.send(JSON.stringify({ type: 'authenticate', v: 2, role, sender, session: crypto.session, config: keys.config, + capability: caps[role], ...(role === 'host' ? { capabilities: caps } : {}) })); + assert.deepEqual(await ready, { type: 'ready', v: 2 }); + return { socket, crypto }; + } + const host = await join('host'), editor = await join('edit'), viewer = await join('view'); + const pending = next(viewer.socket), update = await editor.crypto.encrypt({ type: 'y-update', update: 'SECRET DOCUMENT' }); + editor.socket.send(JSON.stringify(update)); + const wire = await pending; + assert.deepEqual(wire, update); + assert.equal(JSON.stringify(wire).includes('SECRET DOCUMENT'), false); + assert.equal((await viewer.crypto.decrypt(wire)).update, 'SECRET DOCUMENT'); + const ns = await runtime.getDurableObjectNamespace('LIVE_ROOMS'); + const stub = ns.get(ns.idFromName('encrypted-v2:' + roomId)); + const auth = await (await stub.fetch('https://local/__test/inspect')).json(); + for (const secret of [keys.key, ...Object.values(keys.signing), ...Object.values(caps), 'SECRET DOCUMENT']) { + assert.equal(JSON.stringify(auth).includes(secret), false); + } + assert.equal(auth.expiresAt, keys.config.expiresAt); + assert.equal(await (await stub.fetch('https://local/__test/alarm')).json(), keys.config.expiresAt); + + const denied = next(viewer.socket, 'close'); + // A legitimate editor frame cannot be replayed through a viewer connection. + viewer.socket.send(JSON.stringify(update)); + assert.equal((await denied).code, 1008); + + const ended = next(editor.socket); + host.socket.send(JSON.stringify(await host.crypto.encrypt({ type: 'session-end' }))); + assert.equal((await editor.crypto.decrypt(await ended)).type, 'session-end'); + const persisted = await (await stub.fetch('https://local/__test/inspect')).json(); + assert.equal(persisted.ended, true); + const reconnect = await runtime.dispatchFetch(origin + '/live-room/' + roomId + '?v=2', { headers: { Origin: origin, Upgrade: 'websocket' } }); + const rejected = reconnect.webSocket; sockets.push(rejected); rejected.accept(); + const closed = next(rejected, 'close'); + rejected.send(JSON.stringify({ type: 'authenticate', v: 2, role: 'view', sender: 'participant-' + C.random(12), + session: C.random(16), config: keys.config, capability: caps.view })); + assert.equal((await closed).code, 1008); + await stub.fetch('https://local/__test/expire', { method: 'POST' }); + assert.equal(await (await stub.fetch('https://local/__test/inspect')).json(), null); + } finally { + for (const socket of sockets) try { socket.close(); } catch (_) {} + await runtime.dispose(); + } +}); + +test('real relay rejects plaintext v2 messages and preserves an existing legacy room', { timeout: 30000 }, async () => { + const runtime = await sharingRuntime(), sockets = []; + try { + const roomId = 'room2_' + C.random(18); + for (const query of ['?v=3', '?v=2&key=must-not-be-in-url']) { + const invalid = await runtime.dispatchFetch(origin + '/live-room/' + roomId + query, { headers: { Origin: origin, Upgrade: 'websocket' } }); + assert.equal(invalid.status, 400); + } + const response = await runtime.dispatchFetch(origin + '/live-room/' + roomId + '?v=2', { headers: { Origin: origin, Upgrade: 'websocket' } }); + const socket = response.webSocket; sockets.push(socket); socket.accept(); + const denied = next(socket, 'close'); + socket.send(JSON.stringify({ type: 'y-update', update: 'plaintext' })); + assert.equal((await denied).code, 1008); + const caps = { hostCap: C.random(), editCap: C.random(), viewCap: C.random() }, legacyRoom = 'legacy-' + C.random(9), secret = C.random(); + const ns = await runtime.getDurableObjectNamespace('LIVE_ROOMS'); + const stub = ns.get(ns.idFromName(legacyRoom + ':' + secret)); + await stub.fetch('https://local/__test/legacy', { method: 'POST', body: JSON.stringify(caps) }); + const legacy = await runtime.dispatchFetch(origin + '/live-room/' + legacyRoom + '?secret=' + secret + '&role=view&cap=' + caps.viewCap, + { headers: { Origin: origin, Upgrade: 'websocket' } }); + assert.equal(legacy.status, 101); sockets.push(legacy.webSocket); legacy.webSocket.accept(); + } finally { for (const socket of sockets) try { socket.close(); } catch (_) {} await runtime.dispose(); } +}); diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 83bf61d5..fd43b4af 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -66,7 +66,7 @@ The web build loads core libraries from CDN with Subresource Integrity where che | STLLoader / OrbitControls | CDN or prepared desktop copy | STL loading and controls | Lazy | | D3 | CDN or prepared desktop copy | Markmap | Lazy | | Markmap | CDN or prepared desktop copy | Markmap diagrams | Lazy | -| Yjs | CDN or prepared desktop copy | Live Share document sync | Lazy | +| Yjs | Pinned local bundle (`assets/yjs.mjs`) | Live Share document sync | Lazy | When running inside Neutralino, dynamic library URLs are rewritten to local `/libs/...` files prepared by `desktop-app/prepare.js`. @@ -91,15 +91,16 @@ When running inside Neutralino, dynamic library URLs are rewritten to local `/li | Share URL warning ceiling | 32,000 characters | | Legacy share URL ceiling | 4,096 characters | | Server share threshold | 3,000 bytes | -| Stored Share Snapshot max content | 8,000,000 characters | +| Stored Share Snapshot max payload | 8 MiB UTF-8 JSON before encryption | | Stored Share Snapshot TTL | 90 days | | Managed media max source file | 25 MiB | | Managed still-image optimized payload | 300 KiB | | Managed GIF payload | 5 MiB | | Managed video payload | 10 MiB | -| Managed media TTL | 90 days from the most recent upload of that content | +| Managed media TTL | 90 days per encrypted upload | | Live Share max participants | 64 | -| Live Share max message | 8 MB | +| Live Share max payload | 8 MiB before encryption; bounded envelope overhead | +| New Live Share room lifetime | 24 hours | | STL source limit | 2 MiB | | STL geometry limit | 300,000 vertices | @@ -152,11 +153,9 @@ class_name = "LiveRoom" script_name = "markdown-viewer-live-room" ``` -`SHARE_KV` stores large Share Snapshot records and content-addressed managed media for 90 days. Re-uploading identical content refreshes that media item's 90-day expiry. The snapshot and media record types use separate key prefixes. `LIVE_ROOMS` routes Live Share WebSocket rooms to Durable Objects. +`SHARE_KV` stores new encrypted snapshots under `share-v2:` and encrypted attachments under `managed-media-v2:` for 90 days. Every upload receives an independent random id. `LIVE_ROOMS` routes new rooms through the `encrypted-v2:` namespace and stores capability hashes, public verification keys, expiry, and ended status for 24 hours. Document and Review state is not persisted by the relay. Existing keys/records remain readable without rewriting or extending their expiry. -Live Share does not persist Markdown or Review content server-side. Its Durable Object does persist the host, edit, and view bearer capability values plus `createdAt` under `live-room-auth-v1`; the current implementation defines no application TTL or deletion route for that record. Share Snapshot, managed media storage, and Live Share are separate data paths. - -`wrangler.live-room.toml` deploys `workers/live-room-worker.js` with the `LiveRoom` Durable Object migration. +Deploy the updated Pages Functions, browser assets, and Durable Object Worker together; refresh older clients before creating new shares. New plaintext creation is rejected with an upgrade response. See the [rollout notes](../docs/ENCRYPTED_SHARING.md). ## Share API @@ -169,24 +168,24 @@ Live Share does not persist Markdown or Review content server-side. Its Durable Responses set `Cache-Control: no-store` and vary CORS by request origin. The allowed origins are the production app, HTTPS `*.markdownviewer.pages.dev` previews, `null`, and localhost/127.0.0.1 development origins; unsupported origins receive `403`. Requests without an `Origin` header are accepted, so origin filtering is not an access-control substitute. -Stored records contain content, mode, title, creation time, size, and a hash of the creator deletion token. The token is returned only when the snapshot is created. The current UI does not display the token or expose a Delete snapshot action; API clients must capture the response to use early deletion. Invalid ids, missing content, oversized content, invalid deletion tokens, missing KV binding, and unknown routes return JSON errors. See [Share Snapshot](Share-Snapshot.md). +New POST requests accept only `{ "encrypted": { "v": 2, "alg": "A256GCM", "iv": "...", "ct": "..." } }`. Stored records contain the encrypted envelope, format version, creation time, and a deletion-token hash. GET returns the envelope for client decryption. The independent deletion token is returned once and powers **Delete snapshot** while the generated-link modal retains it. See [Share Snapshot](Share-Snapshot.md). ## Managed Media API `functions/api/image/[[id]].js` and its video-friendly route alias `functions/api/media/[[id]].js` support: -- `POST /api/image` for AVIF, BMP, GIF, JPEG, PNG, and WebP data URLs. -- `POST /api/media` for MP4, WebM, and Ogg video data URLs. -- `GET` and `HEAD` on `/api/image/` or `/api/media/` to serve content through its content-addressed public URL. +- `POST /api/image` for encrypted AVIF, BMP, GIF, JPEG, PNG, and WebP envelopes. +- `POST /api/media` for encrypted MP4, WebM, and Ogg video envelopes. +- `GET` and `HEAD` on `/api/image/` or `/api/media/` to retrieve the encrypted envelope. - `OPTIONS` for CORS preflight. -The API accepts still images up to 300 KiB after client-side optimization, GIFs up to 5 MiB, and videos up to 10 MiB. It validates both the declared media type and file signature, derives an unguessable 24-character id from SHA-256 content, and stores the record in `SHARE_KV` with a 90-day TTL. Duplicate content returns the existing id and refreshes its TTL. Responses are public, immutable, and cross-origin until expiry; possession of the URL is sufficient to retrieve the media. This is the same 90-day retention duration used for stored Share Snapshot links, though the two features store separate records. +The client validates media types, signatures, and size before encrypting: 300 KiB optimized still images, 5 MiB GIFs, and 10 MiB videos. The API bounds and validates the ciphertext envelope, uses a random `e2_` id, and stores it for 90 days. It cannot inspect decrypted file types. GET responses for new uploads are `no-store`. Legacy media reads preserve their original records and expiry. Upload and preflight requests use the same production, Cloudflare preview, local-file, and localhost origin checks as Share Snapshot. ## Live Room API -`functions/live-room/[[room]].js` supports WebSocket upgrades only. It validates the WebSocket `Origin`, room and secret length, requires `LIVE_ROOMS`, and forwards to a Durable Object chosen by `roomName + ":" + secret`. The Durable Object authenticates host, edit, and view capabilities, filters message types by role, enforces the participant/message limits, and persists the capability record described above. +`functions/live-room/[[room]].js` supports WebSocket upgrades. It validates Origin and protocol, requires `LIVE_ROOMS`, and routes `?v=2` rooms by `encrypted-v2:`. The Durable Object authenticates role capabilities in the first frame, stores hashes, verifies role signatures, enforces limits, and forwards ciphertext. Client read keys and private signing keys never reach these endpoints. See [Live Share](Live-Share-Cloudflare.md) for runtime flow and limits. @@ -204,7 +203,7 @@ Security headers configured in Docker/Nginx documentation include: - `Permissions-Policy` - `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` -Cloudflare Pages reads the root `_headers`, `_redirects`, and `_routes.json` files. `_routes.json` sends the application root, `/api/*`, and `/live-room/*` through Pages Functions so Live Share WebSocket upgrades reach the Durable Object relay while static files remain direct. `_redirects` consolidates legacy `/tips` and `/index.html` paths on the root URL, and the checked-in `404.html` returns a real noindex missing-page response. Self-hosters should preserve equivalent policies and make sure `workspace-storage.js`, `preview-worker.js`, `sample.md`, `sw.js`, `manifest.json`, `script.js`, `styles.css`, `404.html`, `robots.txt`, `sitemap.xml`, `seo/`, `assets/`, `workers/`, and `functions/` or their platform equivalents are deployed according to the features they intend to use. +Cloudflare Pages reads the root `_headers`, `_redirects`, and `_routes.json` files. `_routes.json` sends the application root, `/api/*`, and `/live-room/*` through Pages Functions so Live Share WebSocket upgrades reach the Durable Object relay while static files remain direct. `_redirects` consolidates legacy `/tips` and `/index.html` paths on the root URL, and the checked-in `404.html` returns a real noindex missing-page response. Self-hosters should preserve equivalent policies and make sure `workspace-storage.js`, `preview-worker.js`, `sample.md`, `sw.js`, `manifest.json`, `script.js`, `sharing-crypto.js`, `sharing-media.js`, `sharing-live.js`, `styles.css`, `404.html`, `robots.txt`, `sitemap.xml`, `seo/`, `assets/`, `workers/`, and `functions/` or their platform equivalents are deployed according to the features they intend to use. Search metadata is generated for the root English URL and every supported `?lang=` URL. Each page has a self-canonical URL, reciprocal `hreflang` links, localized Open Graph/Twitter metadata, and localized structured data; `sitemap.xml` contains the complete alternate-language matrix. Run `npm run check:seo` to verify the committed sitemap or `npm run seo:sitemap` after changing `seo/locales.mjs`. diff --git a/wiki/FAQ.md b/wiki/FAQ.md index 0ec6ff38..081bd9ff 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -90,23 +90,23 @@ Yes. Preview HTML is sanitized with DOMPurify before insertion. Remote SVG outpu ### Are Share Snapshot links private? -No. They are bearer links. Anyone with the complete link can use its View only or Can edit capability. Stored snapshots expire after 90 days. +New snapshots are encrypted on the device. Anyone with the complete link, including its decryption key, can read the snapshot. Stored snapshots expire after 90 days; existing links retain their original lifetime. ### Can I delete a stored Share Snapshot early? -The API supports deletion with the token returned at creation. The current UI does not display that token or provide a Delete snapshot action, so normal UI-created snapshots remain until their 90-day expiry. See [Share Snapshot](Share-Snapshot.md). +Yes. Select **Delete snapshot** while the generated-link modal still retains its creation credential. Closing/resetting it loses that credential; expiry remains available. See [Share Snapshot](Share-Snapshot.md). ### Are managed-media URLs private? -No. They are content-addressed and difficult to guess, but anyone with the URL can retrieve the media until its 90-day expiry. There is no early-delete control. +New managed media is encrypted before upload. The complete URL includes its decryption key; anyone with that URL can read the media. Each upload expires after 90 days. Existing URLs retain their original lifetime. There is no early-delete control. ### Is Live Share end-to-end encrypted? -No. Treat every invitation as a sensitive bearer link. +Yes, for new rooms. Document and Review updates, names, presence, cursors, and session controls are encrypted in clients and signed by role. Protect the complete invite link. Operational metadata remains visible to the relay; encryption does not protect a compromised client or prevent recipients copying content. ### Does Live Share store the Document? -It does not persist Markdown or Review content server-side. Connected clients exchange it through a Durable Object WebSocket relay. The Durable Object does persist host/edit/view capability values and `createdAt` without an application TTL or deletion route. +The relay does not persist document or Review content. New rooms store capability hashes, public verification keys, expiry, and ended status for at most 24 hours. Existing rooms keep their compatibility path. ## Markdown, Renderers, and Editing diff --git a/wiki/Live-Share-Cloudflare.md b/wiki/Live-Share-Cloudflare.md index f78adf9f..9dae67e9 100644 --- a/wiki/Live-Share-Cloudflare.md +++ b/wiki/Live-Share-Cloudflare.md @@ -1,98 +1,45 @@ -# Live Share Rooms for Markdown Collaboration on Cloudflare +# Encrypted Live Share on Cloudflare -Live Share is the temporary Markdown collaboration feature in Markdown Viewer. It is separate from Share Snapshot. Share Snapshot creates a point-in-time link; Live Share relays Yjs updates through a WebSocket room. +Live Share provides temporary collaboration with end-to-end encrypted document updates, Review threads, display names, presence, cursor positions, and session controls. It uses a WebSocket relay and locally bundled Yjs. -## User Flow +## Start and Join -1. The host clicks Live Share. -2. The host enters or accepts a display name. -3. The host chooses Can edit or View only. -4. The app creates a random room id and a random room secret. -5. The app creates separate Yjs documents for Markdown/session state and Review threads. -6. The host opens a WebSocket to `/live-room/?secret=`. -7. The host connection establishes separate host, edit, and view capabilities before the invite link is shown. -8. The invite link includes the selected role and capability for the recipient. Participants open it, join a temporary live tab, request the current Yjs state, and render participant avatars/cursors. -9. The host can end the room for everyone. +1. Open a Document, select **Live Share**, and choose **Can edit** or **View only**. +2. Start the room. The browser creates a read key, independent role signing keys, and separate host/edit/view connection capabilities. +3. After the relay authenticates the host, copy the complete invite link to a trusted recipient. +4. Participants decrypt and synchronize locally in temporary tabs. Their role controls whether they can edit Markdown; both roles can contribute Review feedback. +5. Export any needed copies and let the host **End session** when finished. -The invite URL contains the room id, secret, title, selected role, and that role's bearer capability. It does not embed the full Markdown Document body. +The invite fragment carries the read key, the selected guest role's signing key and capability, and public verification information. It never includes the host signing key. Anyone receiving an invite can use or forward its authority, so protect complete links. -## Cloudflare Runtime +## Runtime and Authorization -There are two checked-in Cloudflare entry points: +The Pages route `functions/live-room/[[room]].js` and standalone `workers/live-room-worker.js` route new `room2_` rooms into a separate Durable Object namespace. Connections use `/live-room/?v=2`; no key or capability is in the request URL. An initial WebSocket authentication frame carries the role capability and public room configuration. Only capability hashes, public verification keys, expiry, and ended status persist in `live-room-auth-v2`. -- `functions/live-room/[[room]].js` is the Pages Function endpoint for `/live-room/`. -- `workers/live-room-worker.js` contains the `LiveRoom` Durable Object class and a Worker export. +Each client derives a fresh per-session AES-GCM key from the room read key with HKDF-SHA-256. Monotonic counters form unique nonces within that session. Frames bind room, sender, session, role, type, and sequence as authenticated data and are signed with the role's ECDSA P-256 key. The relay and recipients verify signatures and allowed message types; recipients also authenticate ciphertext and reject replays. Host-only controls use a separate Yjs document. -The Pages Function: +Viewers can send presence, synchronization requests, and Review changes. Editors can also send document updates. Only the host can send room controls, full Review state, or end the room. Unsupported or malformed encrypted traffic closes the connection without a fallback. -- Requires a WebSocket upgrade request. -- Allows the production app, HTTPS `*.markdownviewer.pages.dev` preview deployments, `null`, and localhost/127.0.0.1 development `Origin` values. -- Requires a `LIVE_ROOMS` Durable Object binding. -- Rejects room names over 160 characters. -- Rejects secrets over 256 characters. -- Uses `roomName + ":" + secret` to select the Durable Object id. -- Returns `Cache-Control: no-store` for non-upgrade status responses. +## Retention and Limits -The Durable Object: +| Item | Behavior | +| :--- | :--- | +| Document and Review history | Not persisted by the relay | +| Room authentication record | Expires 24 hours after creation; an alarm removes it | +| End session | Persists ended status, relays the signed end event, and closes peers; rejoining is rejected | +| Maximum participants | 64, including pending authentication | +| Authentication deadline | 5 seconds | +| Application payload | Up to 8 MiB before encryption; bounded encoded frames and queues | +| Participant stale timeout | 45 seconds | +| Initial state timeout | 8 seconds | -- Accepts the WebSocket pair. -- Authenticates the host capability when the room is first created and checks edit/view capabilities against the stored room credentials for later connections. -- Persists the raw host, edit, and view capability values plus `createdAt` in Durable Object storage under `live-room-auth-v1`. -- Assigns a temporary socket participant id. -- Relays only known message types: `hello`, `presence`, `sync-request`, `sync-state`, `y-update`, `review-sync-request`, `review-sync-state`, `review-update`, `leave`, and `session-end`. -- Filters messages by role: viewers can send presence, sync requests, and Review updates but not Markdown updates or session-end; editors can send Markdown and Review updates; only the host can publish full Review state and every supported message type. -- Adds `roomId` and `sentAt` to normalized messages. -- Broadcasts messages to other sockets in the same room. -- Broadcasts `leave` when a socket closes or errors. +The host supplies initial state. Once no client retains content, the relay cannot recover it. Existing rooms continue through the compatibility path; their records and lifetime are not rewritten. New legacy room creation is disabled. -## Limits +## Privacy Boundaries -| Limit | Value | -| :--- | :--- | -| Max live message size | 8 MB | -| Max WebSocket participants per room | 64 | -| Participant stale timeout in client UI | 45 seconds | -| Client join timeout | 8 seconds | -| Room name length at Pages Function | 160 characters | -| Secret length at Pages Function | 256 characters | - -If the room is full, the Durable Object returns HTTP 429. If credentials or bindings are missing, the endpoints return a plain error response. - -## Data Handling - -Live Share relays: - -- Yjs document updates. -- Anchored comments, threaded replies, participant identities, and thread status updates through a separate Yjs document. -- Initial sync state. -- Display names. -- Participant presence. -- Cursor positions. -- Leave events. -- Host session-end events. - -Live Share does not write Markdown or Review Document content to Cloudflare KV or Durable Object storage. Those updates exist in connected clients and WebSocket relay traffic. The Durable Object does persist the role capability record described above. The normal local Workspace remains local, and joined live tabs are temporary so they are not saved into the participant's tab storage. - -## Room Lifecycle and Recovery - -- The host's client supplies the initial Yjs state to joining participants. -- If no active client can supply initial state, the participant times out after 8 seconds and sees an ended/expired/unavailable message. -- The server does not retain a Markdown or Review copy for recovery after all content-bearing clients leave. -- The host's **End session** action broadcasts `session-end` and disconnects active clients. -- Ending a session does not delete the Durable Object capability record. -- No application TTL, alarm, or deletion route is implemented for that capability record. - -“Expired room” in the client is therefore an availability message, not proof that all server-side capability metadata was deleted. - -## Privacy and Security Notes - -- The room secret is part of the invite URL. Anyone with the link can try to join while the room is active. -- No end-to-end encryption is implemented in the app. -- View only and Can edit roles are checked by the Durable Object, which filters message types by capability. Each capability is still a bearer credential, so treat role-specific invite links as sensitive and do not paste them into public channels. -- The server rejects unsupported WebSocket origins, but origin checks do not replace authentication or end-to-end encryption. -- The host should end the room when collaboration is finished and every participant should export any copy they need before leaving. -- Capability metadata remains in Durable Object storage after the active session ends. -- Cloudflare deployment logs and platform behavior are controlled by the deployer's Cloudflare account configuration. +The relay handles ciphertext and operational metadata, including room identifiers, roles, message types, timing, and sizes. TLS protects transport and connection capabilities. Encryption does not hide metadata, prevent recipients copying content, provide forward secrecy, or protect a compromised browser/device/application delivery path. There is no account-based recipient verification. + +Encrypted shared previews block automatic external assets and remote rendering; advanced diagrams/maps display local source. Keys stay in the client and private invite fragment. Keep exports and complete links private. See [Privacy and Security](Privacy-and-Security.md) and [protocol and rollout notes](../docs/ENCRYPTED_SHARING.md). ## Required Configuration diff --git a/wiki/Privacy-and-Security.md b/wiki/Privacy-and-Security.md index e173c4d2..5369549a 100644 --- a/wiki/Privacy-and-Security.md +++ b/wiki/Privacy-and-Security.md @@ -11,9 +11,9 @@ Markdown Viewer is local-first: normal editing, Preview rendering, local file im - Secret Workspace encrypts its stored files and folder names on the device. - Network-backed features send only the data needed for that feature, but the receiving service or deployment platform can log requests according to its own policy. - Share Snapshot and Live Share URLs are bearer links. Possession of a valid link grants the capability encoded in it. -- Live Share is not end-to-end encrypted. +- New Share Snapshot, managed-media, and Live Share payloads are encrypted on the device before transmission. -> **Privacy:** Do not place sensitive Markdown in a remote-rendered diagram, managed-media upload, stored Share Snapshot, or Live Share room unless you accept the stated service and bearer-link boundaries. +> **Privacy:** Protect complete share links: their fragments contain decryption keys. Encrypted shared previews block automatic external assets and remote renderers. Ordinary documents can still contact remote services when those features are used. ## Data Stored on the Device @@ -94,55 +94,37 @@ representative devices before broad deployment. | Remote diagrams | Diagram source or encoded source | PlantUML, Kroki, or mermaid.ink; response can be browser-cached | Remote renderer receives the source | | GeoJSON/TopoJSON maps | Tile and external-asset requests | Configured map-tile or asset host | Remote host | | External Markdown assets | Image, media, font, and link requests | Author-specified host | Remote host | -| Managed media | Optimized image, GIF, or video after first-use consent | Cloudflare KV for 90 days from the latest upload of identical content | Public to anyone with the unguessable URL | -| Stored Share Snapshot | Markdown content, mode, title, creation time, and size | Cloudflare KV for 90 days | Bearer snapshot URL | -| Live Share | Yjs Markdown/Review updates, display name, presence, cursor, sync, leave, and session-end messages | Cloudflare Durable Object relay while clients are connected | Room secret plus host/edit/view capability | -| Live Share capability metadata | Host, edit, and view bearer capability values plus `createdAt` | Durable Object storage; no application TTL or deletion path is implemented | Used by the Durable Object to authenticate roles | +| Managed media | Encrypted image, GIF, or video after first-use consent | Cloudflare KV for 90 days per upload | Complete URL includes the client decryption key | +| Stored Share Snapshot | Encrypted Markdown, mode, and title; creation metadata and deletion-token hash | Cloudflare KV for 90 days | Complete URL includes the client decryption key | +| Live Share | Encrypted and signed document/Review updates, names, presence, cursors, and controls | Cloudflare Durable Object relay while clients are connected | Read key, role signing key, and role capability | +| Live Share authentication metadata | Capability hashes, public role keys, room expiry, ended status | Durable Object storage for 24 hours in new rooms | Used to authenticate connections and verify signatures | The local GitHub credential vault protects PAT values from plaintext storage and keeps them out of workspace backups. It is not an operating-system keychain: malicious code running in the application origin, a compromised browser/app profile, a hostile extension, or malware on the unlocked device could still use the locally stored key. Remove the local entry and revoke the PAT in GitHub if the device, profile, or token may be compromised. Cloudflare, CDN, GitHub, renderer, map, external-asset, reverse-proxy, and self-hosting operators can retain normal service logs independently of Markdown Viewer's application-level storage. -## Managed Media +## Encrypted Sharing and Managed Media -Supported managed media includes AVIF, BMP, GIF, JPEG, PNG, WebP, MP4, WebM, and Ogg. The client asks for consent before its first managed upload. +New snapshots encrypt Markdown, titles, and access mode using AES-256-GCM with an independent random key. New managed uploads encrypt validated image/video data before transmission, including its media type. New live rooms encrypt document and Review updates, identities, presence, cursors, and controls, and verify role-based signatures. Keys remain in clients and complete share-link fragments; API requests and WebSocket URLs do not carry decryption or private signing keys. -- Source files are limited to 25 MiB before processing. -- Stored still images are limited to 300 KiB after optimization. -- Stored GIFs are limited to 5 MiB. -- Stored videos are limited to 10 MiB. -- The API validates declared media type and file signature. -- The content-derived URL is difficult to guess but is not an authorization mechanism. -- Uploading identical content reuses its identifier and refreshes the 90-day expiry. -- Share Snapshot and Live Share include the URL in Markdown; they do not make a second media copy. -- After expiry, the Markdown reference remains but the media stops loading. +Managed media supports AVIF, BMP, GIF, JPEG, PNG, WebP, MP4, WebM, and Ogg. The client validates file types and signatures. Source files are limited to 25 MiB, optimized still images to 300 KiB, GIFs to 5 MiB, and videos to 10 MiB. Each upload has a random identifier and expires after 90 days; repeated content creates a separate record. There is no managed-media deletion control. -There is no user deletion token for managed media. Expiry is the implemented removal mechanism. +Small snapshots carry ciphertext in the fragment. Larger snapshots store ciphertext in Cloudflare KV for 90 days, with a separate deletion-token hash. **Delete snapshot** is available while the current generated-link modal retains its token. Closing/resetting it loses that in-memory credential. Deletion cannot remove copies already downloaded or separately stored media. -## Share Snapshot +New live rooms store only capability hashes, public verification keys, expiry, and ended status. Their 24-hour deadline is fixed at creation. The relay does not persist document history. Ending a room rejects rejoining; an expiry alarm removes its authentication record. Existing shares, media, and rooms retain their compatibility behavior and original lifetime, with no migration or expiry extension. -Small Share Snapshot content remains in the URL hash as `#share=...`. A hash fragment is not included in the normal HTTP request to open the page, although it can be exposed when a user copies the URL into another system, browser history is synchronized, extensions inspect the page, or the page itself processes the hash. +Encrypted shared previews block automatic external images/media, remote diagrams, maps, and renderer-specific network loading. Advanced content is shown as local source, and managed encrypted media decrypts to local blob URLs. Explicit link navigation and user-selected exports remain available. Web Markdown and encrypted-share HTML exports inline decrypted attachments; exported documents are readable files. -Stored snapshots use `#id=...` and Cloudflare KV. They expire after 90 days. The Share API returns a deletion token to the creating client and stores only its hash. The current application UI does not display the token or offer an early-delete control; an API client must capture the creation response and call `DELETE /api/share/` with that token. +## Encryption Boundaries -View only prevents normal editing in the client. Can edit lets the recipient change their temporary local copy. Neither mode is an authentication boundary, and Can edit snapshots are not real-time collaboration. +- Anyone with a complete valid link can decrypt content and forward that authority. View-only UI controls do not prevent copying or screenshots. +- Lost keys cannot be recovered by the service. Missing keys, unsupported versions, or failed authentication do not fall back to another format. +- Room identifiers, roles, message types, ciphertext sizes, timing, network addresses, and normal platform logs remain operational metadata. +- TLS remains necessary for transport and connection capabilities. Encryption does not provide forward secrecy or protect a compromised device, browser extension, application origin, or application delivery path. +- Role signatures distinguish host/editor/viewer authority; they do not verify a person's real-world identity. Participants sharing the same role credential share that authority. +- The implementation has automated security and integration coverage; it is not a claim of an independent cryptographic audit. -See [Share Snapshot](Share-Snapshot.md). - -## Live Share - -The Live Share relay does not persist Markdown or Review document state to KV or Durable Object storage. It does persist the host/edit/view capability record and `createdAt` in Durable Object storage so sockets can be authenticated. - -- Hosts, editors, and viewers use distinct bearer capabilities. -- The server filters supported message types by role. -- Viewers cannot send Markdown updates or end the session; they can send presence, sync requests, and Review updates. -- The room secret and selected role capability are included in the invite URL. -- Anyone with a valid invite can attempt to join while a host is available. -- There is no end-to-end encryption. -- Ending a session broadcasts a client event and disconnects participants; it does not delete the stored capability record. -- No application TTL, explicit capability-record deletion, or server-side document history is implemented. - -See [Live Share](Live-Share-Cloudflare.md). +See [Share Snapshot](Share-Snapshot.md), [Live Share](Live-Share-Cloudflare.md), and [protocol and rollout notes](../docs/ENCRYPTED_SHARING.md). ## Content Sanitization @@ -215,8 +197,8 @@ See the [Security Policy](../SECURITY.md) for supported versions and vulnerabili ## Known Limitations - Bearer links can be forwarded, logged by surrounding systems, copied into chat, or exposed through browser synchronization. -- Live Share is not end-to-end encrypted and persists capability metadata without an application TTL. -- The current Share Snapshot UI does not expose the early-deletion token. +- New live rooms encrypt content and retain authentication metadata for 24 hours; complete links still grant bearer access. +- Snapshot deletion authority is kept only in the current generated-link modal; closing/resetting it loses that credential. - Managed media has no early-delete control. - Private mode preserves persisted Secret Workspace data. Reset workspace and Reset Secret Workspace are irreversible after confirmation. - Security headers differ by deployment target and can be weakened by self-hosting changes. diff --git a/wiki/Share-Snapshot.md b/wiki/Share-Snapshot.md index fbfa88a0..cc1c9b48 100644 --- a/wiki/Share-Snapshot.md +++ b/wiki/Share-Snapshot.md @@ -1,124 +1,50 @@ # Share Snapshot -Share Snapshot creates a point-in-time copy of the active Document. Use it when a recipient needs to read or edit a copy without joining a Live Share room. +Share Snapshot creates an encrypted, point-in-time copy of the active Document. Choose **View only** or **Can edit**, select **Share**, and send the complete link through a trusted channel. Changes to a snapshot do not synchronize with the sender. -Share Snapshot is not real-time collaboration. Changes made by the sender or recipient after link creation do not synchronize. +## Encryption and Access -## Create a Snapshot +New snapshots encrypt the Markdown, title, and access mode on the device with AES-256-GCM. Each snapshot has an independent random key carried in the URL fragment. The application never sends that key to the share API. Recipients decrypt locally. -1. Open the Document you want to share. -2. Select **Share Snapshot**. -3. Choose **View only** or **Can edit**. -4. Select **Share**. -5. Copy the generated URL and send it through a channel you trust. +Anyone with the complete link can decrypt its content; keep it private. View only restricts the application interface, while Can edit permits changes to a temporary local copy. Neither prevents a recipient from copying content or forwarding the link. Temporary snapshot tabs are excluded from normal Workspace persistence. Export edits before leaving. -The application selects URL-hash or Cloudflare KV storage automatically according to content size and encoded URL length. +## Storage and Retention -> **Privacy:** A Share Snapshot is a bearer link. Anyone who obtains the complete URL can open the capability it contains. There is no account check or recipient allowlist. - -## Access Modes - -| Mode | Recipient experience | What it does not provide | -| :--- | :--- | :--- | -| **View only** | Opens a temporary tab in Preview with editing actions blocked | Authentication, access revocation, or protection against link forwarding | -| **Can edit** | Opens a temporary tab in Split view and lets the recipient edit that local copy | Real-time synchronization or write access to the sender's original Document | - -The recipient's temporary snapshot tab is not written into normal Workspace tab storage. Closing or reloading can discard edits unless the recipient exports the Markdown. - -The application blocks creating another Share Snapshot from a temporary snapshot. Only the host can create a snapshot from a Live Share Document. - -## Storage Modes - -| Property | URL-hash snapshot | Stored snapshot | +| Property | Inline snapshot | Stored snapshot | | :--- | :--- | :--- | -| URL form | `#share=` | `#id=` | -| Selection rule | Used when Markdown is below 3,000 UTF-8 bytes and the encoded URL is no longer than 4,096 characters | Used at 3,000 bytes or more, or when the encoded URL exceeds 4,096 characters | -| Content location | Compressed and base64url-encoded inside the link | Cloudflare KV through `/api/share` | -| Retention | No server-side record created by Markdown Viewer; the copied URL itself remains usable | 90 days | -| Maximum content | Constrained by practical browser, clipboard, and URL handling | 8,000,000 characters | -| Early deletion | Delete every copy of the link that you control | Available through the API only when the caller retained the creation deletion token | -| Server request when opening | The URL hash is not part of the normal HTTP request; the page processes it locally | The application requests `/api/share/` | - -The URL-hash title and access flag are also stored in the fragment. Hash fragments can still be exposed through copied messages, browser synchronization/history, extensions, screenshots, logs from surrounding tools, or a person who forwards the URL. - -## Stored Snapshot Record - -The Share API stores: - -- Markdown content; -- access mode; -- sanitized title; -- creation time; -- character count; and -- a hash of the deletion token. - -The record is written to `SHARE_KV` with a 90-day time to live. The API response uses `Cache-Control: no-store`. +| URL | `#share=v2.&key=` | `#id=s2_&key=` | +| Selection | Below 3,000 UTF-8 content bytes and at most 4,096 URL characters | Larger content or URL | +| Stored data | Ciphertext in the link | Ciphertext, creation time, format version, and deletion-token hash in Cloudflare KV | +| Lifetime | The link itself remains usable | 90 days from creation | +| Payload limit | Constrained by URL size | 8 MiB of UTF-8 JSON, including title and mode | +| Early deletion | No server record to delete | **Delete snapshot** in the current generated-link modal | -Creating a stored snapshot returns: +Fragments are excluded from normal HTTP requests, but complete links can appear in browser history, clipboard managers, extensions, and messaging systems. Losing the key means the content cannot be recovered through the service. -- a random snapshot id; -- a deletion token; and -- the expiry duration. - -The deletion token is returned only at creation. The stored record contains its SHA-256 hash, not the original token. +Existing links keep their existing format and expiry. Reading them does not rewrite records or extend their lifetime. New creation requires the encrypted format; refresh an older client if creation requests an upgrade. ## Delete a Stored Snapshot -The API supports: - -```http -DELETE /api/share/ -Content-Type: application/json - -{"deleteToken":""} -``` - -> **Important:** The current Markdown Viewer UI does not display or export the deletion token and does not provide a Delete snapshot action. It keeps the token only in page memory while the generated link state exists. Closing/resetting the modal or leaving the session loses normal UI access to it. - -An API client that needs early deletion must call `POST /api/share`, securely capture the `deleteToken` from the response, keep it separate from the shared URL, and later submit it to the `DELETE` route. Without the token, wait for the 90-day expiry. - -Deleting a snapshot does not delete managed images, GIFs, or videos referenced by the Markdown. Managed media has separate content-addressed records and its own 90-day expiry. +The creator can select **Delete snapshot** while the generated-link modal retains its deletion credential. Closing/resetting the modal or leaving the page loses that in-memory credential. The deletion token is independent of the decryption key and is never included in recipient links. -## Images and Other External Content +API clients can retain the creation response's `deleteToken` separately and submit `DELETE /api/share/` with JSON `{"deleteToken":"..."}`. The service stores only its hash. Deletion removes the stored snapshot, not previously downloaded copies or separately stored media. Without the token, the original 90-day expiry applies. -Share Snapshot copies Markdown, not every referenced resource. +## Media and Rendering -- Managed media remains available through its public-by-link URL until that media record expires. -- External images, media, fonts, and links are requested from their original hosts. -- Legacy inline data images increase the snapshot size and can force stored-snapshot mode. -- Comments are not included. -- A remote diagram fence is re-rendered by the recipient's browser and can send its source to a renderer. +New managed images, GIFs, and videos are encrypted separately before upload. Their complete URLs, including media keys, are inside the encrypted Markdown. The browser retrieves ciphertext without the key and renders decrypted bytes locally. Media expires 90 days after its own upload. -## Security Boundaries +Encrypted shared previews block automatic external media and remote diagram requests. Advanced diagrams/maps are shown as local source; explicit link navigation remains available. Upload required images through the encrypted media flow before sharing. Older managed URLs are preserved for existing documents but are not automatically loaded in encrypted shared previews. Review comments are not included in snapshots. -- Snapshot ids and encoded content are difficult to guess but are not authentication. -- View only is enforced by the client interface; it does not encrypt or hide the Markdown from someone who controls their browser. -- Origin checks restrict supported browser origins for API creation and access, but requests without an `Origin` header are accepted and bearer-link secrecy remains essential. -- Allowed application origins include the production site, HTTPS `*.markdownviewer.pages.dev` previews, `null`, and local `localhost`/`127.0.0.1` development origins. -- Stored snapshots are not end-to-end encrypted by the application. -- Cloudflare and the deployer can process normal platform and request logs. - -Do not use Share Snapshot as a confidential-document access-control system. +Web Markdown exports inline decrypted managed media. Encrypted-share HTML exports use local content, inline media, and a restrictive content security policy without scripts. Exported files contain readable content: protect them as documents. ## Failure and Recovery -| Symptom | Likely cause | Action | -| :--- | :--- | :--- | -| “Failed to create share link” | Pako or `/api/share` unavailable, missing `SHARE_KV`, disallowed origin, network error, or oversized content | Check the console/network request, configuration, origin, and 8,000,000-character limit | -| Stored link returns not found | Invalid id, early deletion, or 90-day expiry | Ask the sender for a new snapshot | -| Can edit copy disappeared | Snapshot tabs are temporary | Export Markdown before closing or reloading | -| Image is missing | Managed-media expiry, unavailable external host, or blocked request | Replace the resource and create a new snapshot | -| Remote diagram is missing | Renderer unavailable, CSP restriction, or invalid source | Check the renderer network request and fence syntax | - -See [Troubleshooting](Troubleshooting.md) for broader diagnostics. - -## API Summary - -| Method and route | Purpose | +| Symptom | Action | | :--- | :--- | -| `POST /api/share` | Create a stored snapshot | -| `GET /api/share/` | Load a stored snapshot | -| `DELETE /api/share/` | Delete a stored snapshot with its deletion token | -| `OPTIONS /api/share[/]` | CORS preflight | +| Incomplete link or decryption failure | Ask for the entire original link, including everything after `#`; there is no plaintext fallback | +| Stored snapshot missing | Check expiry or deletion; ask the sender for a new snapshot | +| Creation unavailable | Refresh the app and check connectivity, secure-context Web Crypto support, deployment bindings, and payload size | +| External image or diagram source shown | Expected private preview behavior; use encrypted managed attachments | +| Managed attachment missing | Check the complete media key, connection, and its separate expiry | -Deployment details are in [Configuration](Configuration.md#share-api). For the product-wide data boundary, see [Privacy and Security](Privacy-and-Security.md). +See [Privacy and Security](Privacy-and-Security.md), [Configuration](Configuration.md#share-api), and the [protocol and rollout notes](../docs/ENCRYPTED_SHARING.md). diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 64eecbcd..6d057271 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -182,19 +182,19 @@ The final print layout is controlled by the browser or operating system. Differe ## Share Snapshot Fails -- Confirm that Pako loaded. +- Confirm secure-context Web Crypto support and the complete link, including its key. Pako is needed only for legacy inline links. - For stored mode, verify `/api/share`, `SHARE_KV`, request origin, and network access. -- Keep stored content at or below 8,000,000 characters. +- Keep the encrypted snapshot plaintext payload at or below 8 MiB of UTF-8 JSON. - A not-found stored link is invalid, deleted, or expired after 90 days. - A snapshot tab is temporary. Export Markdown before closing or reloading if you edited it. -- The current UI cannot delete a stored snapshot early because it does not expose the API deletion token. +- **Delete snapshot** is available while the generated-link modal retains its creation credential. After closing/resetting it, the original expiry applies. See [Share Snapshot](Share-Snapshot.md). ## Live Share Fails or Reports an Expired Room - Ask the host to keep the room and original host Document open. -- Confirm the complete invite URL, including room secret, role, and capability. +- Confirm the complete invite URL, including its key and role credentials after `#`. New rooms expire after 24 hours. - Verify that `/live-room/` upgrades to WebSocket. - Check `LIVE_ROOMS`, Worker deployment, Durable Object migration, and allowed Origin. - Join within the 8-second initial-state window. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 3ec0c366..6bdf8b9b 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -172,7 +172,7 @@ Scope matching uses Marked's lexer and is best-effort for unusual Markdown. Use Import > From files, the mobile import button, or drag and drop to save local Markdown files in Explorer. Imported documents remain closed until you select one in Explorer, so large imports do not fill the tab bar. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. -Paste an image or GIF from the clipboard, drop an image/GIF/video file, or use the media dialog to insert it at the current editor cursor. A progress toast shows preparation and upload status. Still images are optimized, animated GIFs retain their original animation, and MP4, WebM, or Ogg videos are inserted as playable HTML5 media. After first-use consent, the app uploads the file to managed public media storage and inserts a short HTTPS link. Anyone with that unguessable URL can retrieve the media for up to 90 days; after expiry the reference remains in Markdown but the media stops rendering. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. +Paste an image or GIF from the clipboard, drop an image/GIF/video file, or use the media dialog to insert it at the current editor cursor. A progress toast shows preparation and upload status. Still images are optimized, animated GIFs retain their original animation, and MP4, WebM, or Ogg videos are inserted as playable HTML5 media. After first-use consent, the app encrypts the file before managed upload and inserts an HTTPS link with a decryption key in its fragment. Anyone with the complete URL can decrypt the media for up to 90 days; after expiry the reference remains in Markdown but the media stops rendering. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. - Managed still images are limited to 300 KiB after optimization. - Animated GIFs are limited to 5 MiB, and managed videos to 10 MiB. @@ -227,9 +227,9 @@ Use Share Snapshot when you want to send a point-in-time copy. View only opens the Document in Preview and hides editing. Can edit opens the copy in Split view so the recipient can edit their own local copy. It is not real-time collaboration. -Small Documents are compressed into the URL hash as `#share=...`. Documents of at least 3,000 UTF-8 bytes, or whose encoded URL exceeds 4,096 characters, are stored through `/api/share` and opened with `#id=...`. Stored snapshots use Cloudflare KV for up to 90 days and can contain up to 8,000,000 characters. They are bearer links: anyone with the URL can open the snapshot. +New snapshots encrypt the Markdown, title, and access mode on the device. Small encrypted payloads use `#share=v2...&key=...`. Documents of at least 3,000 UTF-8 bytes, or whose encoded URL exceeds 4,096 characters, are stored through `/api/share` and opened with `#id=s2_...&key=...`. Stored snapshots use Cloudflare KV for up to 90 days and can contain up to 8 MiB of UTF-8 JSON before encryption. They are bearer links: anyone with the URL can open the snapshot. -The creation API returns a deletion token, but the current UI does not display it or provide an early-delete action. Normal UI-created stored snapshots therefore remain until expiry. See [Share Snapshot](Share-Snapshot.md). +The creation API returns a separate deletion token. **Delete snapshot** is available while the generated-link modal retains that credential; after closing/resetting it, the original expiry applies. See [Share Snapshot](Share-Snapshot.md). Shared snapshot tabs are temporary and are not saved into the recipient's workspace. @@ -243,7 +243,7 @@ Use Live Share when you want a temporary real-time room. 4. Start the session. 5. Copy the invite link after the room starts. -Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not persist Markdown or comment content server-side. The Durable Object does persist the host, edit, and view capability values plus their creation time. The invite URL contains a room id, room secret, access role/capability, and title, not the full Document body. The server authenticates host, Can edit, and View only capabilities and filters message types by role. Markdown and comment data use separate Yjs documents, so View only participants can synchronize comments without being allowed to edit Markdown. +New Live Share rooms encrypt and sign Yjs updates before relaying them through a Cloudflare Durable Object. The relay stores capability hashes, public verification keys, expiry, and ended status for 24 hours, without document history. The complete invite fragment contains the read key and selected role credentials. Markdown, Review, and host controls use separate channels, so View only participants can contribute comments without editing Markdown. Encrypted shared previews block automatic external resources and show advanced diagram source locally. Existing shares keep their original lifetime. Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages. Managed images, GIFs, and videos travel as short HTTPS links rather than binary Live Share messages and stop rendering when their 90-day storage TTL expires. diff --git a/workers/live-room-worker.js b/workers/live-room-worker.js index b7f4ad04..d34c6f5c 100644 --- a/workers/live-room-worker.js +++ b/workers/live-room-worker.js @@ -1,4 +1,8 @@ +import '../sharing-crypto.js'; +const Sharing = globalThis.MarkdownShareCrypto; const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; +const MAX_ENCRYPTED_MESSAGE_BYTES = Math.ceil((Sharing.LIVE_LIMIT + 16) * 4 / 3) + 4096; +const ENCRYPTED_AUTH_KEY = 'live-room-auth-v2'; const MAX_PARTICIPANTS = 64; const AUTH_STORAGE_KEY = "live-room-auth-v1"; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{24,160}$/; @@ -132,6 +136,7 @@ export class LiveRoom { const session = this.sessions.get(socket); if (!session) return; this.sessions.delete(socket); + if (session.encrypted) return; this.broadcast({ type: "leave", sender: session.participantId, @@ -155,10 +160,8 @@ export class LiveRoom { return null; } - if (!auth) { - auth = { hostCap, editCap, viewCap, createdAt: Date.now() }; - await this.state.storage.put(AUTH_STORAGE_KEY, auth); - } + // Existing rooms remain compatible. New rooms require protocol v2. + if (!auth) return null; if (auth.hostCap !== capability) return null; return ROLE_HOST; } @@ -190,6 +193,8 @@ export class LiveRoom { }); } + if (url.searchParams.get('v') === '2') return this.fetchEncrypted(request); + const roomId = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() || ""); const secret = url.searchParams.get("secret") || ""; if (!roomId || !secret) { @@ -254,6 +259,106 @@ export class LiveRoom { webSocket: client }); } + + async authenticateEncrypted(message, roomId) { + const role = message.role; + if (!Sharing.exactKeys(message, role === 'host' + ? ['type', 'v', 'role', 'sender', 'session', 'config', 'capability', 'capabilities'] + : ['type', 'v', 'role', 'sender', 'session', 'config', 'capability'])) return null; + if (!['host', 'edit', 'view'].includes(role) || !isValidCapability(message.capability) || + typeof message.sender !== 'string' || !/^[A-Za-z0-9_-]{16,120}$/.test(message.sender) || + typeof message.session !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(message.session)) return null; + let config; + try { config = Sharing.publicConfig(message.config, roomId); } catch (_) { return null; } + if (config.expiresAt <= Date.now() || config.expiresAt > Date.now() + 86400000) return null; + const providedHash = await Sharing.hash(message.capability); + let hashes = null; + if (role === 'host') { + const caps = message.capabilities; + if (!Sharing.exactKeys(caps, ['host', 'edit', 'view']) || !['host', 'edit', 'view'].every(name => isValidCapability(caps[name])) || + new Set(Object.values(caps)).size !== 3 || caps.host !== message.capability) return null; + hashes = {}; + for (const name of ['host', 'edit', 'view']) hashes[name] = await Sharing.hash(caps[name]); + } + const auth = await this.state.storage.transaction(async txn => { + let current = await txn.get(ENCRYPTED_AUTH_KEY); + if (!current) { + if (role !== 'host') return null; + current = { config, hashes, expiresAt: config.expiresAt, ended: false }; + await txn.put(ENCRYPTED_AUTH_KEY, current); + } + if (current.ended || current.expiresAt <= Date.now() || current.hashes[role] !== providedHash || + JSON.stringify(current.config) !== JSON.stringify(config)) return null; + return current; + }); + if (auth) await this.state.storage.setAlarm(auth.expiresAt); + return auth; + } + + async fetchEncrypted(request) { + const roomId = new URL(request.url).pathname.split('/').filter(Boolean).pop() || ''; + if (!/^room2_[A-Za-z0-9_-]{24}$/.test(roomId)) return new Response('Invalid room', { status: 400 }); + if (this.sessions.size >= MAX_PARTICIPANTS) return new Response('Room is full', { status: 429 }); + const [client, socket] = Object.values(new WebSocketPair()); + socket.accept(); + const session = { encrypted: true, roomId, authenticated: false, pending: 0, pendingBytes: 0, sequence: 0, chain: Promise.resolve() }; + this.sessions.set(socket, session); + const timeout = setTimeout(() => { + if (!session.authenticated) { socket.close(1008, 'Authentication timeout'); this.sessions.delete(socket); } + }, 5000); + const close = () => { clearTimeout(timeout); this.sessions.delete(socket); }; + socket.addEventListener('close', close); + socket.addEventListener('error', close); + socket.addEventListener('message', event => { + if (typeof event.data !== 'string' || event.data.length > (session.authenticated ? MAX_ENCRYPTED_MESSAGE_BYTES : 16384) || + session.pending >= 100 || session.pendingBytes + event.data.length > MAX_ENCRYPTED_MESSAGE_BYTES * 2) { + socket.close(1009, 'Message limit'); close(); return; + } + session.pending += 1; + session.pendingBytes += event.data.length; + session.chain = session.chain.then(async () => { + if (!this.sessions.has(socket)) return; + const message = JSON.parse(event.data); + if (!session.authenticated) { + if (message.type !== 'authenticate' || message.v !== 2) throw new Error('Authentication required'); + const auth = await this.authenticateEncrypted(message, roomId); + if (!auth) throw new Error('Invalid credentials'); + Object.assign(session, { authenticated: true, role: message.role, participantId: message.sender, + cryptoSession: message.session, config: auth.config, expiresAt: auth.expiresAt }); + clearTimeout(timeout); + safeSend(socket, { type: 'ready', v: 2 }); + return; + } + if (session.expiresAt <= Date.now()) throw new Error('Room expired'); + Sharing.liveFrame(message); + if (message.roomId !== roomId || message.role !== session.role || message.sender !== session.participantId || + message.session !== session.cryptoSession || message.seq <= session.sequence) throw new Error('Invalid frame'); + await Sharing.verifyFrame(message, session.config); + session.sequence = message.seq; + if (message.type === 'session-end') { + await this.state.storage.transaction(async txn => { + const auth = await txn.get(ENCRYPTED_AUTH_KEY); + if (auth) { auth.ended = true; await txn.put(ENCRYPTED_AUTH_KEY, auth); } + }); + } + this.sessions.forEach((peer, peerSocket) => { + if (peerSocket !== socket && peer.authenticated) safeSend(peerSocket, message); + }); + if (message.type === 'session-end') { + for (const peerSocket of this.sessions.keys()) peerSocket.close(1000, 'Session ended'); + this.sessions.clear(); + } + }).catch(() => { socket.close(1008, 'Invalid encrypted room message'); close(); }) + .finally(() => { session.pending -= 1; session.pendingBytes -= event.data.length; }); + }); + return new Response(null, { status: 101, webSocket: client }); + } + + async alarm() { + for (const socket of this.sessions.keys()) socket.close(1000, 'Session expired'); + this.sessions.clear(); + await this.state.storage.deleteAll(); + } } export default { @@ -282,15 +387,20 @@ export default { }); } + const encrypted = url.searchParams.get('v') === '2'; + if ((url.searchParams.has('v') && !encrypted) || + (encrypted && (url.searchParams.size !== 1 || !/^room2_[A-Za-z0-9_-]{24}$/.test(roomId)))) { + return new Response('Unsupported live room protocol', { status: 400 }); + } const secret = url.searchParams.get("secret") || ""; - if (!secret) { + if (!encrypted && !secret) { return new Response("Missing live room credentials", { status: 400, headers: secureHeaders({ "Cache-Control": "no-store" }) }); } - const id = env.LIVE_ROOMS.idFromName(roomId + ":" + secret); + const id = env.LIVE_ROOMS.idFromName(encrypted ? 'encrypted-v2:' + roomId : roomId + ":" + secret); return env.LIVE_ROOMS.get(id).fetch(request); } };