1/* NIP-17 kind 15 attachments. File keys stay in the decrypted rumor and are2 never copied into markup, URLs, local storage, or the clipboard. */3(() => {4 "use strict";56 const MAX_BYTES = 256 * 1024 * 1024;7 const textDecoder = new TextDecoder();8 const sizeLabel = size => {9 if (size === null) return "";10 if (size < 1024) return size + " B";11 if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KiB";12 if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MiB";13 return (size / (1024 * 1024 * 1024)).toFixed(1) + " GiB";14 };15 const bytes = value => globalThis.tiny.util.bytes(value);16 const hex = value => globalThis.tiny.util.hex(value);17 const fromHex = (value, message) => {18 if (typeof value !== "string" || !/^[0-9a-f]+$/i.test(value) || value.length % 2)19 throw Error(message);20 return Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));21 };22 const tagValue = (tags, name) => {23 const tag = (Array.isArray(tags) ? tags : []).find(item => item?.[0] === name);24 return typeof tag?.[1] === "string" ? tag[1] : "";25 };2627 function parse(rumor) {28 if (!rumor || rumor.kind !== 15 || typeof rumor.content !== "string")29 throw Error("expected a NIP-17 file message");30 let url;31 try { url = new URL(rumor.content); } catch { throw Error("attachment URL is invalid"); }32 if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password)33 throw Error("attachment URL must be HTTP(S) without credentials");34 const algorithm = tagValue(rumor.tags, "encryption-algorithm").toLowerCase();35 if (algorithm !== "aes-gcm") throw Error("unsupported attachment encryption");36 const keyText = tagValue(rumor.tags, "decryption-key");37 const nonceText = tagValue(rumor.tags, "decryption-nonce");38 const hash = tagValue(rumor.tags, "x").toLowerCase();39 const key = fromHex(keyText, "attachment key is invalid");40 const nonce = fromHex(nonceText, "attachment nonce is invalid");41 if (key.length !== 32) throw Error("attachment key is invalid");42 if (nonce.length < 8 || nonce.length > 32) throw Error("attachment nonce is invalid");43 if (!/^[0-9a-f]{64}$/.test(hash)) throw Error("attachment hash is invalid");44 const plaintextHash = tagValue(rumor.tags, "ox").toLowerCase();45 if (plaintextHash && !/^[0-9a-f]{64}$/.test(plaintextHash)) throw Error("plaintext hash is invalid");46 const sizeText = tagValue(rumor.tags, "size");47 const size = sizeText ? Number(sizeText) : null;48 if (size !== null && (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES))49 throw Error("attachment is too large");50 return Object.freeze({51 url: url.href,52 mime: tagValue(rumor.tags, "file-type") || "application/octet-stream",53 name: tagValue(rumor.tags, "filename") || tagValue(rumor.tags, "name") || "attachment",54 hash,55 plaintextHash,56 size,57 key,58 nonce59 });60 }6162 async function responseBytes(response, signal) {63 const declared = Number(response.headers?.get?.("content-length"));64 if (Number.isFinite(declared) && declared > MAX_BYTES) throw Error("attachment is too large");65 if (response.body?.getReader) {66 const reader = response.body.getReader();67 const chunks = [];68 let total = 0;69 try {70 for (;;) {71 if (signal?.aborted) throw Error("attachment opening canceled");72 const part = await reader.read();73 if (part.done) break;74 const chunk = bytes(part.value);75 total += chunk.length;76 if (total > MAX_BYTES) {77 await reader.cancel();78 throw Error("attachment is too large");79 }80 chunks.push(chunk);81 }82 } finally { reader.releaseLock?.(); }83 const output = new Uint8Array(total);84 let offset = 0;85 chunks.forEach(chunk => { output.set(chunk, offset); offset += chunk.length; });86 return output;87 }88 const output = bytes(await response.arrayBuffer());89 if (output.length > MAX_BYTES) throw Error("attachment is too large");90 return output;91 }9293 async function fetchCiphertext(file, signal) {94 const init = {method: "GET", credentials: "omit", referrerPolicy: "no-referrer", signal};95 let response = await fetch(file.url, init);96 if (!response.ok && new URL(file.url).origin === globalThis.location.origin && response.status === 401 && globalThis.tiny.signedFetch) {97 const local = new URL(file.url).pathname + new URL(file.url).search;98 response = await globalThis.tiny.signedFetch(local, "GET", undefined, {signal});99 }100 if (!response.ok) throw Error("attachment could not be fetched");101 return responseBytes(response, signal);102 }103104 async function decrypt(file, ciphertext) {105 const encrypted = bytes(ciphertext);106 if (hex(await globalThis.tiny.util.sha256(encrypted)) !== file.hash) throw Error("attachment hash mismatch");107 let plain;108 try {109 const cryptoKey = await crypto.subtle.importKey("raw", file.key, {name: "AES-GCM"}, false, ["decrypt"]);110 plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: file.nonce}, cryptoKey, encrypted));111 } catch { throw Error("attachment decryption failed"); }112 if (file.plaintextHash && hex(await globalThis.tiny.util.sha256(plain)) !== file.plaintextHash)113 throw Error("attachment plaintext hash mismatch");114 return plain;115 }116117 function downloadLink(owner, file, plain) {118 const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));119 owner._attachmentURLs ||= new Set();120 owner._attachmentURLs.add(url);121 const link = document.createElement("a");122 link.textContent = "Download";123 link.download = file.name;124 link.rel = "noopener";125 link.href = url;126 return link;127 }128129 function preview(node, plain, file, owner = node) {130 const mime = file.mime.toLowerCase().split(";", 1)[0];131 if (mime === "text/plain" || mime === "text/markdown" || mime === "application/json") {132 const output = document.createElement("pre");133 output.textContent = textDecoder.decode(plain.slice(0, 1024 * 1024));134 const footer = document.createElement("p");135 footer.append(downloadLink(owner, file, plain));136 if (plain.length > 1024 * 1024) footer.append(" (preview truncated)");137 node.replaceChildren(output, footer);138 return;139 }140 if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {141 const media = document.createElement(mime.startsWith("image/") ? "img" : mime.startsWith("video/") ? "video" : "audio");142 media.controls = mime.startsWith("video/") || mime.startsWith("audio/");143 if (media.localName === "img") media.alt = file.name;144 if (media.localName === "video") media.preload = "metadata";145 const url = URL.createObjectURL(new Blob([plain], {type: file.mime}));146 media.src = url;147 owner._attachmentURLs ||= new Set();148 owner._attachmentURLs.add(url);149 const footer = document.createElement("p");150 footer.append(downloadLink(owner, file, plain));151 node.replaceChildren(media, footer);152 return;153 }154 const output = document.createElement("p");155 output.append("Decrypted attachment is ready: ", downloadLink(owner, file, plain));156 node.replaceChildren(output);157 }158159 class DirectFile extends HTMLElement {160 connectedCallback() {161 this.revokeAttachments();162 this.generation = (this.generation || 0) + 1;163 const generation = this.generation;164 let file;165 try { file = parse(this.rumor); } catch (error) { this.textContent = error.message; return; }166 this.file = file;167 this.innerHTML = "";168 const article = document.createElement("article");169 const label = document.createElement("span");170 label.textContent = file.name + (file.size === null ? "" : " (" + sizeLabel(file.size) + ")");171 const button = document.createElement("button");172 button.type = "button";173 button.textContent = "Open attachment";174 const status = document.createElement("output");175 button.addEventListener("click", async () => {176 button.disabled = true;177 status.textContent = "Opening attachment...";178 this.abort?.abort();179 this.abort = new AbortController();180 try {181 const plain = await decrypt(file, await fetchCiphertext(file, this.abort.signal));182 if (this.generation !== generation || !this.isConnected) return;183 preview(article, plain, file, this);184 } catch (error) { if (this.generation === generation) status.textContent = error.message; }185 finally { if (this.generation === generation) button.disabled = false; }186 });187 article.append(label, button, status);188 this.append(article);189 }190 revokeAttachments() {191 for (const url of this._attachmentURLs || []) URL.revokeObjectURL(url);192 this._attachmentURLs?.clear();193 }194 disconnectedCallback() {195 this.generation = (this.generation || 0) + 1;196 this.abort?.abort();197 this.revokeAttachments();198 }199 }200201 globalThis.tiny = globalThis.tiny || {};202 globalThis.tiny.chat = globalThis.tiny.chat || {};203 globalThis.tiny.chat.files = Object.freeze({parse, decrypt, render: rumor => {204 const node = document.createElement("direct-file");205 node.rumor = rumor;206 return node;207 }, MAX_BYTES});208 if (globalThis.customElements && !customElements.get("direct-file")) customElements.define("direct-file", DirectFile);209})();