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 +12 -0
- package/bin/html-cloud.js +44 -48
- package/package.json +5 -1
- package/share-core.js +198 -0
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'
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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 =
|
|
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 —
|
|
148
|
-
${baseUrl}/e/${id}#${
|
|
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.
|
|
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
|
+
}
|