droppage 0.1.1 → 0.2.1
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 +114 -11
- package/dist/cli.js +819 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,17 +38,28 @@ Open the printed link in any browser. That's it.
|
|
|
38
38
|
|---|---|
|
|
39
39
|
| `droppage login` | Authenticate with a magic link (session stored in `~/.droppage/config.json`). |
|
|
40
40
|
| `droppage logout` | Sign out and clear local credentials. |
|
|
41
|
-
| `droppage upload <file>` | Publish an `.html`/`.htm` file and print its URL. Options: `--password`, `--email <addr>`, `--private
|
|
42
|
-
| `droppage update <slug> <file>` | Replace the HTML behind an existing URL. Keeps the URL, access mode, and stats. |
|
|
43
|
-
| `droppage
|
|
44
|
-
| `droppage
|
|
41
|
+
| `droppage upload <file>` | Publish an `.html`/`.htm` file (and the local images it references) and print its URL. Options: `--password`, `--email <addr>`, `--private`, `--expires <when>`. |
|
|
42
|
+
| `droppage update <slug> <file>` | Replace the HTML (and images) behind an existing URL. Keeps the URL, access mode, and stats. `--expires <when\|never>` changes the expiry. |
|
|
43
|
+
| `droppage expire <slug> <when\|never>` | Set or clear when a page expires without re-uploading. |
|
|
44
|
+
| `droppage list` | List your pages, including an EXPIRES column. `--json` for machines (rows have `expires_at`), `--preview` for no-count preview URLs. |
|
|
45
|
+
| `droppage delete <slug>` | Delete a page and its images. `--yes` skips the confirmation prompt. |
|
|
45
46
|
| `droppage recipients list <slug>` | List recipients of an email-gated page (`--json`, `--page <n>`). |
|
|
46
47
|
| `droppage recipients add <slug> <emails...>` | Add recipients and queue their invitation emails. |
|
|
47
48
|
| `droppage recipients remove <slug> <email>` | Revoke a recipient; their invitation link stops working (`--yes`). |
|
|
48
49
|
| `droppage recipients resend <slug> <email>` | Queue a fresh invitation for an existing recipient. |
|
|
50
|
+
| `droppage image upload <files...> --page <slug>` | Advanced: add images (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each) to an existing page and print their URLs. `--json` prints `[{file,id,url,bytes,content_type,existing}]`. |
|
|
51
|
+
| `droppage image list [--page <slug>]` | List your pages' images; with `--page`, one page and its image budget. `--json` for machines. |
|
|
52
|
+
| `droppage image delete <id-or-url...>` | Delete images. Cached copies may still show for up to a day. |
|
|
53
|
+
| `droppage quota` | Show the current plan, page usage, monthly meter, reset date, and enabled features. `--json` returns the account API body. |
|
|
54
|
+
| `droppage feedback <message>` | Send feedback without requiring login. Options: `--kind idea|bug|review|other`, `--agent [model]`, `--contact <email>`, review-only `--rating <1-5>` and `--allow-quote`. Quote permission never publishes automatically. |
|
|
55
|
+
| `droppage upgrade` | Install the latest CLI globally with npm and report the installed version. |
|
|
49
56
|
|
|
50
57
|
Run `droppage <command> --help` for full option details.
|
|
51
58
|
|
|
59
|
+
Slugs from 0.2.1 on never start with `-`. If an older page's slug does (say `-AbC12xYz9`), put `--` in front of
|
|
60
|
+
it, with any options before the `--`: `droppage expire -- -AbC12xYz9 never` or
|
|
61
|
+
`droppage update --expires 7d -- -AbC12xYz9 report.html`. The CLI prints the same hint when it happens.
|
|
62
|
+
|
|
52
63
|
## Access modes
|
|
53
64
|
|
|
54
65
|
| Upload flag | Mode | Who can open the link |
|
|
@@ -59,22 +70,95 @@ Run `droppage <command> --help` for full option details.
|
|
|
59
70
|
| `--private` | private | Only you (your account email). Useful for drafts. |
|
|
60
71
|
|
|
61
72
|
`--password`, `--email`, and `--private` are mutually exclusive. `update` never changes the access
|
|
62
|
-
mode — it only swaps the HTML.
|
|
73
|
+
mode — it only swaps the HTML. When you are signed in to droppage.dev, your own protected pages
|
|
74
|
+
open directly, without the password or email link.
|
|
63
75
|
|
|
64
76
|
## Rules for uploads
|
|
65
77
|
|
|
66
78
|
- One `.html` or `.htm` file per upload; the file is validated before any network call.
|
|
67
|
-
- Inline your
|
|
68
|
-
|
|
79
|
+
- Inline your CSS, JS, and data. Relative asset paths other than images will not resolve. Local
|
|
80
|
+
images referenced in `<img src>` are uploaded with the page (see below); sensitive images belong
|
|
81
|
+
inline as data: URIs.
|
|
82
|
+
- Views shown by `list` count one unique visitor per page. Refreshes, reopening in the same browser,
|
|
83
|
+
link previews from chat apps, and your own opens do not add views.
|
|
69
84
|
- Preview URLs (`list --preview`) never increment the view count.
|
|
70
85
|
|
|
86
|
+
## Images
|
|
87
|
+
|
|
88
|
+
Images belong to a page. Reference local image files in your HTML (inside the HTML file's folder; `../` and absolute paths are ignored), with paths relative to the HTML
|
|
89
|
+
file, and upload it:
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<img src="chart.png">
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
$ droppage upload report.html
|
|
97
|
+
droppage: image chart.png → https://droppage.dev/i/AbCdEfGhIjKl.png
|
|
98
|
+
https://droppage.dev/p/k3x9qd2m
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`upload` finds the images, uploads them to that page, rewrites the links to
|
|
102
|
+
`https://droppage.dev/i/<id>.<ext>`, then publishes. The image lines go to stderr; stdout is still
|
|
103
|
+
only the page URL.
|
|
104
|
+
|
|
105
|
+
- `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
|
|
106
|
+
the page's images the new HTML no longer uses.
|
|
107
|
+
- Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
|
|
108
|
+
- Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
|
|
109
|
+
PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
|
|
110
|
+
- Prefer small formats: WebP or AVIF for photos and screenshots, SVG for charts and logos (often
|
|
111
|
+
several times smaller than PNG or JPEG). The CLI prints a tip on stderr when a PNG, JPEG, or GIF
|
|
112
|
+
is over 300 KB.
|
|
113
|
+
- **Images are public** to anyone with the URL. A page's password, email, or private gate does not
|
|
114
|
+
protect them. Keep sensitive images inline in the HTML as data: URIs instead.
|
|
115
|
+
- Advanced: `droppage image upload <files...> --page <slug>` adds images to an existing page and
|
|
116
|
+
prints their URLs; `droppage image list [--page <slug>] [--json]` shows them (`--json` prints
|
|
117
|
+
`{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
|
|
118
|
+
`droppage image delete <id-or-url...>` removes them.
|
|
119
|
+
|
|
120
|
+
## Expiry
|
|
121
|
+
|
|
122
|
+
Pages can expire. Nothing expires unless you set it, on any plan.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
droppage upload report.html --expires 7d # also 30m, 12h, 2w, or a date like 2026-12-31
|
|
126
|
+
droppage update <slug> report.html --expires never
|
|
127
|
+
droppage expire <slug> 2026-12-31 # change it without re-uploading; `never` clears it
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
After expiry the page shows "This page has expired." (HTTP 410) to everyone, including you, and
|
|
131
|
+
its images stop working. A daily cleanup then deletes the page and its images for good.
|
|
132
|
+
`droppage list` shows an EXPIRES column (`never` or a date); `list --json` rows have `expires_at`
|
|
133
|
+
(`null` means never).
|
|
134
|
+
|
|
135
|
+
## Sharing reports and dashboards
|
|
136
|
+
|
|
137
|
+
droppage is a quick way to send an analysis to people who don't have your tools: a dashboard for your
|
|
138
|
+
team, a weekly KPI or stakeholder report, a client deliverable, a notebook export. They open a link.
|
|
139
|
+
|
|
140
|
+
- **Export one HTML file**, up to 5 MB, with the data inside it: `jupyter nbconvert --to html
|
|
141
|
+
analysis.ipynb`, Plotly's `fig.write_html("report.html", include_plotlyjs="cdn")`, Altair's
|
|
142
|
+
`chart.save("report.html")`, or your own Chart.js / ECharts page. Scripts run, so interactive
|
|
143
|
+
charts work, and chart libraries can load from a CDN.
|
|
144
|
+
- **Pages run in a sandbox**: `localStorage`, cookies, and same-origin requests are not available, so
|
|
145
|
+
keep the data in the file rather than fetching it.
|
|
146
|
+
- **Pick who sees it.** `--email cfo@example.com,ops@example.com` for named people (Builder; manage
|
|
147
|
+
them later with `droppage recipients`), `--password` for a group link (Starter and Builder),
|
|
148
|
+
`--private` for a draft. On the Free plan, pages can be public or private, without a password or email list.
|
|
149
|
+
- **Same link every week.** `droppage update <slug> report.html` replaces the report and keeps the
|
|
150
|
+
link and its access settings (Starter and Builder). Use `--expires 30d` for one-off deliverables.
|
|
151
|
+
- **Views are people.** `droppage list --json` shows `view_count` (unique visitors) and
|
|
152
|
+
`last_viewed_at` for each report.
|
|
153
|
+
|
|
71
154
|
## For agents and scripts
|
|
72
155
|
|
|
73
156
|
droppage keeps a strict stream contract so it is safe to call from automation:
|
|
74
157
|
|
|
75
|
-
- `upload` and `update` print **only the URL** on stdout
|
|
76
|
-
|
|
77
|
-
-
|
|
158
|
+
- `upload` and `update` print **only the URL** on stdout, even when they upload images;
|
|
159
|
+
`image upload` prints **only the image URL(s)**; `expire` prints **nothing** on stdout.
|
|
160
|
+
- `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
|
|
161
|
+
- Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
|
|
78
162
|
- Non-zero exit code on any failure.
|
|
79
163
|
- Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
|
|
80
164
|
|
|
@@ -84,10 +168,28 @@ Ready-made instruction packs for Codex, Cursor, and Claude-style agents live in
|
|
|
84
168
|
## Updating
|
|
85
169
|
|
|
86
170
|
```bash
|
|
87
|
-
|
|
171
|
+
droppage upgrade # upgrade to the latest release
|
|
88
172
|
droppage --version # confirm the installed version
|
|
89
173
|
```
|
|
90
174
|
|
|
175
|
+
After a command finishes, interactive terminals check npm for a new release at most once every 24
|
|
176
|
+
hours and put any update notice on stderr only. The check sends no identifier or usage data, and
|
|
177
|
+
all errors are ignored. Set `DROPPAGE_NO_UPDATE_CHECK=1` to opt out. This registry lookup is the
|
|
178
|
+
CLI's only non-backend network request; droppage still collects no telemetry.
|
|
179
|
+
|
|
180
|
+
Before bulk publishing, use `droppage quota --json`. A `warn` level means the monthly meter is at
|
|
181
|
+
least 80% used; `limit` means new uploads, updates, or previews may pause. Existing links remain
|
|
182
|
+
live. Delete unneeded pages or review plan options at [droppage.dev/account](https://droppage.dev/account).
|
|
183
|
+
|
|
184
|
+
Feedback is public and login is optional:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
droppage feedback "The preview command should…" --kind idea
|
|
188
|
+
droppage feedback "Upload failed after…" --kind bug --agent "my-model"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Agent-authored feedback should always use `--agent` and include the model name.
|
|
192
|
+
|
|
91
193
|
Releases are published from GitHub Actions with
|
|
92
194
|
[npm provenance](https://docs.npmjs.com/generating-provenance-statements), so every version on npm
|
|
93
195
|
is verifiably built from a tagged commit in the public repository.
|
|
@@ -102,6 +204,7 @@ setups, these environment variables are honoured (in order of precedence):
|
|
|
102
204
|
| `DROPPAGE_SUPABASE_URL` / `DROPPAGE_SUPABASE_ANON_KEY` | Point the CLI at another backend. |
|
|
103
205
|
| `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Fallback names for the same. |
|
|
104
206
|
| `DROPPAGE_SHARE_BASE_URL` | Origin used when printing share URLs (default `https://droppage.dev`). |
|
|
207
|
+
| `DROPPAGE_NO_UPDATE_CHECK` | Set to `1` to disable the optional 24-hour npm release check. |
|
|
105
208
|
|
|
106
209
|
## Security
|
|
107
210
|
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ var init_package = __esm({
|
|
|
15
15
|
"package.json"() {
|
|
16
16
|
package_default = {
|
|
17
17
|
name: "droppage",
|
|
18
|
-
version: "0.
|
|
18
|
+
version: "0.2.1",
|
|
19
19
|
description: "Publish HTML files as shareable, access-controlled live pages",
|
|
20
20
|
type: "module",
|
|
21
21
|
license: "Apache-2.0",
|
|
@@ -83,12 +83,12 @@ var init_package = __esm({
|
|
|
83
83
|
// src/lib/callback-server.ts
|
|
84
84
|
import { createServer } from "http";
|
|
85
85
|
function closeServer(server) {
|
|
86
|
-
return new Promise((
|
|
87
|
-
server.close(() =>
|
|
86
|
+
return new Promise((resolve2) => {
|
|
87
|
+
server.close(() => resolve2());
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
function tryListen(port) {
|
|
91
|
-
return new Promise((
|
|
91
|
+
return new Promise((resolve2, reject) => {
|
|
92
92
|
const server = createServer();
|
|
93
93
|
const onError = (err) => {
|
|
94
94
|
server.off("listening", onListening);
|
|
@@ -96,7 +96,7 @@ function tryListen(port) {
|
|
|
96
96
|
};
|
|
97
97
|
const onListening = () => {
|
|
98
98
|
server.off("error", onError);
|
|
99
|
-
|
|
99
|
+
resolve2(server);
|
|
100
100
|
};
|
|
101
101
|
server.once("error", onError);
|
|
102
102
|
server.once("listening", onListening);
|
|
@@ -137,7 +137,7 @@ function normalizeArgs(portOrOptions, pathArg) {
|
|
|
137
137
|
}
|
|
138
138
|
function waitForAuthCode(portOrOptions, pathArg) {
|
|
139
139
|
const { port, path, timeoutMs } = normalizeArgs(portOrOptions, pathArg);
|
|
140
|
-
return new Promise((
|
|
140
|
+
return new Promise((resolve2, reject) => {
|
|
141
141
|
let settled = false;
|
|
142
142
|
let timeout;
|
|
143
143
|
const settle = (fn) => {
|
|
@@ -160,7 +160,7 @@ function waitForAuthCode(portOrOptions, pathArg) {
|
|
|
160
160
|
res.end(SUCCESS_BODY);
|
|
161
161
|
void closeServer(server).then(() => {
|
|
162
162
|
if (code) {
|
|
163
|
-
settle(() =>
|
|
163
|
+
settle(() => resolve2(code));
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
166
|
const message = errorDescription || error || "No auth code in callback";
|
|
@@ -215,7 +215,7 @@ function rejectSecrets(label, value) {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
function resolveSupabaseEnv() {
|
|
218
|
-
loadDotenv({ path: join(process.cwd(), ".env") });
|
|
218
|
+
loadDotenv({ path: join(process.cwd(), ".env"), quiet: true });
|
|
219
219
|
const url = process.env.DROPPAGE_SUPABASE_URL || process.env.SUPABASE_URL || DEFAULT_SUPABASE_URL;
|
|
220
220
|
const anonKey = process.env.DROPPAGE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
|
|
221
221
|
if (!url.trim() || !anonKey.trim()) {
|
|
@@ -245,6 +245,18 @@ var init_env = __esm({
|
|
|
245
245
|
import Conf from "conf";
|
|
246
246
|
import { homedir } from "os";
|
|
247
247
|
import { join as join2 } from "path";
|
|
248
|
+
function confAt(slot, configName) {
|
|
249
|
+
const home = homedir();
|
|
250
|
+
if (!slot.instance || slot.home !== home) {
|
|
251
|
+
slot.home = home;
|
|
252
|
+
slot.instance = new Conf({
|
|
253
|
+
cwd: join2(home, ".droppage"),
|
|
254
|
+
configName,
|
|
255
|
+
configFileMode: 384
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return slot.instance;
|
|
259
|
+
}
|
|
248
260
|
function validateConfig(data) {
|
|
249
261
|
for (const [key, value] of Object.entries(data)) {
|
|
250
262
|
if (FORBIDDEN.test(key)) {
|
|
@@ -260,16 +272,10 @@ function validateConfig(data) {
|
|
|
260
272
|
}
|
|
261
273
|
}
|
|
262
274
|
function getConf() {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
cwd: join2(home, ".droppage"),
|
|
268
|
-
configName: "config",
|
|
269
|
-
configFileMode: 384
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
return confInstance;
|
|
275
|
+
return confAt(sessionConf, "config");
|
|
276
|
+
}
|
|
277
|
+
function getUpdateCheckConf() {
|
|
278
|
+
return confAt(updateCheckConf, "update-check");
|
|
273
279
|
}
|
|
274
280
|
function createConfStorage() {
|
|
275
281
|
const conf = getConf();
|
|
@@ -287,11 +293,13 @@ function createConfStorage() {
|
|
|
287
293
|
}
|
|
288
294
|
};
|
|
289
295
|
}
|
|
290
|
-
var FORBIDDEN,
|
|
296
|
+
var FORBIDDEN, sessionConf, updateCheckConf;
|
|
291
297
|
var init_config = __esm({
|
|
292
298
|
"src/lib/config.ts"() {
|
|
293
299
|
"use strict";
|
|
294
300
|
FORBIDDEN = /service_role|sb_secret/i;
|
|
301
|
+
sessionConf = {};
|
|
302
|
+
updateCheckConf = {};
|
|
295
303
|
}
|
|
296
304
|
});
|
|
297
305
|
|
|
@@ -375,7 +383,8 @@ async function login() {
|
|
|
375
383
|
`(debug) After you click the link, Auth redirects to: ${emailRedirectTo}`
|
|
376
384
|
);
|
|
377
385
|
const { url } = resolveSupabaseEnv();
|
|
378
|
-
|
|
386
|
+
const noBrowser = Boolean(process.env.DROPPAGE_NO_BROWSER || process.env.CI);
|
|
387
|
+
if (isLocalSupabaseUrl(url) && !noBrowser) {
|
|
379
388
|
try {
|
|
380
389
|
await open(INBUCKET_URL);
|
|
381
390
|
} catch {
|
|
@@ -483,6 +492,86 @@ var init_password = __esm({
|
|
|
483
492
|
}
|
|
484
493
|
});
|
|
485
494
|
|
|
495
|
+
// src/lib/duration.ts
|
|
496
|
+
function parseExpiry(value, now = Date.now()) {
|
|
497
|
+
const raw = value.trim().toLowerCase();
|
|
498
|
+
if (raw === "never" || raw === "none") return null;
|
|
499
|
+
const match = /^(\d+)\s*([mhdw])$/.exec(raw);
|
|
500
|
+
if (match) {
|
|
501
|
+
const ms = Number(match[1]) * UNITS[match[2]];
|
|
502
|
+
if (ms <= 0) throw new Error(`--expires must be in the future: ${value}`);
|
|
503
|
+
return new Date(now + ms);
|
|
504
|
+
}
|
|
505
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
|
|
506
|
+
const date = new Date(value.trim());
|
|
507
|
+
if (Number.isNaN(date.getTime())) throw new Error(`--expires: not a date: ${value}`);
|
|
508
|
+
if (date.getTime() <= now) throw new Error(`--expires must be in the future: ${value}`);
|
|
509
|
+
return date;
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`--expires: use a duration like 30m, 12h, 7d or 2w, a date like 2026-12-31, or never`);
|
|
512
|
+
}
|
|
513
|
+
var UNITS;
|
|
514
|
+
var init_duration = __esm({
|
|
515
|
+
"src/lib/duration.ts"() {
|
|
516
|
+
"use strict";
|
|
517
|
+
UNITS = { m: 6e4, h: 36e5, d: 864e5, w: 7 * 864e5 };
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// src/lib/html-images.ts
|
|
522
|
+
import { existsSync, realpathSync, statSync } from "fs";
|
|
523
|
+
import { dirname, extname, relative, resolve, sep } from "path";
|
|
524
|
+
function inside(base, path) {
|
|
525
|
+
const rel = relative(base, path);
|
|
526
|
+
return !!rel && !rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-z]:/i.test(rel);
|
|
527
|
+
}
|
|
528
|
+
function findLocalImages(html, htmlFile) {
|
|
529
|
+
const base = dirname(resolve(htmlFile));
|
|
530
|
+
let realBase;
|
|
531
|
+
try {
|
|
532
|
+
realBase = realpathSync(base);
|
|
533
|
+
} catch {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
const seen = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const match of html.matchAll(SRC_RE)) {
|
|
538
|
+
const ref = match[3].trim();
|
|
539
|
+
if (seen.has(ref) || /^(?:[a-z][a-z0-9+.-]*:|\/|\\|#)/i.test(ref)) continue;
|
|
540
|
+
let decoded;
|
|
541
|
+
try {
|
|
542
|
+
decoded = decodeURIComponent(ref.split(/[?#]/)[0]);
|
|
543
|
+
} catch {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!IMAGE_EXTS.has(extname(decoded).toLowerCase())) continue;
|
|
547
|
+
const path = resolve(base, decoded);
|
|
548
|
+
if (!inside(base, path) || !existsSync(path)) continue;
|
|
549
|
+
let real;
|
|
550
|
+
try {
|
|
551
|
+
real = realpathSync(path);
|
|
552
|
+
} catch {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (!inside(realBase, real) || !statSync(real).isFile()) continue;
|
|
556
|
+
seen.set(ref, { ref, path: real });
|
|
557
|
+
}
|
|
558
|
+
return [...seen.values()];
|
|
559
|
+
}
|
|
560
|
+
function rewriteImageRefs(html, urls) {
|
|
561
|
+
return html.replace(SRC_RE, (whole, before, quote, ref) => {
|
|
562
|
+
const url = urls.get(ref.trim());
|
|
563
|
+
return url ? `${before}${quote}${url}${quote}` : whole;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
var IMAGE_EXTS, SRC_RE;
|
|
567
|
+
var init_html_images = __esm({
|
|
568
|
+
"src/lib/html-images.ts"() {
|
|
569
|
+
"use strict";
|
|
570
|
+
IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
|
|
571
|
+
SRC_RE = /(<(?:img|source)\b[^>]*?\ssrc\s*=\s*)(["'])([^"']+)\2/gi;
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
486
575
|
// src/lib/share-url.ts
|
|
487
576
|
function resolveShareBaseUrl() {
|
|
488
577
|
const raw = process.env.DROPPAGE_SHARE_BASE_URL?.trim();
|
|
@@ -499,29 +588,197 @@ var init_share_url = __esm({
|
|
|
499
588
|
}
|
|
500
589
|
});
|
|
501
590
|
|
|
591
|
+
// src/services/images.ts
|
|
592
|
+
import { readFile, stat } from "fs/promises";
|
|
593
|
+
import { basename } from "path";
|
|
594
|
+
async function accessToken() {
|
|
595
|
+
const { data } = await createSupabaseClient().auth.getSession();
|
|
596
|
+
const token = data.session?.access_token;
|
|
597
|
+
if (!token) throw new ImageRequestError("not signed in; run `droppage login`", 401);
|
|
598
|
+
return token;
|
|
599
|
+
}
|
|
600
|
+
async function call(path, init) {
|
|
601
|
+
const base = resolveShareBaseUrl();
|
|
602
|
+
let response;
|
|
603
|
+
try {
|
|
604
|
+
response = await fetch(`${base}${path}`, init);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
throw new ImageRequestError(`could not reach ${base}: ${err instanceof Error ? err.message : String(err)}`, 0);
|
|
607
|
+
}
|
|
608
|
+
const text = await response.text();
|
|
609
|
+
let body = null;
|
|
610
|
+
try {
|
|
611
|
+
body = text ? JSON.parse(text) : null;
|
|
612
|
+
} catch {
|
|
613
|
+
}
|
|
614
|
+
if (!response.ok) {
|
|
615
|
+
const message = body?.error ?? `request failed (HTTP ${response.status})`;
|
|
616
|
+
throw new ImageRequestError(message, response.status);
|
|
617
|
+
}
|
|
618
|
+
return body;
|
|
619
|
+
}
|
|
620
|
+
async function uploadImage(file, slug, { update = false } = {}) {
|
|
621
|
+
const info = await stat(file).catch(() => null);
|
|
622
|
+
if (!info?.isFile()) throw new ImageRequestError(`file not found: ${file}`, 0);
|
|
623
|
+
if (info.size === 0) throw new ImageRequestError(`file is empty: ${file}`, 0);
|
|
624
|
+
if (info.size > MAX_IMAGE_BYTES) {
|
|
625
|
+
throw new ImageRequestError(`image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB (${info.size} bytes): ${file}`, 413);
|
|
626
|
+
}
|
|
627
|
+
const bytes = await readFile(file);
|
|
628
|
+
const body = await call("/api/images", {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: {
|
|
631
|
+
Authorization: `Bearer ${await accessToken()}`,
|
|
632
|
+
"Content-Type": "application/octet-stream",
|
|
633
|
+
"X-Droppage-Filename": encodeURIComponent(basename(file)),
|
|
634
|
+
"X-Droppage-Page": slug,
|
|
635
|
+
...update ? { "X-Droppage-Update": "1" } : {}
|
|
636
|
+
},
|
|
637
|
+
body: bytes
|
|
638
|
+
});
|
|
639
|
+
if (info.size > HEAVY_IMAGE_BYTES && /\.(png|jpe?g|gif)$/i.test(file)) {
|
|
640
|
+
console.error(`droppage: tip: ${basename(file)} is ${Math.round(info.size / 1024)} KB; WebP or AVIF is usually several times smaller (SVG for charts).`);
|
|
641
|
+
}
|
|
642
|
+
const image = body;
|
|
643
|
+
if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
|
|
644
|
+
throw new ImageRequestError("unexpected response from the image API", 0);
|
|
645
|
+
}
|
|
646
|
+
return image;
|
|
647
|
+
}
|
|
648
|
+
async function listImages(slug) {
|
|
649
|
+
const query = slug ? `?page=${encodeURIComponent(slug)}` : "";
|
|
650
|
+
const body = await call(`/api/images${query}`, {
|
|
651
|
+
method: "GET",
|
|
652
|
+
headers: { Authorization: `Bearer ${await accessToken()}` }
|
|
653
|
+
});
|
|
654
|
+
const list = body;
|
|
655
|
+
if (!list || !Array.isArray(list.images)) throw new ImageRequestError("unexpected response from the image API", 0);
|
|
656
|
+
return list;
|
|
657
|
+
}
|
|
658
|
+
function imageIdFrom(value) {
|
|
659
|
+
const match = /(?:^|\/i\/)([A-Za-z0-9]{12})(?:\.[a-z]+)?$/.exec(value.trim());
|
|
660
|
+
return match ? match[1] : null;
|
|
661
|
+
}
|
|
662
|
+
async function deleteImage(idOrUrl) {
|
|
663
|
+
const id = imageIdFrom(idOrUrl);
|
|
664
|
+
if (!id) throw new ImageRequestError(`not an image id or URL: ${idOrUrl}`, 0);
|
|
665
|
+
await call(`/api/images/${id}`, {
|
|
666
|
+
method: "DELETE",
|
|
667
|
+
headers: { Authorization: `Bearer ${await accessToken()}` }
|
|
668
|
+
});
|
|
669
|
+
return id;
|
|
670
|
+
}
|
|
671
|
+
async function pruneImages(slug) {
|
|
672
|
+
const body = await call(`/api/pages/${encodeURIComponent(slug)}/images/prune`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: { Authorization: `Bearer ${await accessToken()}` }
|
|
675
|
+
});
|
|
676
|
+
return Number(body?.removed ?? 0);
|
|
677
|
+
}
|
|
678
|
+
var MAX_IMAGE_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
|
|
679
|
+
var init_images = __esm({
|
|
680
|
+
"src/services/images.ts"() {
|
|
681
|
+
"use strict";
|
|
682
|
+
init_share_url();
|
|
683
|
+
init_supabase();
|
|
684
|
+
MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
685
|
+
HEAVY_IMAGE_BYTES = 300 * 1024;
|
|
686
|
+
ImageRequestError = class extends Error {
|
|
687
|
+
constructor(message, status) {
|
|
688
|
+
super(message);
|
|
689
|
+
this.status = status;
|
|
690
|
+
this.name = "ImageRequestError";
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// src/services/page-images.ts
|
|
697
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
698
|
+
import { basename as basename2 } from "path";
|
|
699
|
+
async function attachLocalImages(file, slug, { html, update = false } = {}) {
|
|
700
|
+
const source = html ?? await readFile2(file);
|
|
701
|
+
const text = source.toString("utf8");
|
|
702
|
+
const local = findLocalImages(text, file);
|
|
703
|
+
if (local.length === 0) return { html: source, uploaded: [] };
|
|
704
|
+
const urls = /* @__PURE__ */ new Map();
|
|
705
|
+
const uploaded = [];
|
|
706
|
+
for (const image of local) {
|
|
707
|
+
const result = await uploadImage(image.path, slug, { update });
|
|
708
|
+
urls.set(image.ref, result.url);
|
|
709
|
+
uploaded.push(result);
|
|
710
|
+
console.error(`droppage: image ${basename2(image.path)} \u2192 ${result.url}`);
|
|
711
|
+
}
|
|
712
|
+
return { html: Buffer.from(rewriteImageRefs(text, urls), "utf8"), uploaded };
|
|
713
|
+
}
|
|
714
|
+
async function hasLocalImages(file) {
|
|
715
|
+
return findLocalImages((await readFile2(file)).toString("utf8"), file).length > 0;
|
|
716
|
+
}
|
|
717
|
+
async function pruneUnusedImages(slug) {
|
|
718
|
+
return pruneImages(slug);
|
|
719
|
+
}
|
|
720
|
+
var init_page_images = __esm({
|
|
721
|
+
"src/services/page-images.ts"() {
|
|
722
|
+
"use strict";
|
|
723
|
+
init_html_images();
|
|
724
|
+
init_images();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
502
728
|
// src/lib/slug.ts
|
|
503
729
|
import { customAlphabet } from "nanoid";
|
|
504
730
|
function createSlug() {
|
|
505
|
-
return
|
|
731
|
+
return first() + rest();
|
|
732
|
+
}
|
|
733
|
+
function dashSlugHint(error) {
|
|
734
|
+
const slug = DASH_SLUG_ERROR.exec(error)?.[1];
|
|
735
|
+
if (!slug) return "";
|
|
736
|
+
return `droppage: if ${slug} is a page, put -- in front of it (options go before the --), for example:
|
|
737
|
+
droppage expire -- ${slug} never
|
|
738
|
+
droppage update --expires 7d -- ${slug} report.html
|
|
739
|
+
`;
|
|
506
740
|
}
|
|
507
|
-
var SLUG_LENGTH, alphabet,
|
|
741
|
+
var SLUG_LENGTH, alphanumeric, alphabet, first, rest, DASH_SLUG_ERROR;
|
|
508
742
|
var init_slug = __esm({
|
|
509
743
|
"src/lib/slug.ts"() {
|
|
510
744
|
"use strict";
|
|
511
745
|
SLUG_LENGTH = 10;
|
|
512
|
-
|
|
513
|
-
|
|
746
|
+
alphanumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
747
|
+
alphabet = `${alphanumeric}_-`;
|
|
748
|
+
first = customAlphabet(alphanumeric, 1);
|
|
749
|
+
rest = customAlphabet(alphabet, SLUG_LENGTH - 1);
|
|
750
|
+
DASH_SLUG_ERROR = new RegExp(`unknown option '(-[A-Za-z0-9_][A-Za-z0-9_-]{${SLUG_LENGTH - 2}})'`);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// src/lib/supabase-error.ts
|
|
755
|
+
function asError(err) {
|
|
756
|
+
if (err instanceof Error) return err;
|
|
757
|
+
if (err !== null && typeof err === "object" && typeof err.message === "string") {
|
|
758
|
+
const error = new Error(err.message);
|
|
759
|
+
const code = err.code;
|
|
760
|
+
if (typeof code === "string") Object.assign(error, { code });
|
|
761
|
+
return error;
|
|
762
|
+
}
|
|
763
|
+
return new Error(String(err));
|
|
764
|
+
}
|
|
765
|
+
var init_supabase_error = __esm({
|
|
766
|
+
"src/lib/supabase-error.ts"() {
|
|
767
|
+
"use strict";
|
|
514
768
|
}
|
|
515
769
|
});
|
|
516
770
|
|
|
517
771
|
// src/lib/validate-html.ts
|
|
518
|
-
import { extname } from "path";
|
|
772
|
+
import { extname as extname2 } from "path";
|
|
773
|
+
function isHostedImage(url) {
|
|
774
|
+
return url.startsWith(`${resolveShareBaseUrl()}/i/`);
|
|
775
|
+
}
|
|
519
776
|
function formatBytes(bytes) {
|
|
520
777
|
const mb = bytes / (1024 * 1024);
|
|
521
778
|
return `${mb.toFixed(1)} MB`;
|
|
522
779
|
}
|
|
523
780
|
function validateHtmlUpload(path, bytes) {
|
|
524
|
-
const ext =
|
|
781
|
+
const ext = extname2(path).toLowerCase();
|
|
525
782
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
526
783
|
throw new Error(
|
|
527
784
|
`Unsupported file type: ${path} (expected .html or .htm)`
|
|
@@ -553,7 +810,8 @@ function validateHtmlUpload(path, bytes) {
|
|
|
553
810
|
"HTML references relative assets (src/href beginning with ./ or /); they will not resolve in a single-file share. Inline them for a self-contained page."
|
|
554
811
|
);
|
|
555
812
|
}
|
|
556
|
-
|
|
813
|
+
const external = [...body.matchAll(EXTERNAL_ASSET_RE)].map((m) => m[1]).filter((url) => !isHostedImage(url));
|
|
814
|
+
if (external.length > 0) {
|
|
557
815
|
warnings.push(
|
|
558
816
|
"HTML references external assets (//host or http(s)://); they must be publicly reachable to render in the shared page."
|
|
559
817
|
);
|
|
@@ -564,6 +822,7 @@ var MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, SNIFF_BYTES, HTML_MARKERS, RELATIVE_AS
|
|
|
564
822
|
var init_validate_html = __esm({
|
|
565
823
|
"src/lib/validate-html.ts"() {
|
|
566
824
|
"use strict";
|
|
825
|
+
init_share_url();
|
|
567
826
|
MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
568
827
|
ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
569
828
|
SNIFF_BYTES = 4096;
|
|
@@ -574,33 +833,51 @@ var init_validate_html = __esm({
|
|
|
574
833
|
/<body[\s>]/i,
|
|
575
834
|
/<div[\s>]/i
|
|
576
835
|
];
|
|
577
|
-
RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/))/i;
|
|
578
|
-
EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["'](
|
|
836
|
+
RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/)(?!i\/[A-Za-z0-9]{12}\.[a-z]+["']))/i;
|
|
837
|
+
EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["']((?:\/\/|https?:\/\/)[^"']*)/gi;
|
|
579
838
|
}
|
|
580
839
|
});
|
|
581
840
|
|
|
582
841
|
// src/services/upload.ts
|
|
583
|
-
import { readFile } from "fs/promises";
|
|
842
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
584
843
|
async function upload({
|
|
585
844
|
file,
|
|
586
845
|
user,
|
|
587
846
|
passwordHash,
|
|
588
847
|
allowedEmails,
|
|
589
|
-
accessMode
|
|
848
|
+
accessMode,
|
|
849
|
+
prepare,
|
|
850
|
+
expiresAt
|
|
590
851
|
}) {
|
|
591
|
-
|
|
852
|
+
let bytes = await readFile3(file);
|
|
592
853
|
const { warnings } = validateHtmlUpload(file, bytes);
|
|
593
|
-
for (const w of warnings) console.error(`droppage: warning: ${w}`);
|
|
854
|
+
if (!prepare) for (const w of warnings) console.error(`droppage: warning: ${w}`);
|
|
594
855
|
const supabase = createSupabaseClient();
|
|
856
|
+
const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
|
|
857
|
+
const preflight = await supabase.rpc("assert_page_create_allowed", {
|
|
858
|
+
p_access_mode: resolvedMode,
|
|
859
|
+
p_recipient_count: allowedEmails?.length ?? 0
|
|
860
|
+
});
|
|
861
|
+
if (preflight.error) throw asError(preflight.error);
|
|
595
862
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
596
863
|
const slug = createSlug();
|
|
597
864
|
const storagePath = `${user.id}/${slug}.html`;
|
|
865
|
+
if (prepare) {
|
|
866
|
+
bytes = await prepare(slug);
|
|
867
|
+
if (attempt === 0) for (const w of validateHtmlUpload(file, bytes).warnings) console.error(`droppage: warning: ${w}`);
|
|
868
|
+
}
|
|
598
869
|
const up = await supabase.storage.from(BUCKET).upload(storagePath, bytes, {
|
|
599
870
|
contentType: "text/html; charset=utf-8",
|
|
600
871
|
upsert: false
|
|
601
872
|
});
|
|
602
|
-
if (up.error)
|
|
603
|
-
|
|
873
|
+
if (up.error) {
|
|
874
|
+
if (/row-level security/i.test(up.error.message)) {
|
|
875
|
+
throw new Error(
|
|
876
|
+
`the server rejected this upload: ${up.error.message}. This can happen when an account limit is reached while an upload is in flight \u2014 run \`droppage quota\` to check your limits.`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
throw asError(up.error);
|
|
880
|
+
}
|
|
604
881
|
const ins = await supabase.rpc("create_page_with_entitlements", {
|
|
605
882
|
p_slug: slug,
|
|
606
883
|
p_storage_path: storagePath,
|
|
@@ -608,7 +885,17 @@ async function upload({
|
|
|
608
885
|
p_password_hash: resolvedMode === "password" ? passwordHash : null,
|
|
609
886
|
p_allowed_emails: allowedEmails ?? null
|
|
610
887
|
});
|
|
611
|
-
if (!ins.error)
|
|
888
|
+
if (!ins.error) {
|
|
889
|
+
if (expiresAt) {
|
|
890
|
+
const expiry = await supabase.rpc("set_page_expiry", { p_slug: slug, p_expires_at: expiresAt.toISOString() });
|
|
891
|
+
if (expiry.error) {
|
|
892
|
+
throw new Error(
|
|
893
|
+
`published ${buildShareUrl(slug)} but could not set its expiry (${expiry.error.message}); set it with \`droppage expire ${slug} <when>\` or delete it`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return buildShareUrl(slug);
|
|
898
|
+
}
|
|
612
899
|
const rm = await supabase.storage.from(BUCKET).remove([storagePath]);
|
|
613
900
|
if (rm.error) {
|
|
614
901
|
console.error(
|
|
@@ -616,7 +903,7 @@ async function upload({
|
|
|
616
903
|
);
|
|
617
904
|
}
|
|
618
905
|
if (ins.error.code === "23505") continue;
|
|
619
|
-
throw ins.error;
|
|
906
|
+
throw asError(ins.error);
|
|
620
907
|
}
|
|
621
908
|
throw new Error(
|
|
622
909
|
`Could not allocate a unique slug after ${MAX_RETRIES} attempts`
|
|
@@ -628,6 +915,7 @@ var init_upload = __esm({
|
|
|
628
915
|
"use strict";
|
|
629
916
|
init_share_url();
|
|
630
917
|
init_slug();
|
|
918
|
+
init_supabase_error();
|
|
631
919
|
init_supabase();
|
|
632
920
|
init_validate_html();
|
|
633
921
|
BUCKET = "pages";
|
|
@@ -642,7 +930,7 @@ function registerUpload(program2) {
|
|
|
642
930
|
"--email <address>",
|
|
643
931
|
"restrict access to an email allowlist (repeatable; comma-separated)",
|
|
644
932
|
(val, prev = []) => prev.concat(val.split(","))
|
|
645
|
-
).option("--private", "restrict access to only you (owner-only draft)").action(
|
|
933
|
+
).option("--private", "restrict access to only you (owner-only draft)").option("--expires <when>", "delete the page (and its images) after 30m, 12h, 7d, 2w or on a date").action(
|
|
646
934
|
async (file, options) => {
|
|
647
935
|
let user;
|
|
648
936
|
try {
|
|
@@ -707,21 +995,28 @@ function registerUpload(program2) {
|
|
|
707
995
|
allowedEmails = [ownerEmail];
|
|
708
996
|
accessMode = "private";
|
|
709
997
|
}
|
|
998
|
+
const expiresAt = options.expires ? parseExpiry(options.expires) ?? void 0 : void 0;
|
|
710
999
|
console.error(`droppage: uploading ${file}...`);
|
|
1000
|
+
const prepare = await hasLocalImages(file) ? async (slug) => (await attachLocalImages(file, slug)).html : void 0;
|
|
711
1001
|
const url = await upload({
|
|
712
1002
|
file,
|
|
713
1003
|
user,
|
|
714
1004
|
passwordHash,
|
|
715
1005
|
allowedEmails,
|
|
716
|
-
accessMode
|
|
1006
|
+
accessMode,
|
|
1007
|
+
prepare,
|
|
1008
|
+
expiresAt
|
|
717
1009
|
});
|
|
1010
|
+
if (expiresAt) console.error(`droppage: expires ${expiresAt.toISOString()}`);
|
|
718
1011
|
console.error("droppage: done");
|
|
719
1012
|
process.stdout.write(url + "\n");
|
|
720
1013
|
} catch (err) {
|
|
721
1014
|
process.exitCode = 1;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
)
|
|
1015
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1016
|
+
console.error(`droppage: upload failed: ${message}`);
|
|
1017
|
+
if (/quota reached|require (Starter|Builder)/i.test(message)) {
|
|
1018
|
+
console.error("run `droppage quota` to see your limits");
|
|
1019
|
+
}
|
|
725
1020
|
}
|
|
726
1021
|
}
|
|
727
1022
|
);
|
|
@@ -730,17 +1025,19 @@ var init_upload2 = __esm({
|
|
|
730
1025
|
"src/commands/upload.ts"() {
|
|
731
1026
|
"use strict";
|
|
732
1027
|
init_password();
|
|
1028
|
+
init_duration();
|
|
1029
|
+
init_page_images();
|
|
733
1030
|
init_auth();
|
|
734
1031
|
init_upload();
|
|
735
1032
|
}
|
|
736
1033
|
});
|
|
737
1034
|
|
|
738
1035
|
// src/services/pages.ts
|
|
739
|
-
import { readFile as
|
|
1036
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
740
1037
|
async function listPages(user) {
|
|
741
1038
|
const supabase = createSupabaseClient();
|
|
742
1039
|
const { data, error } = await supabase.from("pages").select(
|
|
743
|
-
"slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at"
|
|
1040
|
+
"slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at, expires_at"
|
|
744
1041
|
).eq("owner_id", user.id).order("created_at", { ascending: false });
|
|
745
1042
|
if (error) throw error;
|
|
746
1043
|
return data ?? [];
|
|
@@ -782,9 +1079,10 @@ async function deletePage(slug, user) {
|
|
|
782
1079
|
async function updatePageHtml({
|
|
783
1080
|
slug,
|
|
784
1081
|
file,
|
|
785
|
-
user
|
|
1082
|
+
user,
|
|
1083
|
+
html
|
|
786
1084
|
}) {
|
|
787
|
-
const bytes = await
|
|
1085
|
+
const bytes = html ?? await readFile4(file);
|
|
788
1086
|
const { warnings } = validateHtmlUpload(file, bytes);
|
|
789
1087
|
for (const warning of warnings) {
|
|
790
1088
|
console.error(`droppage: warning: ${warning}`);
|
|
@@ -793,7 +1091,7 @@ async function updatePageHtml({
|
|
|
793
1091
|
const preflight = await supabase.rpc("assert_page_update_allowed", {
|
|
794
1092
|
p_slug: slug
|
|
795
1093
|
});
|
|
796
|
-
if (preflight.error) throw preflight.error;
|
|
1094
|
+
if (preflight.error) throw asError(preflight.error);
|
|
797
1095
|
const { data: row, error: selectError } = await supabase.from("pages").select("slug, storage_path").eq("slug", slug).eq("owner_id", user.id).maybeSingle();
|
|
798
1096
|
if (selectError) throw selectError;
|
|
799
1097
|
if (!row) throw new Error("page not found or not owned");
|
|
@@ -815,11 +1113,24 @@ async function updatePageHtml({
|
|
|
815
1113
|
url: buildShareUrl(slug)
|
|
816
1114
|
};
|
|
817
1115
|
}
|
|
1116
|
+
async function setPageExpiry(slug, expiresAt) {
|
|
1117
|
+
const supabase = createSupabaseClient();
|
|
1118
|
+
const { error } = await supabase.rpc("set_page_expiry", {
|
|
1119
|
+
p_slug: slug,
|
|
1120
|
+
p_expires_at: expiresAt ? expiresAt.toISOString() : null
|
|
1121
|
+
});
|
|
1122
|
+
if (error) throw asError(error);
|
|
1123
|
+
}
|
|
1124
|
+
async function assertUpdateAllowed(slug) {
|
|
1125
|
+
const { error } = await createSupabaseClient().rpc("assert_page_update_allowed", { p_slug: slug });
|
|
1126
|
+
if (error) throw asError(error);
|
|
1127
|
+
}
|
|
818
1128
|
var BUCKET2, NOT_FOUND_MESSAGE;
|
|
819
1129
|
var init_pages = __esm({
|
|
820
1130
|
"src/services/pages.ts"() {
|
|
821
1131
|
"use strict";
|
|
822
1132
|
init_share_url();
|
|
1133
|
+
init_supabase_error();
|
|
823
1134
|
init_supabase();
|
|
824
1135
|
init_validate_html();
|
|
825
1136
|
BUCKET2 = "pages";
|
|
@@ -828,8 +1139,9 @@ var init_pages = __esm({
|
|
|
828
1139
|
});
|
|
829
1140
|
|
|
830
1141
|
// src/commands/update.ts
|
|
1142
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
831
1143
|
function registerUpdate(program2) {
|
|
832
|
-
program2.command("update").description("Replace the HTML behind an existing page URL").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").action(async (slug, file) => {
|
|
1144
|
+
program2.command("update").description("Replace the HTML behind an existing page URL (its local images are uploaded too)").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").option("--expires <when>", "set when the page expires: 30m, 12h, 7d, 2w, a date, or never").action(async (slug, file, options) => {
|
|
833
1145
|
let user;
|
|
834
1146
|
try {
|
|
835
1147
|
user = await requireAuth();
|
|
@@ -838,22 +1150,53 @@ function registerUpdate(program2) {
|
|
|
838
1150
|
throw err;
|
|
839
1151
|
}
|
|
840
1152
|
try {
|
|
1153
|
+
const expiresAt = options.expires === void 0 ? void 0 : parseExpiry(options.expires);
|
|
841
1154
|
console.error(`droppage: updating ${slug} from ${file}...`);
|
|
842
|
-
|
|
1155
|
+
await assertUpdateAllowed(slug);
|
|
1156
|
+
validateHtmlUpload(file, await readFile5(file));
|
|
1157
|
+
const { html } = await attachLocalImages(file, slug, { update: true });
|
|
1158
|
+
const result = await updatePageHtml({ slug, file, user, html });
|
|
1159
|
+
const removed = await pruneUnusedImages(slug);
|
|
1160
|
+
if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
|
|
1161
|
+
if (expiresAt !== void 0) {
|
|
1162
|
+
await setPageExpiry(slug, expiresAt);
|
|
1163
|
+
console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
|
|
1164
|
+
}
|
|
843
1165
|
console.error(`droppage: updated ${slug}`);
|
|
844
1166
|
process.stdout.write(result.url + "\n");
|
|
845
1167
|
} catch (err) {
|
|
846
1168
|
process.exitCode = 1;
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
)
|
|
1169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1170
|
+
console.error(`droppage: update failed: ${message}`);
|
|
1171
|
+
if (/quota reached|limit reached|require (Starter|Builder)/i.test(message)) {
|
|
1172
|
+
console.error("run `droppage quota` to see your limits");
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
program2.command("expire").description("Set when a page (and its images) is deleted: 30m, 12h, 7d, 2w, a date, or never").argument("<slug>", "slug of the page").argument("<when>", "e.g. 7d, 2026-12-31, or never").action(async (slug, when) => {
|
|
1177
|
+
try {
|
|
1178
|
+
await requireAuth();
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
if (err instanceof AuthRequiredError) return;
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
const expiresAt = parseExpiry(when);
|
|
1185
|
+
await setPageExpiry(slug, expiresAt);
|
|
1186
|
+
console.error(expiresAt ? `droppage: ${slug} expires ${expiresAt.toISOString()}` : `droppage: ${slug} never expires`);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
process.exitCode = 1;
|
|
1189
|
+
console.error(`droppage: expire failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
850
1190
|
}
|
|
851
1191
|
});
|
|
852
1192
|
}
|
|
853
1193
|
var init_update = __esm({
|
|
854
1194
|
"src/commands/update.ts"() {
|
|
855
1195
|
"use strict";
|
|
1196
|
+
init_validate_html();
|
|
1197
|
+
init_duration();
|
|
856
1198
|
init_auth();
|
|
1199
|
+
init_page_images();
|
|
857
1200
|
init_pages();
|
|
858
1201
|
}
|
|
859
1202
|
});
|
|
@@ -883,7 +1226,8 @@ function pageTableCells(row) {
|
|
|
883
1226
|
formatModeCell(row.access_mode, row.allowed_emails),
|
|
884
1227
|
formatDateCell(row.created_at),
|
|
885
1228
|
String(row.view_count),
|
|
886
|
-
formatDateCell(row.last_viewed_at)
|
|
1229
|
+
formatDateCell(row.last_viewed_at),
|
|
1230
|
+
row.expires_at ? row.expires_at.slice(0, 16).replace("T", " ") : "never"
|
|
887
1231
|
];
|
|
888
1232
|
}
|
|
889
1233
|
var PAGE_TABLE_HEADERS, PAGE_TABLE_ALIGN;
|
|
@@ -895,13 +1239,15 @@ var init_table = __esm({
|
|
|
895
1239
|
"MODE",
|
|
896
1240
|
"CREATED",
|
|
897
1241
|
"VIEWS",
|
|
898
|
-
"LAST VIEWED"
|
|
1242
|
+
"LAST VIEWED",
|
|
1243
|
+
"EXPIRES"
|
|
899
1244
|
];
|
|
900
1245
|
PAGE_TABLE_ALIGN = [
|
|
901
1246
|
"left",
|
|
902
1247
|
"left",
|
|
903
1248
|
"left",
|
|
904
1249
|
"right",
|
|
1250
|
+
"left",
|
|
905
1251
|
"left"
|
|
906
1252
|
];
|
|
907
1253
|
}
|
|
@@ -1243,6 +1589,353 @@ var init_recipients2 = __esm({
|
|
|
1243
1589
|
}
|
|
1244
1590
|
});
|
|
1245
1591
|
|
|
1592
|
+
// src/services/quota.ts
|
|
1593
|
+
async function fetchQuota() {
|
|
1594
|
+
const supabase = createSupabaseClient();
|
|
1595
|
+
const { data } = await supabase.auth.getSession();
|
|
1596
|
+
const token = data.session?.access_token;
|
|
1597
|
+
if (!token) throw new QuotaRequestError("not signed in; run `droppage login`", 401);
|
|
1598
|
+
let response;
|
|
1599
|
+
try {
|
|
1600
|
+
response = await fetch(`${resolveShareBaseUrl()}/api/account`, {
|
|
1601
|
+
method: "GET",
|
|
1602
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1603
|
+
});
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
throw new QuotaRequestError(
|
|
1606
|
+
`could not reach ${resolveShareBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1607
|
+
0
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
const text = await response.text();
|
|
1611
|
+
let body = null;
|
|
1612
|
+
try {
|
|
1613
|
+
body = text ? JSON.parse(text) : null;
|
|
1614
|
+
} catch {
|
|
1615
|
+
}
|
|
1616
|
+
if (!response.ok) {
|
|
1617
|
+
const message = body?.error ?? `request failed (HTTP ${response.status})`;
|
|
1618
|
+
throw new QuotaRequestError(message, response.status);
|
|
1619
|
+
}
|
|
1620
|
+
if (body === null || typeof body !== "object" || !("usage" in body) || !("entitlements" in body) || typeof body.usage !== "object" || body.usage === null || typeof body.entitlements !== "object" || body.entitlements === null) {
|
|
1621
|
+
throw new QuotaRequestError(`request failed (HTTP ${response.status})`, response.status);
|
|
1622
|
+
}
|
|
1623
|
+
return body;
|
|
1624
|
+
}
|
|
1625
|
+
var QuotaRequestError;
|
|
1626
|
+
var init_quota = __esm({
|
|
1627
|
+
"src/services/quota.ts"() {
|
|
1628
|
+
"use strict";
|
|
1629
|
+
init_share_url();
|
|
1630
|
+
init_supabase();
|
|
1631
|
+
QuotaRequestError = class extends Error {
|
|
1632
|
+
constructor(message, status) {
|
|
1633
|
+
super(message);
|
|
1634
|
+
this.status = status;
|
|
1635
|
+
this.name = "QuotaRequestError";
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// src/commands/quota.ts
|
|
1642
|
+
function formatBytes2(value) {
|
|
1643
|
+
if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
|
|
1644
|
+
if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
|
|
1645
|
+
return `${Number((value / 1e9).toFixed(2))} GB`;
|
|
1646
|
+
}
|
|
1647
|
+
function usedAndLimit(usage) {
|
|
1648
|
+
const used = usage.meter === "views" ? `${usage.used} views` : formatBytes2(usage.used);
|
|
1649
|
+
if (usage.limit === null) return `${used} / unlimited`;
|
|
1650
|
+
const limit = usage.meter === "views" ? `${usage.limit} views` : formatBytes2(usage.limit);
|
|
1651
|
+
return `${used} / ${limit} (${usage.percent}%)`;
|
|
1652
|
+
}
|
|
1653
|
+
function registerQuota(program2) {
|
|
1654
|
+
program2.command("quota").description("Show your plan, current usage, and publishing limits").option("--json", "print the account API response as JSON").action(async (options) => {
|
|
1655
|
+
try {
|
|
1656
|
+
await requireAuth();
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
if (err instanceof AuthRequiredError) return;
|
|
1659
|
+
throw err;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
const result = await fetchQuota();
|
|
1663
|
+
if (options.json) {
|
|
1664
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const enabled = [
|
|
1668
|
+
result.entitlements.can_update && "update",
|
|
1669
|
+
result.entitlements.can_preview && "preview",
|
|
1670
|
+
result.entitlements.can_password && "password",
|
|
1671
|
+
result.entitlements.can_email_allowlist && "email"
|
|
1672
|
+
].filter((feature) => Boolean(feature));
|
|
1673
|
+
const pageLimit = result.usage.pages.limit === null ? "unlimited" : String(result.usage.pages.limit);
|
|
1674
|
+
process.stdout.write(renderTable(
|
|
1675
|
+
["ITEM", "VALUE"],
|
|
1676
|
+
[
|
|
1677
|
+
["Plan", result.entitlements.plan_name],
|
|
1678
|
+
["Pages", `${result.usage.pages.used} / ${pageLimit}`],
|
|
1679
|
+
...result.usage.images ? [["Images per page", result.usage.images.per_page_limit_bytes === null ? "unlimited" : `up to ${formatBytes2(result.usage.images.per_page_limit_bytes)}`]] : [],
|
|
1680
|
+
["Monthly meter", usedAndLimit(result.usage)],
|
|
1681
|
+
["Period ends", result.usage.period_end],
|
|
1682
|
+
["Features", enabled.length ? enabled.join(", ") : "basic publishing"]
|
|
1683
|
+
]
|
|
1684
|
+
) + "\n");
|
|
1685
|
+
console.error(`droppage: quota level ${result.usage.level}.`);
|
|
1686
|
+
} catch (err) {
|
|
1687
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1688
|
+
console.error(`droppage: quota failed: ${message}`);
|
|
1689
|
+
process.exitCode = 1;
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
var init_quota2 = __esm({
|
|
1694
|
+
"src/commands/quota.ts"() {
|
|
1695
|
+
"use strict";
|
|
1696
|
+
init_table();
|
|
1697
|
+
init_auth();
|
|
1698
|
+
init_quota();
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
// src/services/feedback.ts
|
|
1703
|
+
async function submitFeedback(input2) {
|
|
1704
|
+
const { data } = await createSupabaseClient().auth.getSession();
|
|
1705
|
+
const token = data.session?.access_token;
|
|
1706
|
+
const base = resolveShareBaseUrl();
|
|
1707
|
+
let response;
|
|
1708
|
+
try {
|
|
1709
|
+
response = await fetch(`${base}/api/feedback`, {
|
|
1710
|
+
method: "POST",
|
|
1711
|
+
headers: {
|
|
1712
|
+
"Content-Type": "application/json",
|
|
1713
|
+
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
1714
|
+
},
|
|
1715
|
+
body: JSON.stringify(input2)
|
|
1716
|
+
});
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
throw new FeedbackRequestError(
|
|
1719
|
+
`could not reach ${base}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1720
|
+
0
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
const text = await response.text();
|
|
1724
|
+
let body = null;
|
|
1725
|
+
try {
|
|
1726
|
+
body = text ? JSON.parse(text) : null;
|
|
1727
|
+
} catch {
|
|
1728
|
+
}
|
|
1729
|
+
if (!response.ok || !body?.ok || !body.id) {
|
|
1730
|
+
throw new FeedbackRequestError(
|
|
1731
|
+
body?.error ?? `request failed (HTTP ${response.status})`,
|
|
1732
|
+
response.status
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
return { ok: true, id: body.id };
|
|
1736
|
+
}
|
|
1737
|
+
var FeedbackRequestError;
|
|
1738
|
+
var init_feedback = __esm({
|
|
1739
|
+
"src/services/feedback.ts"() {
|
|
1740
|
+
"use strict";
|
|
1741
|
+
init_share_url();
|
|
1742
|
+
init_supabase();
|
|
1743
|
+
FeedbackRequestError = class extends Error {
|
|
1744
|
+
constructor(message, status) {
|
|
1745
|
+
super(message);
|
|
1746
|
+
this.status = status;
|
|
1747
|
+
this.name = "FeedbackRequestError";
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
// src/commands/feedback.ts
|
|
1754
|
+
function isKind(value) {
|
|
1755
|
+
return value === "idea" || value === "bug" || value === "review" || value === "other";
|
|
1756
|
+
}
|
|
1757
|
+
function registerFeedback(program2) {
|
|
1758
|
+
program2.command("feedback").description("Send an idea, bug report, review, or other feedback").argument("<message>", "feedback message (3\u20134000 characters)").option("--kind <kind>", "idea, bug, review, or other", "other").option("--agent [model]", "identify this feedback as coming from an agent").option("--contact <email>", "optional reply-to email address").option("--rating <rating>", "optional 1\u20135 rating for a review").option("--allow-quote", "allow this review to be quoted publicly (never publishes automatically)").action(async (message, options) => {
|
|
1759
|
+
const cleanedMessage = message.trim();
|
|
1760
|
+
const model = typeof options.agent === "string" ? options.agent.trim() : void 0;
|
|
1761
|
+
const contact = options.contact?.trim().toLowerCase();
|
|
1762
|
+
if (cleanedMessage.length < 3 || cleanedMessage.length > 4e3) {
|
|
1763
|
+
console.error("droppage: feedback must be between 3 and 4000 characters");
|
|
1764
|
+
process.exitCode = 1;
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
if (!isKind(options.kind)) {
|
|
1768
|
+
console.error("droppage: --kind must be idea, bug, review, or other");
|
|
1769
|
+
process.exitCode = 1;
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
if (model && model.length > 120) {
|
|
1773
|
+
console.error("droppage: agent model must be 120 characters or fewer");
|
|
1774
|
+
process.exitCode = 1;
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (contact && !emailSchema.safeParse(contact).success) {
|
|
1778
|
+
console.error(`droppage: invalid contact email: ${contact}`);
|
|
1779
|
+
process.exitCode = 1;
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const rating = options.rating === void 0 ? void 0 : Number(options.rating);
|
|
1783
|
+
if (rating !== void 0 && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
|
|
1784
|
+
console.error("droppage: --rating must be a whole number from 1 to 5");
|
|
1785
|
+
process.exitCode = 1;
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (options.kind !== "review" && (rating !== void 0 || options.allowQuote)) {
|
|
1789
|
+
console.error("droppage: --rating and --allow-quote require --kind review");
|
|
1790
|
+
process.exitCode = 1;
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
try {
|
|
1794
|
+
const result = await submitFeedback({
|
|
1795
|
+
message: cleanedMessage,
|
|
1796
|
+
kind: options.kind,
|
|
1797
|
+
actor: options.agent === void 0 ? "human" : "agent",
|
|
1798
|
+
...model ? { model } : {},
|
|
1799
|
+
...contact ? { contact } : {},
|
|
1800
|
+
...rating !== void 0 ? { rating } : {},
|
|
1801
|
+
...options.allowQuote ? { testimonial_consent: true } : {},
|
|
1802
|
+
source: "cli",
|
|
1803
|
+
cli_version: package_default.version
|
|
1804
|
+
});
|
|
1805
|
+
process.stdout.write(`${result.id}
|
|
1806
|
+
`);
|
|
1807
|
+
console.error("droppage: thanks \u2014 your feedback was sent.");
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
1810
|
+
console.error(`droppage: feedback failed: ${message2}`);
|
|
1811
|
+
process.exitCode = 1;
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
var init_feedback2 = __esm({
|
|
1816
|
+
"src/commands/feedback.ts"() {
|
|
1817
|
+
"use strict";
|
|
1818
|
+
init_package();
|
|
1819
|
+
init_auth();
|
|
1820
|
+
init_feedback();
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
// src/commands/upgrade.ts
|
|
1825
|
+
import { spawn } from "child_process";
|
|
1826
|
+
function installLatest() {
|
|
1827
|
+
return new Promise((resolve2, reject) => {
|
|
1828
|
+
const child = spawn("npm", ["install", "-g", "droppage@latest"], {
|
|
1829
|
+
stdio: "inherit",
|
|
1830
|
+
shell: process.platform === "win32"
|
|
1831
|
+
});
|
|
1832
|
+
child.once("error", reject);
|
|
1833
|
+
child.once("close", (code) => code === 0 ? resolve2() : reject(new Error(`npm exited with status ${code ?? "unknown"}`)));
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
function registerUpgrade(program2) {
|
|
1837
|
+
program2.command("upgrade").description("Install the latest droppage CLI release").action(async () => {
|
|
1838
|
+
try {
|
|
1839
|
+
await installLatest();
|
|
1840
|
+
console.error("droppage: upgrade complete; run `droppage --version` to confirm.");
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
console.error(`droppage: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1843
|
+
process.exitCode = 1;
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
var init_upgrade = __esm({
|
|
1848
|
+
"src/commands/upgrade.ts"() {
|
|
1849
|
+
"use strict";
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
// src/commands/image.ts
|
|
1854
|
+
function formatBytes3(value) {
|
|
1855
|
+
if (value < 1e3) return `${value} B`;
|
|
1856
|
+
if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
|
|
1857
|
+
if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
|
|
1858
|
+
return `${Number((value / 1e9).toFixed(2))} GB`;
|
|
1859
|
+
}
|
|
1860
|
+
async function signedIn() {
|
|
1861
|
+
try {
|
|
1862
|
+
await requireAuth();
|
|
1863
|
+
return true;
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
if (err instanceof AuthRequiredError) return false;
|
|
1866
|
+
throw err;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function fail(err) {
|
|
1870
|
+
console.error(`droppage: ${err instanceof Error ? err.message : String(err)}`);
|
|
1871
|
+
process.exitCode = 1;
|
|
1872
|
+
}
|
|
1873
|
+
function registerImage(program2) {
|
|
1874
|
+
const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
|
|
1875
|
+
image.command("upload").description("Add images to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each)").argument("<files...>", "image files").requiredOption("--page <slug>", "the page the images belong to (they go when it is deleted or expires)").option("--json", "print the results as JSON").action(async (files, options) => {
|
|
1876
|
+
if (!await signedIn()) return;
|
|
1877
|
+
const results = [];
|
|
1878
|
+
for (const file of files) {
|
|
1879
|
+
try {
|
|
1880
|
+
const uploaded = await uploadImage(file, options.page);
|
|
1881
|
+
results.push({ file, ...uploaded });
|
|
1882
|
+
if (!options.json) {
|
|
1883
|
+
process.stdout.write(`${uploaded.url}
|
|
1884
|
+
`);
|
|
1885
|
+
if (uploaded.existing) console.error(`droppage: ${file} is already on this page; same URL.`);
|
|
1886
|
+
}
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
fail(new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
1892
|
+
});
|
|
1893
|
+
image.command("list").description("List your pages' images (one page with --page, including its image budget)").option("--page <slug>", "only this page").option("--json", "print the list as JSON").action(async (options) => {
|
|
1894
|
+
if (!await signedIn()) return;
|
|
1895
|
+
try {
|
|
1896
|
+
const list = await listImages(options.page);
|
|
1897
|
+
if (options.json) {
|
|
1898
|
+
process.stdout.write(JSON.stringify(list, null, 2) + "\n");
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
if (list.images.length === 0) {
|
|
1902
|
+
console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
|
|
1903
|
+
} else {
|
|
1904
|
+
process.stdout.write(renderTable(
|
|
1905
|
+
["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
|
|
1906
|
+
list.images.map((row) => [row.id, row.page ?? "", row.name ?? "", formatBytes3(row.bytes), formatDateCell(row.created_at), row.url]),
|
|
1907
|
+
["left", "left", "left", "right", "left", "left"]
|
|
1908
|
+
) + "\n");
|
|
1909
|
+
}
|
|
1910
|
+
if (list.page) {
|
|
1911
|
+
const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
|
|
1912
|
+
console.error(`droppage: images on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
|
|
1913
|
+
}
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
fail(err);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
image.command("delete").description("Delete images (by id or URL); a browser that already loaded one may keep it up to a day").argument("<images...>", "image ids or URLs").action(async (ids) => {
|
|
1919
|
+
if (!await signedIn()) return;
|
|
1920
|
+
for (const value of ids) {
|
|
1921
|
+
try {
|
|
1922
|
+
const id = await deleteImage(value);
|
|
1923
|
+
console.error(`droppage: deleted image ${id}`);
|
|
1924
|
+
} catch (err) {
|
|
1925
|
+
fail(new Error(`${value}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
var init_image = __esm({
|
|
1931
|
+
"src/commands/image.ts"() {
|
|
1932
|
+
"use strict";
|
|
1933
|
+
init_table();
|
|
1934
|
+
init_auth();
|
|
1935
|
+
init_images();
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1246
1939
|
// src/index.ts
|
|
1247
1940
|
var index_exports = {};
|
|
1248
1941
|
__export(index_exports, {
|
|
@@ -1252,7 +1945,7 @@ import { Command } from "commander";
|
|
|
1252
1945
|
function createProgram() {
|
|
1253
1946
|
const program2 = new Command().name("droppage").description(
|
|
1254
1947
|
"Publish HTML files as shareable, access-controlled live pages"
|
|
1255
|
-
).version(package_default.version);
|
|
1948
|
+
).version(package_default.version).configureOutput({ outputError: (str, write) => write(str + dashSlugHint(str)) });
|
|
1256
1949
|
registerLogin(program2);
|
|
1257
1950
|
registerLogout(program2);
|
|
1258
1951
|
registerUpload(program2);
|
|
@@ -1260,6 +1953,10 @@ function createProgram() {
|
|
|
1260
1953
|
registerList(program2);
|
|
1261
1954
|
registerDelete(program2);
|
|
1262
1955
|
registerRecipients(program2);
|
|
1956
|
+
registerQuota(program2);
|
|
1957
|
+
registerImage(program2);
|
|
1958
|
+
registerFeedback(program2);
|
|
1959
|
+
registerUpgrade(program2);
|
|
1263
1960
|
return program2;
|
|
1264
1961
|
}
|
|
1265
1962
|
var init_index = __esm({
|
|
@@ -1273,6 +1970,71 @@ var init_index = __esm({
|
|
|
1273
1970
|
init_list();
|
|
1274
1971
|
init_delete();
|
|
1275
1972
|
init_recipients2();
|
|
1973
|
+
init_quota2();
|
|
1974
|
+
init_feedback2();
|
|
1975
|
+
init_upgrade();
|
|
1976
|
+
init_image();
|
|
1977
|
+
init_slug();
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
// src/lib/update-check.ts
|
|
1982
|
+
var update_check_exports = {};
|
|
1983
|
+
__export(update_check_exports, {
|
|
1984
|
+
checkForUpdate: () => checkForUpdate
|
|
1985
|
+
});
|
|
1986
|
+
function stableVersion(value) {
|
|
1987
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
1988
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
1989
|
+
}
|
|
1990
|
+
function isNewerVersion(latest, current) {
|
|
1991
|
+
const next = stableVersion(latest);
|
|
1992
|
+
const installed = stableVersion(current);
|
|
1993
|
+
if (!next || !installed) return false;
|
|
1994
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
1995
|
+
if (next[index] !== installed[index]) return next[index] > installed[index];
|
|
1996
|
+
}
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
function shouldCheck() {
|
|
2000
|
+
return Boolean(process.stderr.isTTY) && !process.env.DROPPAGE_NO_UPDATE_CHECK && !process.env.CI;
|
|
2001
|
+
}
|
|
2002
|
+
async function checkForUpdate() {
|
|
2003
|
+
if (!shouldCheck()) return;
|
|
2004
|
+
try {
|
|
2005
|
+
const cache = getUpdateCheckConf();
|
|
2006
|
+
const lastCheckedAt = Date.parse(cache.get("lastCheckedAt") ?? "");
|
|
2007
|
+
const stale = !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt >= CHECK_INTERVAL_MS;
|
|
2008
|
+
let latest = cache.get("latest");
|
|
2009
|
+
if (stale) {
|
|
2010
|
+
const response = await fetch(REGISTRY_URL, {
|
|
2011
|
+
headers: { Accept: "application/json" },
|
|
2012
|
+
signal: AbortSignal.timeout(1500)
|
|
2013
|
+
});
|
|
2014
|
+
if (!response.ok) return;
|
|
2015
|
+
const body = await response.json();
|
|
2016
|
+
if (typeof body.version !== "string" || !body.version.trim()) return;
|
|
2017
|
+
latest = body.version.trim();
|
|
2018
|
+
cache.set("latest", latest);
|
|
2019
|
+
cache.set("lastCheckedAt", (/* @__PURE__ */ new Date()).toISOString());
|
|
2020
|
+
}
|
|
2021
|
+
if (latest && isNewerVersion(latest, package_default.version)) {
|
|
2022
|
+
process.stderr.write(
|
|
2023
|
+
`droppage ${package_default.version} \u2192 ${latest} available: npm install -g droppage@latest (or droppage upgrade)
|
|
2024
|
+
`
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
} catch {
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
var CHECK_INTERVAL_MS, REGISTRY_URL;
|
|
2031
|
+
var init_update_check = __esm({
|
|
2032
|
+
"src/lib/update-check.ts"() {
|
|
2033
|
+
"use strict";
|
|
2034
|
+
init_package();
|
|
2035
|
+
init_config();
|
|
2036
|
+
CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
2037
|
+
REGISTRY_URL = "https://registry.npmjs.org/droppage/latest";
|
|
1276
2038
|
}
|
|
1277
2039
|
});
|
|
1278
2040
|
|
|
@@ -1290,3 +2052,5 @@ Upgrade Node.js: https://nodejs.org/
|
|
|
1290
2052
|
var { createProgram: createProgram2 } = await Promise.resolve().then(() => (init_index(), index_exports));
|
|
1291
2053
|
var program = createProgram2();
|
|
1292
2054
|
await program.parseAsync(process.argv);
|
|
2055
|
+
var { checkForUpdate: checkForUpdate2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
|
|
2056
|
+
await checkForUpdate2();
|