html-cloud 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,13 @@ Pipe straight from a generator:
21
21
  my-report-tool | npx html-cloud -
22
22
  ```
23
23
 
24
+ Changed the file? Update it in place — the share link you already sent out
25
+ now shows the new version:
26
+
27
+ ```sh
28
+ npx html-cloud ./report.html --update "https://html.cloud/e/kT4eN7xQ#9dKw2mPv…"
29
+ ```
30
+
24
31
  ## Why this exists
25
32
 
26
33
  AI tools (Claude, ChatGPT, Gemini) produce self-contained HTML — presentations,
@@ -45,6 +52,7 @@ Read the full explainer: [html.cloud/security](https://html.cloud/security)
45
52
  | Option | Description | Default |
46
53
  |---|---|---|
47
54
  | `--expires <7\|30\|never>` | Days until the link expires | `30` |
55
+ | `--update <edit-link>` | Replace the content behind an existing share (pass the edit link a previous run printed). The share link stays the same; expiry is unchanged | — |
48
56
  | `--url <base>` | Server base URL (or `$HTML_CLOUD_URL`) | `https://html.cloud` |
49
57
  | `--no-copy` | Don't copy the share link to the clipboard | copy is on |
50
58
 
@@ -55,6 +63,10 @@ piped or scripted, the clipboard is never touched.
55
63
  Limits: one `.html`/`.htm` file (or stdin), max 10 MB. Expiry can be changed
56
64
  later from the edit link.
57
65
 
66
+ An update never changes the keys: the edit key unwraps the document's existing
67
+ view key locally, the new file is encrypted under that same key, and only the
68
+ ciphertext is replaced. That is why the old share link keeps working.
69
+
58
70
  ## Honest threat model
59
71
 
60
72
  Anyone who has the share link can read the file — link handling is on you.
package/bin/html-cloud.js CHANGED
@@ -12,13 +12,7 @@ import { readFileSync } from 'node:fs';
12
12
  import { basename } from 'node:path';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import {
16
- generateViewKey, generateEditKey, exportViewKey,
17
- encryptBytes, encryptViewKeyWithEditKey, computeEditAuth,
18
- packCiphertext, b64url,
19
- } from '../crypto.js';
20
-
21
- const MAX_SIZE = 10 * 1024 * 1024; // 10 MB, matches the server limit
15
+ import { shareDocument, updateDocument, parseEditLink, MAX_SIZE } from '../share-core.js';
22
16
 
23
17
  const HELP = `
24
18
  html-cloud — private HTML file sharing, encrypted before upload
@@ -26,9 +20,13 @@ html-cloud — private HTML file sharing, encrypted before upload
26
20
  Usage:
27
21
  npx html-cloud <file.html> [options]
28
22
  cat page.html | npx html-cloud -
23
+ npx html-cloud <file.html> --update <edit-link>
29
24
 
30
25
  Options:
31
26
  --expires <7|30|never> Days until the link expires (default: 30)
27
+ --update <edit-link> Replace the content behind an existing share. Pass
28
+ the private edit link a previous run printed; the
29
+ share link stays the same
32
30
  --url <base> Server base URL (default: https://html.cloud,
33
31
  or $HTML_CLOUD_URL)
34
32
  --no-copy Don't copy the share link to the clipboard
@@ -61,7 +59,8 @@ try {
61
59
  args = parseArgs({
62
60
  allowPositionals: true,
63
61
  options: {
64
- expires: { type: 'string', default: '30' },
62
+ expires: { type: 'string' },
63
+ update: { type: 'string' },
65
64
  url: { type: 'string' },
66
65
  'no-copy': { type: 'boolean', default: false },
67
66
  help: { type: 'boolean', short: 'h', default: false },
@@ -77,11 +76,26 @@ if (args.values.help || args.positionals.length === 0) {
77
76
  }
78
77
 
79
78
  const input = args.positionals[0];
80
- const expires = args.values.expires;
79
+
80
+ // --update replaces an existing document: the edit link names the server and
81
+ // the document, and expiry is left untouched (change it from the edit page).
82
+ let editLink = null;
83
+ if (args.values.update !== undefined) {
84
+ try {
85
+ editLink = parseEditLink(args.values.update);
86
+ } catch (err) {
87
+ fail(err.message.toLowerCase().replace(/\.$/, ''));
88
+ }
89
+ if (args.values.expires !== undefined) {
90
+ fail('--expires cannot be combined with --update (expiry is unchanged by an update)');
91
+ }
92
+ }
93
+
94
+ const expires = args.values.expires ?? '30';
81
95
  if (!['7', '30', 'never'].includes(expires)) {
82
96
  fail(`--expires must be 7, 30 or never (got "${expires}")`);
83
97
  }
84
- const baseUrl = (args.values.url ?? process.env.HTML_CLOUD_URL ?? 'https://html.cloud')
98
+ const baseUrl = (args.values.url ?? editLink?.baseUrl ?? process.env.HTML_CLOUD_URL ?? 'https://html.cloud')
85
99
  .replace(/\/+$/, '');
86
100
 
87
101
  let plaintext;
@@ -98,54 +112,36 @@ if (input === '-') {
98
112
  if (plaintext.length === 0) fail('input is empty');
99
113
  if (plaintext.length > MAX_SIZE) fail('file is too large (max 10 MB)');
100
114
 
101
- // 1. Generate keys locally these never leave this process except inside the printed links.
102
- const viewKey = await generateViewKey();
103
- const editKeyRaw = await generateEditKey();
104
- const viewKeyRaw = await exportViewKey(viewKey);
105
-
106
- // 2. Encrypt content and wrap the view key with the edit key.
107
- const { iv, ciphertext } = await encryptBytes(viewKey, plaintext);
108
- const packed = packCiphertext(iv, ciphertext);
109
- const encryptedViewKey = await encryptViewKeyWithEditKey(viewKeyRaw, editKeyRaw);
110
- const editAuth = await computeEditAuth(editKeyRaw);
111
-
112
- // 3. Upload ciphertext only.
113
- let res;
115
+ // Encrypt locally and upload ciphertext only shared with the website and the
116
+ // browser extension via share-core.js. Keys are generated inside and returned
117
+ // as URL fragments; they never leave this process except in the printed links.
118
+ // An update re-uses the document's existing view key (unwrapped locally with
119
+ // the edit key) so the share link that is already out there keeps working.
120
+ let id, viewFrag, editFrag;
114
121
  try {
115
- res = await fetch(`${baseUrl}/api/documents`, {
116
- method: 'POST',
117
- headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({
119
- ciphertext: packed,
120
- encrypted_view_key: encryptedViewKey,
121
- edit_auth: editAuth,
122
- expires_in: expires,
123
- size: plaintext.length,
124
- }),
125
- });
126
- } catch {
127
- fail(`could not reach ${baseUrl}`);
128
- }
129
-
130
- if (!res.ok) {
131
- if (res.status === 429) fail('too many uploads — please wait a few minutes and try again');
132
- const err = await res.json().catch(() => ({}));
133
- fail(err.error || err.message || `upload failed (HTTP ${res.status})`);
122
+ ({ id, viewFrag, editFrag } = editLink
123
+ ? await updateDocument(editLink.id, editLink.editFrag, plaintext, { baseUrl })
124
+ : await shareDocument(plaintext, { expiresIn: expires, baseUrl }));
125
+ } catch (err) {
126
+ // fetch throws a TypeError when the host is unreachable; anything else is an
127
+ // Error we raised with a ready-to-print message (server error, rate limit…).
128
+ if (err instanceof TypeError) fail(`could not reach ${baseUrl}`);
129
+ fail(err.message.toLowerCase());
134
130
  }
135
131
 
136
- const { id } = await res.json();
137
- const shareLink = `${baseUrl}/v/${id}#${b64url(viewKeyRaw)}`;
132
+ const shareLink = `${baseUrl}/v/${id}#${viewFrag}`;
138
133
 
139
134
  // Copy only in interactive use — never alter the clipboard from scripts/pipes.
140
135
  const copied = !args.values['no-copy'] && process.stdout.isTTY && copyToClipboard(shareLink);
141
136
 
142
- const expiryNote = expires === 'never' ? 'never expires' : `expires in ${expires} days`;
137
+ const expiryNote = editLink ? 'content replaced, expiry unchanged'
138
+ : expires === 'never' ? 'never expires' : `expires in ${expires} days`;
143
139
  console.log(`
144
- Share link (anyone with this can view)${copied ? ' — copied to clipboard' : ''}:
140
+ Share link (${editLink ? 'unchanged — ' : ''}anyone with this can view)${copied ? ' — copied to clipboard' : ''}:
145
141
  ${shareLink}
146
142
 
147
- Edit link (keep private — replace, change expiry, delete):
148
- ${baseUrl}/e/${id}#${b64url(editKeyRaw)}
143
+ Edit link (keep private — update, change expiry, delete):
144
+ ${baseUrl}/e/${id}#${editFrag}
149
145
 
150
146
  Encrypted locally with AES-256-GCM · ${expiryNote} · the server never saw the keys
151
147
  `.trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html-cloud",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Share an HTML file privately from the command line. Encrypted with AES-256-GCM before upload — the server stores only ciphertext. No account.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "crypto.js",
12
+ "share-core.js",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -17,7 +18,10 @@
17
18
  "keywords": [
18
19
  "html",
19
20
  "share",
21
+ "file-sharing",
22
+ "cli",
20
23
  "encrypted",
24
+ "end-to-end-encryption",
21
25
  "zero-knowledge",
22
26
  "private",
23
27
  "claude-artifact",
package/share-core.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The one place that defines what "uploading a document" means.
3
+ *
4
+ * Browser (homepage), CLI (`npx html-cloud`) and the Chrome extension all share
5
+ * this single encrypt-then-upload sequence so the zero-knowledge wire contract
6
+ * can never silently diverge between clients — exactly like crypto.js.
7
+ *
8
+ * Lives in cli/ (next to crypto.js) so the npm package can ship it; the browser
9
+ * re-exports it via resources/js/share-core.js.
10
+ */
11
+
12
+ import {
13
+ generateViewKey, generateEditKey, exportViewKey, importViewKey,
14
+ encryptBytes, encryptViewKeyWithEditKey, decryptViewKeyWithEditKey,
15
+ computeEditAuth, packCiphertext, b64url, b64urlDecode,
16
+ } from './crypto.js';
17
+
18
+ export const MAX_SIZE = 10 * 1024 * 1024; // 10 MB — also enforced server-side
19
+
20
+ /**
21
+ * Cosmetic, URL-safe slug from a filename. It rides in the share link purely so
22
+ * previews show a title — never stored, never used to look up the document.
23
+ */
24
+ export function slugify(name) {
25
+ return name
26
+ .replace(/\.html?$/i, '')
27
+ .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip accents: ä -> a
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '')
31
+ .slice(0, 60)
32
+ .replace(/-+$/, '');
33
+ }
34
+
35
+ /** Build the viewer path. A slug, when present, makes the link self-describing. */
36
+ export function viewPath(id, slug) {
37
+ return slug ? `/v/${id}/${slug}` : `/v/${id}`;
38
+ }
39
+
40
+ /**
41
+ * Split an edit link (`https://host/e/{id}#{editKey}`) into the pieces a
42
+ * client needs to talk to the server about that document. The key stays in
43
+ * the fragment, exactly where the link carries it — it is never sent anywhere
44
+ * except as the `edit_key` proof inside an authorized write.
45
+ *
46
+ * @param {string} link
47
+ * @returns {{ baseUrl: string, id: string, editFrag: string }}
48
+ */
49
+ export function parseEditLink(link) {
50
+ let url;
51
+ try {
52
+ url = new URL(String(link).trim());
53
+ } catch {
54
+ throw new Error('Not a valid edit link.');
55
+ }
56
+ const match = url.pathname.match(/^\/e\/([A-Za-z0-9]+)\/?$/);
57
+ const editFrag = url.hash.slice(1);
58
+ if (!match || !editFrag) {
59
+ throw new Error('Not an edit link — expected the private https://html.cloud/e/{id}#{key} link.');
60
+ }
61
+ return { baseUrl: url.origin, id: match[1], editFrag };
62
+ }
63
+
64
+ /**
65
+ * Encrypt plaintext bytes into the server wire payload plus the two URL-fragment
66
+ * keys. Pure crypto, no network — keys are generated here and never leave except
67
+ * inside the returned fragment strings.
68
+ *
69
+ * @param {Uint8Array} plaintext
70
+ * @returns {{ payload: object, viewFrag: string, editFrag: string }}
71
+ */
72
+ export async function encryptDocument(plaintext) {
73
+ const viewKey = await generateViewKey();
74
+ const editKeyRaw = await generateEditKey();
75
+ const viewKeyRaw = await exportViewKey(viewKey);
76
+
77
+ const { iv, ciphertext } = await encryptBytes(viewKey, plaintext);
78
+
79
+ return {
80
+ payload: {
81
+ ciphertext: packCiphertext(iv, ciphertext),
82
+ encrypted_view_key: await encryptViewKeyWithEditKey(viewKeyRaw, editKeyRaw),
83
+ edit_auth: await computeEditAuth(editKeyRaw),
84
+ size: plaintext.length,
85
+ },
86
+ viewFrag: b64url(viewKeyRaw),
87
+ editFrag: b64url(editKeyRaw),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Encrypt + upload a document. Returns the server id and the two fragment keys.
93
+ * The caller owns everything after this: building links, copying, remembering.
94
+ *
95
+ * @param {Uint8Array} plaintext
96
+ * @param {object} [opts]
97
+ * @param {string} [opts.expiresIn='30'] '7' | '30' | 'never'
98
+ * @param {boolean} [opts.sensitive] omitted from the body when undefined
99
+ * @param {string} [opts.baseUrl=''] '' = same-origin; absolute for CLI/extension
100
+ * @param {object} [opts.headers={}] extra request headers (extension, CLI)
101
+ * @param {Function} [opts.fetchImpl=fetch] injectable fetch (background worker / Node)
102
+ * @returns {Promise<{ id: string, viewFrag: string, editFrag: string }>}
103
+ */
104
+ export async function shareDocument(plaintext, {
105
+ expiresIn = '30',
106
+ sensitive,
107
+ baseUrl = '',
108
+ headers = {},
109
+ fetchImpl = fetch,
110
+ } = {}) {
111
+ const { payload, viewFrag, editFrag } = await encryptDocument(plaintext);
112
+
113
+ const body = { ...payload, expires_in: expiresIn };
114
+ if (sensitive !== undefined) body.sensitive = sensitive;
115
+
116
+ const res = await fetchImpl(`${baseUrl}/api/documents`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json', ...headers },
119
+ body: JSON.stringify(body),
120
+ });
121
+
122
+ if (!res.ok) {
123
+ if (res.status === 429) {
124
+ throw new Error('Too many uploads — please wait a few minutes and try again.');
125
+ }
126
+ const err = await res.json().catch(() => ({}));
127
+ throw new Error(err.error || err.message || `Upload failed (HTTP ${res.status})`);
128
+ }
129
+
130
+ const { id } = await res.json();
131
+ return { id, viewFrag, editFrag };
132
+ }
133
+
134
+ /**
135
+ * Replace the content of an existing document. The new plaintext is encrypted
136
+ * under the document's *existing* view key (unwrapped locally with the edit
137
+ * key), so every share link already handed out keeps working unchanged. Only
138
+ * the edit-key holder can do this; the server still sees only ciphertext.
139
+ *
140
+ * @param {string} id Document id from the edit link.
141
+ * @param {string} editFrag base64url edit key from after the # in the edit link.
142
+ * @param {Uint8Array} plaintext
143
+ * @param {object} [opts] Same transport options as shareDocument.
144
+ * @param {string} [opts.baseUrl='']
145
+ * @param {object} [opts.headers={}]
146
+ * @param {Function} [opts.fetchImpl=fetch]
147
+ * @returns {Promise<{ id: string, viewFrag: string, editFrag: string }>}
148
+ */
149
+ export async function updateDocument(id, editFrag, plaintext, {
150
+ baseUrl = '',
151
+ headers = {},
152
+ fetchImpl = fetch,
153
+ } = {}) {
154
+ let editKeyRaw;
155
+ try {
156
+ editKeyRaw = b64urlDecode(editFrag);
157
+ } catch {
158
+ throw new Error('Invalid edit key.');
159
+ }
160
+
161
+ const current = await fetchImpl(`${baseUrl}/api/documents/${id}`, { headers });
162
+ if (current.status === 404) {
163
+ throw new Error('Document not found — it may have expired or been deleted.');
164
+ }
165
+ if (!current.ok) {
166
+ throw new Error(`Could not load document (HTTP ${current.status})`);
167
+ }
168
+ const { encrypted_view_key: encryptedViewKey } = await current.json();
169
+
170
+ let viewKeyRaw;
171
+ try {
172
+ viewKeyRaw = await decryptViewKeyWithEditKey(encryptedViewKey, editKeyRaw);
173
+ } catch {
174
+ throw new Error('Invalid edit key for this document.');
175
+ }
176
+
177
+ const viewKey = await importViewKey(viewKeyRaw);
178
+ const { iv, ciphertext } = await encryptBytes(viewKey, plaintext);
179
+
180
+ const res = await fetchImpl(`${baseUrl}/api/documents/${id}`, {
181
+ method: 'PUT',
182
+ headers: { 'Content-Type': 'application/json', ...headers },
183
+ body: JSON.stringify({
184
+ ciphertext: packCiphertext(iv, ciphertext),
185
+ encrypted_view_key: await encryptViewKeyWithEditKey(viewKeyRaw, editKeyRaw),
186
+ edit_key: b64url(editKeyRaw),
187
+ size: plaintext.length,
188
+ }),
189
+ });
190
+
191
+ if (!res.ok) {
192
+ if (res.status === 403) throw new Error('Invalid edit key for this document.');
193
+ const err = await res.json().catch(() => ({}));
194
+ throw new Error(err.error || err.message || `Update failed (HTTP ${res.status})`);
195
+ }
196
+
197
+ return { id, viewFrag: b64url(viewKeyRaw), editFrag: b64url(editKeyRaw) };
198
+ }