droppage 0.1.1 → 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 +91 -11
- package/dist/cli.js +802 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,14 +38,21 @@ 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,22 +66,76 @@ Run `droppage <command> --help` for full option details.
|
|
|
59
66
|
| `--private` | private | Only you (your account email). Useful for drafts. |
|
|
60
67
|
|
|
61
68
|
`--password`, `--email`, and `--private` are mutually exclusive. `update` never changes the access
|
|
62
|
-
mode — it only swaps the HTML.
|
|
69
|
+
mode — it only swaps the HTML. When you are signed in to droppage.dev, your own protected pages
|
|
70
|
+
open directly, without the password or email link.
|
|
63
71
|
|
|
64
72
|
## Rules for uploads
|
|
65
73
|
|
|
66
74
|
- One `.html` or `.htm` file per upload; the file is validated before any network call.
|
|
67
|
-
- Inline your
|
|
68
|
-
|
|
75
|
+
- Inline your CSS, JS, and data. Relative asset paths other than images will not resolve. Local
|
|
76
|
+
images referenced in `<img src>` are uploaded with the page (see below); sensitive images belong
|
|
77
|
+
inline as data: URIs.
|
|
78
|
+
- Views shown by `list` count one unique visitor per page. Refreshes, reopening in the same browser,
|
|
79
|
+
link previews from chat apps, and your own opens do not add views.
|
|
69
80
|
- Preview URLs (`list --preview`) never increment the view count.
|
|
70
81
|
|
|
82
|
+
## Images
|
|
83
|
+
|
|
84
|
+
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
|
|
85
|
+
file, and upload it:
|
|
86
|
+
|
|
87
|
+
```html
|
|
88
|
+
<img src="chart.png">
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
$ droppage upload report.html
|
|
93
|
+
droppage: image chart.png → https://droppage.dev/i/AbCdEfGhIjKl.png
|
|
94
|
+
https://droppage.dev/p/k3x9qd2m
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`upload` finds the images, uploads them to that page, rewrites the links to
|
|
98
|
+
`https://droppage.dev/i/<id>.<ext>`, then publishes. The image lines go to stderr; stdout is still
|
|
99
|
+
only the page URL.
|
|
100
|
+
|
|
101
|
+
- `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
|
|
102
|
+
the page's images the new HTML no longer uses.
|
|
103
|
+
- Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
|
|
104
|
+
- Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
|
|
105
|
+
PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
|
|
106
|
+
- Prefer small formats: WebP or AVIF for photos and screenshots, SVG for charts and logos (often
|
|
107
|
+
several times smaller than PNG or JPEG). The CLI prints a tip on stderr when a PNG, JPEG, or GIF
|
|
108
|
+
is over 300 KB.
|
|
109
|
+
- **Images are public** to anyone with the URL. A page's password, email, or private gate does not
|
|
110
|
+
protect them. Keep sensitive images inline in the HTML as data: URIs instead.
|
|
111
|
+
- Advanced: `droppage image upload <files...> --page <slug>` adds images to an existing page and
|
|
112
|
+
prints their URLs; `droppage image list [--page <slug>] [--json]` shows them (`--json` prints
|
|
113
|
+
`{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
|
|
114
|
+
`droppage image delete <id-or-url...>` removes them.
|
|
115
|
+
|
|
116
|
+
## Expiry
|
|
117
|
+
|
|
118
|
+
Pages can expire. Nothing expires unless you set it, on any plan.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
droppage upload report.html --expires 7d # also 30m, 12h, 2w, or a date like 2026-12-31
|
|
122
|
+
droppage update <slug> report.html --expires never
|
|
123
|
+
droppage expire <slug> 2026-12-31 # change it without re-uploading; `never` clears it
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
After expiry the page shows "This page has expired." (HTTP 410) to everyone, including you, and
|
|
127
|
+
its images stop working. A daily cleanup then deletes the page and its images for good.
|
|
128
|
+
`droppage list` shows an EXPIRES column (`never` or a date); `list --json` rows have `expires_at`
|
|
129
|
+
(`null` means never).
|
|
130
|
+
|
|
71
131
|
## For agents and scripts
|
|
72
132
|
|
|
73
133
|
droppage keeps a strict stream contract so it is safe to call from automation:
|
|
74
134
|
|
|
75
|
-
- `upload` and `update` print **only the URL** on stdout
|
|
76
|
-
|
|
77
|
-
-
|
|
135
|
+
- `upload` and `update` print **only the URL** on stdout, even when they upload images;
|
|
136
|
+
`image upload` prints **only the image URL(s)**; `expire` prints **nothing** on stdout.
|
|
137
|
+
- `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
|
|
138
|
+
- Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
|
|
78
139
|
- Non-zero exit code on any failure.
|
|
79
140
|
- Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
|
|
80
141
|
|
|
@@ -84,10 +145,28 @@ Ready-made instruction packs for Codex, Cursor, and Claude-style agents live in
|
|
|
84
145
|
## Updating
|
|
85
146
|
|
|
86
147
|
```bash
|
|
87
|
-
|
|
148
|
+
droppage upgrade # upgrade to the latest release
|
|
88
149
|
droppage --version # confirm the installed version
|
|
89
150
|
```
|
|
90
151
|
|
|
152
|
+
After a command finishes, interactive terminals check npm for a new release at most once every 24
|
|
153
|
+
hours and put any update notice on stderr only. The check sends no identifier or usage data, and
|
|
154
|
+
all errors are ignored. Set `DROPPAGE_NO_UPDATE_CHECK=1` to opt out. This registry lookup is the
|
|
155
|
+
CLI's only non-backend network request; droppage still collects no telemetry.
|
|
156
|
+
|
|
157
|
+
Before bulk publishing, use `droppage quota --json`. A `warn` level means the monthly meter is at
|
|
158
|
+
least 80% used; `limit` means new uploads, updates, or previews may pause. Existing links remain
|
|
159
|
+
live. Delete unneeded pages or review plan options at [droppage.dev/account](https://droppage.dev/account).
|
|
160
|
+
|
|
161
|
+
Feedback is public and login is optional:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
droppage feedback "The preview command should…" --kind idea
|
|
165
|
+
droppage feedback "Upload failed after…" --kind bug --agent "my-model"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Agent-authored feedback should always use `--agent` and include the model name.
|
|
169
|
+
|
|
91
170
|
Releases are published from GitHub Actions with
|
|
92
171
|
[npm provenance](https://docs.npmjs.com/generating-provenance-statements), so every version on npm
|
|
93
172
|
is verifiably built from a tagged commit in the public repository.
|
|
@@ -102,6 +181,7 @@ setups, these environment variables are honoured (in order of precedence):
|
|
|
102
181
|
| `DROPPAGE_SUPABASE_URL` / `DROPPAGE_SUPABASE_ANON_KEY` | Point the CLI at another backend. |
|
|
103
182
|
| `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Fallback names for the same. |
|
|
104
183
|
| `DROPPAGE_SHARE_BASE_URL` | Origin used when printing share URLs (default `https://droppage.dev`). |
|
|
184
|
+
| `DROPPAGE_NO_UPDATE_CHECK` | Set to `1` to disable the optional 24-hour npm release check. |
|
|
105
185
|
|
|
106
186
|
## Security
|
|
107
187
|
|
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.0",
|
|
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,6 +588,143 @@ 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() {
|
|
@@ -514,14 +740,34 @@ var init_slug = __esm({
|
|
|
514
740
|
}
|
|
515
741
|
});
|
|
516
742
|
|
|
743
|
+
// src/lib/supabase-error.ts
|
|
744
|
+
function asError(err) {
|
|
745
|
+
if (err instanceof Error) return err;
|
|
746
|
+
if (err !== null && typeof err === "object" && typeof err.message === "string") {
|
|
747
|
+
const error = new Error(err.message);
|
|
748
|
+
const code = err.code;
|
|
749
|
+
if (typeof code === "string") Object.assign(error, { code });
|
|
750
|
+
return error;
|
|
751
|
+
}
|
|
752
|
+
return new Error(String(err));
|
|
753
|
+
}
|
|
754
|
+
var init_supabase_error = __esm({
|
|
755
|
+
"src/lib/supabase-error.ts"() {
|
|
756
|
+
"use strict";
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
517
760
|
// src/lib/validate-html.ts
|
|
518
|
-
import { extname } from "path";
|
|
761
|
+
import { extname as extname2 } from "path";
|
|
762
|
+
function isHostedImage(url) {
|
|
763
|
+
return url.startsWith(`${resolveShareBaseUrl()}/i/`);
|
|
764
|
+
}
|
|
519
765
|
function formatBytes(bytes) {
|
|
520
766
|
const mb = bytes / (1024 * 1024);
|
|
521
767
|
return `${mb.toFixed(1)} MB`;
|
|
522
768
|
}
|
|
523
769
|
function validateHtmlUpload(path, bytes) {
|
|
524
|
-
const ext =
|
|
770
|
+
const ext = extname2(path).toLowerCase();
|
|
525
771
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
526
772
|
throw new Error(
|
|
527
773
|
`Unsupported file type: ${path} (expected .html or .htm)`
|
|
@@ -553,7 +799,8 @@ function validateHtmlUpload(path, bytes) {
|
|
|
553
799
|
"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
800
|
);
|
|
555
801
|
}
|
|
556
|
-
|
|
802
|
+
const external = [...body.matchAll(EXTERNAL_ASSET_RE)].map((m) => m[1]).filter((url) => !isHostedImage(url));
|
|
803
|
+
if (external.length > 0) {
|
|
557
804
|
warnings.push(
|
|
558
805
|
"HTML references external assets (//host or http(s)://); they must be publicly reachable to render in the shared page."
|
|
559
806
|
);
|
|
@@ -564,6 +811,7 @@ var MAX_UPLOAD_BYTES, ALLOWED_EXTENSIONS, SNIFF_BYTES, HTML_MARKERS, RELATIVE_AS
|
|
|
564
811
|
var init_validate_html = __esm({
|
|
565
812
|
"src/lib/validate-html.ts"() {
|
|
566
813
|
"use strict";
|
|
814
|
+
init_share_url();
|
|
567
815
|
MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
568
816
|
ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
569
817
|
SNIFF_BYTES = 4096;
|
|
@@ -574,33 +822,51 @@ var init_validate_html = __esm({
|
|
|
574
822
|
/<body[\s>]/i,
|
|
575
823
|
/<div[\s>]/i
|
|
576
824
|
];
|
|
577
|
-
RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/))/i;
|
|
578
|
-
EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["'](
|
|
825
|
+
RELATIVE_ASSET_RE = /(?:src|href)\s*=\s*["'](?:\.\.?\/|\/(?!\/)(?!i\/[A-Za-z0-9]{12}\.[a-z]+["']))/i;
|
|
826
|
+
EXTERNAL_ASSET_RE = /(?:src|href)\s*=\s*["']((?:\/\/|https?:\/\/)[^"']*)/gi;
|
|
579
827
|
}
|
|
580
828
|
});
|
|
581
829
|
|
|
582
830
|
// src/services/upload.ts
|
|
583
|
-
import { readFile } from "fs/promises";
|
|
831
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
584
832
|
async function upload({
|
|
585
833
|
file,
|
|
586
834
|
user,
|
|
587
835
|
passwordHash,
|
|
588
836
|
allowedEmails,
|
|
589
|
-
accessMode
|
|
837
|
+
accessMode,
|
|
838
|
+
prepare,
|
|
839
|
+
expiresAt
|
|
590
840
|
}) {
|
|
591
|
-
|
|
841
|
+
let bytes = await readFile3(file);
|
|
592
842
|
const { warnings } = validateHtmlUpload(file, bytes);
|
|
593
|
-
for (const w of warnings) console.error(`droppage: warning: ${w}`);
|
|
843
|
+
if (!prepare) for (const w of warnings) console.error(`droppage: warning: ${w}`);
|
|
594
844
|
const supabase = createSupabaseClient();
|
|
845
|
+
const resolvedMode = accessMode ?? (allowedEmails ? "email" : passwordHash ? "password" : "public");
|
|
846
|
+
const preflight = await supabase.rpc("assert_page_create_allowed", {
|
|
847
|
+
p_access_mode: resolvedMode,
|
|
848
|
+
p_recipient_count: allowedEmails?.length ?? 0
|
|
849
|
+
});
|
|
850
|
+
if (preflight.error) throw asError(preflight.error);
|
|
595
851
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
596
852
|
const slug = createSlug();
|
|
597
853
|
const storagePath = `${user.id}/${slug}.html`;
|
|
854
|
+
if (prepare) {
|
|
855
|
+
bytes = await prepare(slug);
|
|
856
|
+
if (attempt === 0) for (const w of validateHtmlUpload(file, bytes).warnings) console.error(`droppage: warning: ${w}`);
|
|
857
|
+
}
|
|
598
858
|
const up = await supabase.storage.from(BUCKET).upload(storagePath, bytes, {
|
|
599
859
|
contentType: "text/html; charset=utf-8",
|
|
600
860
|
upsert: false
|
|
601
861
|
});
|
|
602
|
-
if (up.error)
|
|
603
|
-
|
|
862
|
+
if (up.error) {
|
|
863
|
+
if (/row-level security/i.test(up.error.message)) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`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.`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
throw asError(up.error);
|
|
869
|
+
}
|
|
604
870
|
const ins = await supabase.rpc("create_page_with_entitlements", {
|
|
605
871
|
p_slug: slug,
|
|
606
872
|
p_storage_path: storagePath,
|
|
@@ -608,7 +874,17 @@ async function upload({
|
|
|
608
874
|
p_password_hash: resolvedMode === "password" ? passwordHash : null,
|
|
609
875
|
p_allowed_emails: allowedEmails ?? null
|
|
610
876
|
});
|
|
611
|
-
if (!ins.error)
|
|
877
|
+
if (!ins.error) {
|
|
878
|
+
if (expiresAt) {
|
|
879
|
+
const expiry = await supabase.rpc("set_page_expiry", { p_slug: slug, p_expires_at: expiresAt.toISOString() });
|
|
880
|
+
if (expiry.error) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`published ${buildShareUrl(slug)} but could not set its expiry (${expiry.error.message}); set it with \`droppage expire ${slug} <when>\` or delete it`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return buildShareUrl(slug);
|
|
887
|
+
}
|
|
612
888
|
const rm = await supabase.storage.from(BUCKET).remove([storagePath]);
|
|
613
889
|
if (rm.error) {
|
|
614
890
|
console.error(
|
|
@@ -616,7 +892,7 @@ async function upload({
|
|
|
616
892
|
);
|
|
617
893
|
}
|
|
618
894
|
if (ins.error.code === "23505") continue;
|
|
619
|
-
throw ins.error;
|
|
895
|
+
throw asError(ins.error);
|
|
620
896
|
}
|
|
621
897
|
throw new Error(
|
|
622
898
|
`Could not allocate a unique slug after ${MAX_RETRIES} attempts`
|
|
@@ -628,6 +904,7 @@ var init_upload = __esm({
|
|
|
628
904
|
"use strict";
|
|
629
905
|
init_share_url();
|
|
630
906
|
init_slug();
|
|
907
|
+
init_supabase_error();
|
|
631
908
|
init_supabase();
|
|
632
909
|
init_validate_html();
|
|
633
910
|
BUCKET = "pages";
|
|
@@ -642,7 +919,7 @@ function registerUpload(program2) {
|
|
|
642
919
|
"--email <address>",
|
|
643
920
|
"restrict access to an email allowlist (repeatable; comma-separated)",
|
|
644
921
|
(val, prev = []) => prev.concat(val.split(","))
|
|
645
|
-
).option("--private", "restrict access to only you (owner-only draft)").action(
|
|
922
|
+
).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
923
|
async (file, options) => {
|
|
647
924
|
let user;
|
|
648
925
|
try {
|
|
@@ -707,21 +984,28 @@ function registerUpload(program2) {
|
|
|
707
984
|
allowedEmails = [ownerEmail];
|
|
708
985
|
accessMode = "private";
|
|
709
986
|
}
|
|
987
|
+
const expiresAt = options.expires ? parseExpiry(options.expires) ?? void 0 : void 0;
|
|
710
988
|
console.error(`droppage: uploading ${file}...`);
|
|
989
|
+
const prepare = await hasLocalImages(file) ? async (slug) => (await attachLocalImages(file, slug)).html : void 0;
|
|
711
990
|
const url = await upload({
|
|
712
991
|
file,
|
|
713
992
|
user,
|
|
714
993
|
passwordHash,
|
|
715
994
|
allowedEmails,
|
|
716
|
-
accessMode
|
|
995
|
+
accessMode,
|
|
996
|
+
prepare,
|
|
997
|
+
expiresAt
|
|
717
998
|
});
|
|
999
|
+
if (expiresAt) console.error(`droppage: expires ${expiresAt.toISOString()}`);
|
|
718
1000
|
console.error("droppage: done");
|
|
719
1001
|
process.stdout.write(url + "\n");
|
|
720
1002
|
} catch (err) {
|
|
721
1003
|
process.exitCode = 1;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
)
|
|
1004
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
console.error(`droppage: upload failed: ${message}`);
|
|
1006
|
+
if (/quota reached|require (Starter|Builder)/i.test(message)) {
|
|
1007
|
+
console.error("run `droppage quota` to see your limits");
|
|
1008
|
+
}
|
|
725
1009
|
}
|
|
726
1010
|
}
|
|
727
1011
|
);
|
|
@@ -730,17 +1014,19 @@ var init_upload2 = __esm({
|
|
|
730
1014
|
"src/commands/upload.ts"() {
|
|
731
1015
|
"use strict";
|
|
732
1016
|
init_password();
|
|
1017
|
+
init_duration();
|
|
1018
|
+
init_page_images();
|
|
733
1019
|
init_auth();
|
|
734
1020
|
init_upload();
|
|
735
1021
|
}
|
|
736
1022
|
});
|
|
737
1023
|
|
|
738
1024
|
// src/services/pages.ts
|
|
739
|
-
import { readFile as
|
|
1025
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
740
1026
|
async function listPages(user) {
|
|
741
1027
|
const supabase = createSupabaseClient();
|
|
742
1028
|
const { data, error } = await supabase.from("pages").select(
|
|
743
|
-
"slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at"
|
|
1029
|
+
"slug, access_mode, allowed_emails, view_count, created_at, last_viewed_at, expires_at"
|
|
744
1030
|
).eq("owner_id", user.id).order("created_at", { ascending: false });
|
|
745
1031
|
if (error) throw error;
|
|
746
1032
|
return data ?? [];
|
|
@@ -782,9 +1068,10 @@ async function deletePage(slug, user) {
|
|
|
782
1068
|
async function updatePageHtml({
|
|
783
1069
|
slug,
|
|
784
1070
|
file,
|
|
785
|
-
user
|
|
1071
|
+
user,
|
|
1072
|
+
html
|
|
786
1073
|
}) {
|
|
787
|
-
const bytes = await
|
|
1074
|
+
const bytes = html ?? await readFile4(file);
|
|
788
1075
|
const { warnings } = validateHtmlUpload(file, bytes);
|
|
789
1076
|
for (const warning of warnings) {
|
|
790
1077
|
console.error(`droppage: warning: ${warning}`);
|
|
@@ -793,7 +1080,7 @@ async function updatePageHtml({
|
|
|
793
1080
|
const preflight = await supabase.rpc("assert_page_update_allowed", {
|
|
794
1081
|
p_slug: slug
|
|
795
1082
|
});
|
|
796
|
-
if (preflight.error) throw preflight.error;
|
|
1083
|
+
if (preflight.error) throw asError(preflight.error);
|
|
797
1084
|
const { data: row, error: selectError } = await supabase.from("pages").select("slug, storage_path").eq("slug", slug).eq("owner_id", user.id).maybeSingle();
|
|
798
1085
|
if (selectError) throw selectError;
|
|
799
1086
|
if (!row) throw new Error("page not found or not owned");
|
|
@@ -815,11 +1102,24 @@ async function updatePageHtml({
|
|
|
815
1102
|
url: buildShareUrl(slug)
|
|
816
1103
|
};
|
|
817
1104
|
}
|
|
1105
|
+
async function setPageExpiry(slug, expiresAt) {
|
|
1106
|
+
const supabase = createSupabaseClient();
|
|
1107
|
+
const { error } = await supabase.rpc("set_page_expiry", {
|
|
1108
|
+
p_slug: slug,
|
|
1109
|
+
p_expires_at: expiresAt ? expiresAt.toISOString() : null
|
|
1110
|
+
});
|
|
1111
|
+
if (error) throw asError(error);
|
|
1112
|
+
}
|
|
1113
|
+
async function assertUpdateAllowed(slug) {
|
|
1114
|
+
const { error } = await createSupabaseClient().rpc("assert_page_update_allowed", { p_slug: slug });
|
|
1115
|
+
if (error) throw asError(error);
|
|
1116
|
+
}
|
|
818
1117
|
var BUCKET2, NOT_FOUND_MESSAGE;
|
|
819
1118
|
var init_pages = __esm({
|
|
820
1119
|
"src/services/pages.ts"() {
|
|
821
1120
|
"use strict";
|
|
822
1121
|
init_share_url();
|
|
1122
|
+
init_supabase_error();
|
|
823
1123
|
init_supabase();
|
|
824
1124
|
init_validate_html();
|
|
825
1125
|
BUCKET2 = "pages";
|
|
@@ -828,8 +1128,9 @@ var init_pages = __esm({
|
|
|
828
1128
|
});
|
|
829
1129
|
|
|
830
1130
|
// src/commands/update.ts
|
|
1131
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
831
1132
|
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) => {
|
|
1133
|
+
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
1134
|
let user;
|
|
834
1135
|
try {
|
|
835
1136
|
user = await requireAuth();
|
|
@@ -838,22 +1139,53 @@ function registerUpdate(program2) {
|
|
|
838
1139
|
throw err;
|
|
839
1140
|
}
|
|
840
1141
|
try {
|
|
1142
|
+
const expiresAt = options.expires === void 0 ? void 0 : parseExpiry(options.expires);
|
|
841
1143
|
console.error(`droppage: updating ${slug} from ${file}...`);
|
|
842
|
-
|
|
1144
|
+
await assertUpdateAllowed(slug);
|
|
1145
|
+
validateHtmlUpload(file, await readFile5(file));
|
|
1146
|
+
const { html } = await attachLocalImages(file, slug, { update: true });
|
|
1147
|
+
const result = await updatePageHtml({ slug, file, user, html });
|
|
1148
|
+
const removed = await pruneUnusedImages(slug);
|
|
1149
|
+
if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
|
|
1150
|
+
if (expiresAt !== void 0) {
|
|
1151
|
+
await setPageExpiry(slug, expiresAt);
|
|
1152
|
+
console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
|
|
1153
|
+
}
|
|
843
1154
|
console.error(`droppage: updated ${slug}`);
|
|
844
1155
|
process.stdout.write(result.url + "\n");
|
|
845
1156
|
} catch (err) {
|
|
846
1157
|
process.exitCode = 1;
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
)
|
|
1158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1159
|
+
console.error(`droppage: update failed: ${message}`);
|
|
1160
|
+
if (/quota reached|limit reached|require (Starter|Builder)/i.test(message)) {
|
|
1161
|
+
console.error("run `droppage quota` to see your limits");
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
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) => {
|
|
1166
|
+
try {
|
|
1167
|
+
await requireAuth();
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
if (err instanceof AuthRequiredError) return;
|
|
1170
|
+
throw err;
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
const expiresAt = parseExpiry(when);
|
|
1174
|
+
await setPageExpiry(slug, expiresAt);
|
|
1175
|
+
console.error(expiresAt ? `droppage: ${slug} expires ${expiresAt.toISOString()}` : `droppage: ${slug} never expires`);
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
process.exitCode = 1;
|
|
1178
|
+
console.error(`droppage: expire failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
850
1179
|
}
|
|
851
1180
|
});
|
|
852
1181
|
}
|
|
853
1182
|
var init_update = __esm({
|
|
854
1183
|
"src/commands/update.ts"() {
|
|
855
1184
|
"use strict";
|
|
1185
|
+
init_validate_html();
|
|
1186
|
+
init_duration();
|
|
856
1187
|
init_auth();
|
|
1188
|
+
init_page_images();
|
|
857
1189
|
init_pages();
|
|
858
1190
|
}
|
|
859
1191
|
});
|
|
@@ -883,7 +1215,8 @@ function pageTableCells(row) {
|
|
|
883
1215
|
formatModeCell(row.access_mode, row.allowed_emails),
|
|
884
1216
|
formatDateCell(row.created_at),
|
|
885
1217
|
String(row.view_count),
|
|
886
|
-
formatDateCell(row.last_viewed_at)
|
|
1218
|
+
formatDateCell(row.last_viewed_at),
|
|
1219
|
+
row.expires_at ? row.expires_at.slice(0, 16).replace("T", " ") : "never"
|
|
887
1220
|
];
|
|
888
1221
|
}
|
|
889
1222
|
var PAGE_TABLE_HEADERS, PAGE_TABLE_ALIGN;
|
|
@@ -895,13 +1228,15 @@ var init_table = __esm({
|
|
|
895
1228
|
"MODE",
|
|
896
1229
|
"CREATED",
|
|
897
1230
|
"VIEWS",
|
|
898
|
-
"LAST VIEWED"
|
|
1231
|
+
"LAST VIEWED",
|
|
1232
|
+
"EXPIRES"
|
|
899
1233
|
];
|
|
900
1234
|
PAGE_TABLE_ALIGN = [
|
|
901
1235
|
"left",
|
|
902
1236
|
"left",
|
|
903
1237
|
"left",
|
|
904
1238
|
"right",
|
|
1239
|
+
"left",
|
|
905
1240
|
"left"
|
|
906
1241
|
];
|
|
907
1242
|
}
|
|
@@ -1243,6 +1578,353 @@ var init_recipients2 = __esm({
|
|
|
1243
1578
|
}
|
|
1244
1579
|
});
|
|
1245
1580
|
|
|
1581
|
+
// src/services/quota.ts
|
|
1582
|
+
async function fetchQuota() {
|
|
1583
|
+
const supabase = createSupabaseClient();
|
|
1584
|
+
const { data } = await supabase.auth.getSession();
|
|
1585
|
+
const token = data.session?.access_token;
|
|
1586
|
+
if (!token) throw new QuotaRequestError("not signed in; run `droppage login`", 401);
|
|
1587
|
+
let response;
|
|
1588
|
+
try {
|
|
1589
|
+
response = await fetch(`${resolveShareBaseUrl()}/api/account`, {
|
|
1590
|
+
method: "GET",
|
|
1591
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1592
|
+
});
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
throw new QuotaRequestError(
|
|
1595
|
+
`could not reach ${resolveShareBaseUrl()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1596
|
+
0
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
const text = await response.text();
|
|
1600
|
+
let body = null;
|
|
1601
|
+
try {
|
|
1602
|
+
body = text ? JSON.parse(text) : null;
|
|
1603
|
+
} catch {
|
|
1604
|
+
}
|
|
1605
|
+
if (!response.ok) {
|
|
1606
|
+
const message = body?.error ?? `request failed (HTTP ${response.status})`;
|
|
1607
|
+
throw new QuotaRequestError(message, response.status);
|
|
1608
|
+
}
|
|
1609
|
+
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) {
|
|
1610
|
+
throw new QuotaRequestError(`request failed (HTTP ${response.status})`, response.status);
|
|
1611
|
+
}
|
|
1612
|
+
return body;
|
|
1613
|
+
}
|
|
1614
|
+
var QuotaRequestError;
|
|
1615
|
+
var init_quota = __esm({
|
|
1616
|
+
"src/services/quota.ts"() {
|
|
1617
|
+
"use strict";
|
|
1618
|
+
init_share_url();
|
|
1619
|
+
init_supabase();
|
|
1620
|
+
QuotaRequestError = class extends Error {
|
|
1621
|
+
constructor(message, status) {
|
|
1622
|
+
super(message);
|
|
1623
|
+
this.status = status;
|
|
1624
|
+
this.name = "QuotaRequestError";
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
// src/commands/quota.ts
|
|
1631
|
+
function formatBytes2(value) {
|
|
1632
|
+
if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
|
|
1633
|
+
if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
|
|
1634
|
+
return `${Number((value / 1e9).toFixed(2))} GB`;
|
|
1635
|
+
}
|
|
1636
|
+
function usedAndLimit(usage) {
|
|
1637
|
+
const used = usage.meter === "views" ? `${usage.used} views` : formatBytes2(usage.used);
|
|
1638
|
+
if (usage.limit === null) return `${used} / unlimited`;
|
|
1639
|
+
const limit = usage.meter === "views" ? `${usage.limit} views` : formatBytes2(usage.limit);
|
|
1640
|
+
return `${used} / ${limit} (${usage.percent}%)`;
|
|
1641
|
+
}
|
|
1642
|
+
function registerQuota(program2) {
|
|
1643
|
+
program2.command("quota").description("Show your plan, current usage, and publishing limits").option("--json", "print the account API response as JSON").action(async (options) => {
|
|
1644
|
+
try {
|
|
1645
|
+
await requireAuth();
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
if (err instanceof AuthRequiredError) return;
|
|
1648
|
+
throw err;
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
const result = await fetchQuota();
|
|
1652
|
+
if (options.json) {
|
|
1653
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const enabled = [
|
|
1657
|
+
result.entitlements.can_update && "update",
|
|
1658
|
+
result.entitlements.can_preview && "preview",
|
|
1659
|
+
result.entitlements.can_password && "password",
|
|
1660
|
+
result.entitlements.can_email_allowlist && "email"
|
|
1661
|
+
].filter((feature) => Boolean(feature));
|
|
1662
|
+
const pageLimit = result.usage.pages.limit === null ? "unlimited" : String(result.usage.pages.limit);
|
|
1663
|
+
process.stdout.write(renderTable(
|
|
1664
|
+
["ITEM", "VALUE"],
|
|
1665
|
+
[
|
|
1666
|
+
["Plan", result.entitlements.plan_name],
|
|
1667
|
+
["Pages", `${result.usage.pages.used} / ${pageLimit}`],
|
|
1668
|
+
...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)}`]] : [],
|
|
1669
|
+
["Monthly meter", usedAndLimit(result.usage)],
|
|
1670
|
+
["Period ends", result.usage.period_end],
|
|
1671
|
+
["Features", enabled.length ? enabled.join(", ") : "basic publishing"]
|
|
1672
|
+
]
|
|
1673
|
+
) + "\n");
|
|
1674
|
+
console.error(`droppage: quota level ${result.usage.level}.`);
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1677
|
+
console.error(`droppage: quota failed: ${message}`);
|
|
1678
|
+
process.exitCode = 1;
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
var init_quota2 = __esm({
|
|
1683
|
+
"src/commands/quota.ts"() {
|
|
1684
|
+
"use strict";
|
|
1685
|
+
init_table();
|
|
1686
|
+
init_auth();
|
|
1687
|
+
init_quota();
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// src/services/feedback.ts
|
|
1692
|
+
async function submitFeedback(input2) {
|
|
1693
|
+
const { data } = await createSupabaseClient().auth.getSession();
|
|
1694
|
+
const token = data.session?.access_token;
|
|
1695
|
+
const base = resolveShareBaseUrl();
|
|
1696
|
+
let response;
|
|
1697
|
+
try {
|
|
1698
|
+
response = await fetch(`${base}/api/feedback`, {
|
|
1699
|
+
method: "POST",
|
|
1700
|
+
headers: {
|
|
1701
|
+
"Content-Type": "application/json",
|
|
1702
|
+
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
1703
|
+
},
|
|
1704
|
+
body: JSON.stringify(input2)
|
|
1705
|
+
});
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
throw new FeedbackRequestError(
|
|
1708
|
+
`could not reach ${base}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1709
|
+
0
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
const text = await response.text();
|
|
1713
|
+
let body = null;
|
|
1714
|
+
try {
|
|
1715
|
+
body = text ? JSON.parse(text) : null;
|
|
1716
|
+
} catch {
|
|
1717
|
+
}
|
|
1718
|
+
if (!response.ok || !body?.ok || !body.id) {
|
|
1719
|
+
throw new FeedbackRequestError(
|
|
1720
|
+
body?.error ?? `request failed (HTTP ${response.status})`,
|
|
1721
|
+
response.status
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
return { ok: true, id: body.id };
|
|
1725
|
+
}
|
|
1726
|
+
var FeedbackRequestError;
|
|
1727
|
+
var init_feedback = __esm({
|
|
1728
|
+
"src/services/feedback.ts"() {
|
|
1729
|
+
"use strict";
|
|
1730
|
+
init_share_url();
|
|
1731
|
+
init_supabase();
|
|
1732
|
+
FeedbackRequestError = class extends Error {
|
|
1733
|
+
constructor(message, status) {
|
|
1734
|
+
super(message);
|
|
1735
|
+
this.status = status;
|
|
1736
|
+
this.name = "FeedbackRequestError";
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
// src/commands/feedback.ts
|
|
1743
|
+
function isKind(value) {
|
|
1744
|
+
return value === "idea" || value === "bug" || value === "review" || value === "other";
|
|
1745
|
+
}
|
|
1746
|
+
function registerFeedback(program2) {
|
|
1747
|
+
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) => {
|
|
1748
|
+
const cleanedMessage = message.trim();
|
|
1749
|
+
const model = typeof options.agent === "string" ? options.agent.trim() : void 0;
|
|
1750
|
+
const contact = options.contact?.trim().toLowerCase();
|
|
1751
|
+
if (cleanedMessage.length < 3 || cleanedMessage.length > 4e3) {
|
|
1752
|
+
console.error("droppage: feedback must be between 3 and 4000 characters");
|
|
1753
|
+
process.exitCode = 1;
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
if (!isKind(options.kind)) {
|
|
1757
|
+
console.error("droppage: --kind must be idea, bug, review, or other");
|
|
1758
|
+
process.exitCode = 1;
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
if (model && model.length > 120) {
|
|
1762
|
+
console.error("droppage: agent model must be 120 characters or fewer");
|
|
1763
|
+
process.exitCode = 1;
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (contact && !emailSchema.safeParse(contact).success) {
|
|
1767
|
+
console.error(`droppage: invalid contact email: ${contact}`);
|
|
1768
|
+
process.exitCode = 1;
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
const rating = options.rating === void 0 ? void 0 : Number(options.rating);
|
|
1772
|
+
if (rating !== void 0 && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
|
|
1773
|
+
console.error("droppage: --rating must be a whole number from 1 to 5");
|
|
1774
|
+
process.exitCode = 1;
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (options.kind !== "review" && (rating !== void 0 || options.allowQuote)) {
|
|
1778
|
+
console.error("droppage: --rating and --allow-quote require --kind review");
|
|
1779
|
+
process.exitCode = 1;
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
try {
|
|
1783
|
+
const result = await submitFeedback({
|
|
1784
|
+
message: cleanedMessage,
|
|
1785
|
+
kind: options.kind,
|
|
1786
|
+
actor: options.agent === void 0 ? "human" : "agent",
|
|
1787
|
+
...model ? { model } : {},
|
|
1788
|
+
...contact ? { contact } : {},
|
|
1789
|
+
...rating !== void 0 ? { rating } : {},
|
|
1790
|
+
...options.allowQuote ? { testimonial_consent: true } : {},
|
|
1791
|
+
source: "cli",
|
|
1792
|
+
cli_version: package_default.version
|
|
1793
|
+
});
|
|
1794
|
+
process.stdout.write(`${result.id}
|
|
1795
|
+
`);
|
|
1796
|
+
console.error("droppage: thanks \u2014 your feedback was sent.");
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
1799
|
+
console.error(`droppage: feedback failed: ${message2}`);
|
|
1800
|
+
process.exitCode = 1;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
var init_feedback2 = __esm({
|
|
1805
|
+
"src/commands/feedback.ts"() {
|
|
1806
|
+
"use strict";
|
|
1807
|
+
init_package();
|
|
1808
|
+
init_auth();
|
|
1809
|
+
init_feedback();
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
// src/commands/upgrade.ts
|
|
1814
|
+
import { spawn } from "child_process";
|
|
1815
|
+
function installLatest() {
|
|
1816
|
+
return new Promise((resolve2, reject) => {
|
|
1817
|
+
const child = spawn("npm", ["install", "-g", "droppage@latest"], {
|
|
1818
|
+
stdio: "inherit",
|
|
1819
|
+
shell: process.platform === "win32"
|
|
1820
|
+
});
|
|
1821
|
+
child.once("error", reject);
|
|
1822
|
+
child.once("close", (code) => code === 0 ? resolve2() : reject(new Error(`npm exited with status ${code ?? "unknown"}`)));
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
function registerUpgrade(program2) {
|
|
1826
|
+
program2.command("upgrade").description("Install the latest droppage CLI release").action(async () => {
|
|
1827
|
+
try {
|
|
1828
|
+
await installLatest();
|
|
1829
|
+
console.error("droppage: upgrade complete; run `droppage --version` to confirm.");
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
console.error(`droppage: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1832
|
+
process.exitCode = 1;
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
var init_upgrade = __esm({
|
|
1837
|
+
"src/commands/upgrade.ts"() {
|
|
1838
|
+
"use strict";
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
// src/commands/image.ts
|
|
1843
|
+
function formatBytes3(value) {
|
|
1844
|
+
if (value < 1e3) return `${value} B`;
|
|
1845
|
+
if (value < 1e6) return `${Math.round(value / 1e3)} KB`;
|
|
1846
|
+
if (value < 1e9) return `${Number((value / 1e6).toFixed(1))} MB`;
|
|
1847
|
+
return `${Number((value / 1e9).toFixed(2))} GB`;
|
|
1848
|
+
}
|
|
1849
|
+
async function signedIn() {
|
|
1850
|
+
try {
|
|
1851
|
+
await requireAuth();
|
|
1852
|
+
return true;
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
if (err instanceof AuthRequiredError) return false;
|
|
1855
|
+
throw err;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
function fail(err) {
|
|
1859
|
+
console.error(`droppage: ${err instanceof Error ? err.message : String(err)}`);
|
|
1860
|
+
process.exitCode = 1;
|
|
1861
|
+
}
|
|
1862
|
+
function registerImage(program2) {
|
|
1863
|
+
const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
|
|
1864
|
+
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) => {
|
|
1865
|
+
if (!await signedIn()) return;
|
|
1866
|
+
const results = [];
|
|
1867
|
+
for (const file of files) {
|
|
1868
|
+
try {
|
|
1869
|
+
const uploaded = await uploadImage(file, options.page);
|
|
1870
|
+
results.push({ file, ...uploaded });
|
|
1871
|
+
if (!options.json) {
|
|
1872
|
+
process.stdout.write(`${uploaded.url}
|
|
1873
|
+
`);
|
|
1874
|
+
if (uploaded.existing) console.error(`droppage: ${file} is already on this page; same URL.`);
|
|
1875
|
+
}
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
fail(new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
1881
|
+
});
|
|
1882
|
+
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) => {
|
|
1883
|
+
if (!await signedIn()) return;
|
|
1884
|
+
try {
|
|
1885
|
+
const list = await listImages(options.page);
|
|
1886
|
+
if (options.json) {
|
|
1887
|
+
process.stdout.write(JSON.stringify(list, null, 2) + "\n");
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (list.images.length === 0) {
|
|
1891
|
+
console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
|
|
1892
|
+
} else {
|
|
1893
|
+
process.stdout.write(renderTable(
|
|
1894
|
+
["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
|
|
1895
|
+
list.images.map((row) => [row.id, row.page ?? "", row.name ?? "", formatBytes3(row.bytes), formatDateCell(row.created_at), row.url]),
|
|
1896
|
+
["left", "left", "left", "right", "left", "left"]
|
|
1897
|
+
) + "\n");
|
|
1898
|
+
}
|
|
1899
|
+
if (list.page) {
|
|
1900
|
+
const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
|
|
1901
|
+
console.error(`droppage: images on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
|
|
1902
|
+
}
|
|
1903
|
+
} catch (err) {
|
|
1904
|
+
fail(err);
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
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) => {
|
|
1908
|
+
if (!await signedIn()) return;
|
|
1909
|
+
for (const value of ids) {
|
|
1910
|
+
try {
|
|
1911
|
+
const id = await deleteImage(value);
|
|
1912
|
+
console.error(`droppage: deleted image ${id}`);
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
fail(new Error(`${value}: ${err instanceof Error ? err.message : String(err)}`));
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
var init_image = __esm({
|
|
1920
|
+
"src/commands/image.ts"() {
|
|
1921
|
+
"use strict";
|
|
1922
|
+
init_table();
|
|
1923
|
+
init_auth();
|
|
1924
|
+
init_images();
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1246
1928
|
// src/index.ts
|
|
1247
1929
|
var index_exports = {};
|
|
1248
1930
|
__export(index_exports, {
|
|
@@ -1260,6 +1942,10 @@ function createProgram() {
|
|
|
1260
1942
|
registerList(program2);
|
|
1261
1943
|
registerDelete(program2);
|
|
1262
1944
|
registerRecipients(program2);
|
|
1945
|
+
registerQuota(program2);
|
|
1946
|
+
registerImage(program2);
|
|
1947
|
+
registerFeedback(program2);
|
|
1948
|
+
registerUpgrade(program2);
|
|
1263
1949
|
return program2;
|
|
1264
1950
|
}
|
|
1265
1951
|
var init_index = __esm({
|
|
@@ -1273,6 +1959,70 @@ var init_index = __esm({
|
|
|
1273
1959
|
init_list();
|
|
1274
1960
|
init_delete();
|
|
1275
1961
|
init_recipients2();
|
|
1962
|
+
init_quota2();
|
|
1963
|
+
init_feedback2();
|
|
1964
|
+
init_upgrade();
|
|
1965
|
+
init_image();
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
// src/lib/update-check.ts
|
|
1970
|
+
var update_check_exports = {};
|
|
1971
|
+
__export(update_check_exports, {
|
|
1972
|
+
checkForUpdate: () => checkForUpdate
|
|
1973
|
+
});
|
|
1974
|
+
function stableVersion(value) {
|
|
1975
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
1976
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
1977
|
+
}
|
|
1978
|
+
function isNewerVersion(latest, current) {
|
|
1979
|
+
const next = stableVersion(latest);
|
|
1980
|
+
const installed = stableVersion(current);
|
|
1981
|
+
if (!next || !installed) return false;
|
|
1982
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
1983
|
+
if (next[index] !== installed[index]) return next[index] > installed[index];
|
|
1984
|
+
}
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
function shouldCheck() {
|
|
1988
|
+
return Boolean(process.stderr.isTTY) && !process.env.DROPPAGE_NO_UPDATE_CHECK && !process.env.CI;
|
|
1989
|
+
}
|
|
1990
|
+
async function checkForUpdate() {
|
|
1991
|
+
if (!shouldCheck()) return;
|
|
1992
|
+
try {
|
|
1993
|
+
const cache = getUpdateCheckConf();
|
|
1994
|
+
const lastCheckedAt = Date.parse(cache.get("lastCheckedAt") ?? "");
|
|
1995
|
+
const stale = !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt >= CHECK_INTERVAL_MS;
|
|
1996
|
+
let latest = cache.get("latest");
|
|
1997
|
+
if (stale) {
|
|
1998
|
+
const response = await fetch(REGISTRY_URL, {
|
|
1999
|
+
headers: { Accept: "application/json" },
|
|
2000
|
+
signal: AbortSignal.timeout(1500)
|
|
2001
|
+
});
|
|
2002
|
+
if (!response.ok) return;
|
|
2003
|
+
const body = await response.json();
|
|
2004
|
+
if (typeof body.version !== "string" || !body.version.trim()) return;
|
|
2005
|
+
latest = body.version.trim();
|
|
2006
|
+
cache.set("latest", latest);
|
|
2007
|
+
cache.set("lastCheckedAt", (/* @__PURE__ */ new Date()).toISOString());
|
|
2008
|
+
}
|
|
2009
|
+
if (latest && isNewerVersion(latest, package_default.version)) {
|
|
2010
|
+
process.stderr.write(
|
|
2011
|
+
`droppage ${package_default.version} \u2192 ${latest} available: npm install -g droppage@latest (or droppage upgrade)
|
|
2012
|
+
`
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
} catch {
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
var CHECK_INTERVAL_MS, REGISTRY_URL;
|
|
2019
|
+
var init_update_check = __esm({
|
|
2020
|
+
"src/lib/update-check.ts"() {
|
|
2021
|
+
"use strict";
|
|
2022
|
+
init_package();
|
|
2023
|
+
init_config();
|
|
2024
|
+
CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
2025
|
+
REGISTRY_URL = "https://registry.npmjs.org/droppage/latest";
|
|
1276
2026
|
}
|
|
1277
2027
|
});
|
|
1278
2028
|
|
|
@@ -1290,3 +2040,5 @@ Upgrade Node.js: https://nodejs.org/
|
|
|
1290
2040
|
var { createProgram: createProgram2 } = await Promise.resolve().then(() => (init_index(), index_exports));
|
|
1291
2041
|
var program = createProgram2();
|
|
1292
2042
|
await program.parseAsync(process.argv);
|
|
2043
|
+
var { checkForUpdate: checkForUpdate2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
|
|
2044
|
+
await checkForUpdate2();
|