tinyrelayfile
tinyrelay/tinyclient/chat-files.js/

tinyclient/chat-files.js raw
1/* NIP-17 kind 15 attachments. File keys stay in the decrypted rumor and are
2 never copied into markup, URLs, local storage, or the clipboard. */
3(() => {
4 "use strict";
5
6 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 };
26
27 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 nonce
59 });
60 }
61
62 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 }
92
93 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 }
103
104 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 }
116
117 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 }
128
129 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 }
158
159 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 }
200
201 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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs