droppage 0.2.1 → 0.3.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 +43 -8
- package/dist/cli.js +175 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,18 +38,18 @@ 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 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. |
|
|
41
|
+
| `droppage upload <file>` | Publish an `.html`/`.htm` file (and the local images and videos it references) and print its URL. Options: `--password`, `--email <addr>`, `--private`, `--expires <when>`. |
|
|
42
|
+
| `droppage update <slug> <file>` | Replace the HTML (and images and videos) behind an existing URL. Keeps the URL, access mode, and stats. `--expires <when\|never>` changes the expiry. |
|
|
43
43
|
| `droppage expire <slug> <when\|never>` | Set or clear when a page expires without re-uploading. |
|
|
44
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
|
+
| `droppage delete <slug>` | Delete a page and its images and videos. `--yes` skips the confirmation prompt. |
|
|
46
46
|
| `droppage recipients list <slug>` | List recipients of an email-gated page (`--json`, `--page <n>`). |
|
|
47
47
|
| `droppage recipients add <slug> <emails...>` | Add recipients and queue their invitation emails. |
|
|
48
48
|
| `droppage recipients remove <slug> <email>` | Revoke a recipient; their invitation link stops working (`--yes`). |
|
|
49
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.
|
|
50
|
+
| `droppage image upload <files...> --page <slug>` | Advanced: add images (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each) or videos (MP4, WebM; up to 20 MB each) to an existing page and print their URLs. Alias: `droppage media upload`. `--json` prints `[{file,id,url,bytes,content_type,existing}]`. |
|
|
51
|
+
| `droppage image list [--page <slug>]` | List your pages' images and videos; with `--page`, one page and its image budget. `--json` for machines. |
|
|
52
|
+
| `droppage image delete <id-or-url...>` | Delete images or videos. They stop being served within 5 minutes; a browser that already loaded one may keep it up to a day. |
|
|
53
53
|
| `droppage quota` | Show the current plan, page usage, monthly meter, reset date, and enabled features. `--json` returns the account API body. |
|
|
54
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
55
|
| `droppage upgrade` | Install the latest CLI globally with npm and report the installed version. |
|
|
@@ -104,6 +104,9 @@ only the page URL.
|
|
|
104
104
|
|
|
105
105
|
- `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
|
|
106
106
|
the page's images the new HTML no longer uses.
|
|
107
|
+
- Besides `<img src>`, the CLI picks up local images in `srcset` on `<img>`/`<source>` (every
|
|
108
|
+
candidate, descriptors kept), `<link rel="icon"|"apple-touch-icon" href>`, and `url(...)` inside
|
|
109
|
+
`<style>` blocks (0.2.2+). Not picked up: `style="..."` attributes and `.ico` files.
|
|
107
110
|
- Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
|
|
108
111
|
- Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
|
|
109
112
|
PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
|
|
@@ -117,6 +120,38 @@ only the page URL.
|
|
|
117
120
|
`{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
|
|
118
121
|
`droppage image delete <id-or-url...>` removes them.
|
|
119
122
|
|
|
123
|
+
## Videos
|
|
124
|
+
|
|
125
|
+
Short product films (CLI 0.3.0+) are hosted exactly like images. Reference local MP4 or WebM files,
|
|
126
|
+
and a poster image, in a `<video>`:
|
|
127
|
+
|
|
128
|
+
```html
|
|
129
|
+
<video autoplay muted loop playsinline poster="still.webp">
|
|
130
|
+
<source src="film.webm" type="video/webm">
|
|
131
|
+
<source src="film.mp4" type="video/mp4">
|
|
132
|
+
</video>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
droppage upload page.html
|
|
137
|
+
# stderr: droppage: image still.webp → https://droppage.dev/i/AbCdEfGhIjKl.webp
|
|
138
|
+
# stderr: droppage: video film.webm → https://droppage.dev/i/MnOpQrStUvWx.webm
|
|
139
|
+
# stderr: droppage: video film.mp4 → https://droppage.dev/i/YzAbCdEfGhIj.mp4
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- Picked up: `<video src>`, `<source src>` (inside `<video>`, and `<picture>` as before), and
|
|
143
|
+
`<video poster>`. Same folder rule as images.
|
|
144
|
+
- Each video up to 20 MB (20,000,000 bytes), checked by its file signature, not its name: MP4
|
|
145
|
+
(H.264 plays everywhere) and WebM (VP9). `.mov` and `.mkv` are refused; convert them first, e.g.
|
|
146
|
+
`ffmpeg -i in.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart -an film.mp4`.
|
|
147
|
+
- Videos share the page's image budget (Free 5 MB, Starter 20 MB, Builder 50 MB per page), are
|
|
148
|
+
pruned by `update` and deleted with the page, like images. Video traffic counts toward your
|
|
149
|
+
monthly bandwidth.
|
|
150
|
+
- Served from `https://droppage.dev/i/<id>.mp4|webm` with HTTP Range support (`206 Partial Content`),
|
|
151
|
+
so Safari, iOS and seeking work. Public to anyone with the URL, like images.
|
|
152
|
+
- No need to inline films as base64 any more. `droppage image upload film.mp4 --page <slug>` (or
|
|
153
|
+
`droppage media upload`) adds a video to an existing page.
|
|
154
|
+
|
|
120
155
|
## Expiry
|
|
121
156
|
|
|
122
157
|
Pages can expire. Nothing expires unless you set it, on any plan.
|
|
@@ -156,9 +191,9 @@ team, a weekly KPI or stakeholder report, a client deliverable, a notebook expor
|
|
|
156
191
|
droppage keeps a strict stream contract so it is safe to call from automation:
|
|
157
192
|
|
|
158
193
|
- `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.
|
|
194
|
+
`image upload` prints **only the image or video URL(s)**; `expire` prints **nothing** on stdout.
|
|
160
195
|
- `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
|
|
161
|
-
- Progress, warnings, prompts, errors, and the per-
|
|
196
|
+
- Progress, warnings, prompts, errors, and the per-file `droppage: image … → …` / `droppage: video … → …` lines go to **stderr**.
|
|
162
197
|
- Non-zero exit code on any failure.
|
|
163
198
|
- Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
|
|
164
199
|
|
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.3.0",
|
|
19
19
|
description: "Publish HTML files as shareable, access-controlled live pages",
|
|
20
20
|
type: "module",
|
|
21
21
|
license: "Apache-2.0",
|
|
@@ -521,6 +521,78 @@ var init_duration = __esm({
|
|
|
521
521
|
// src/lib/html-images.ts
|
|
522
522
|
import { existsSync, realpathSync, statSync } from "fs";
|
|
523
523
|
import { dirname, extname, relative, resolve, sep } from "path";
|
|
524
|
+
function imageSlots(html) {
|
|
525
|
+
const slots = [];
|
|
526
|
+
const attrValue = (match) => ({ start: match.index + match[1].length + 1, value: match[3] });
|
|
527
|
+
for (const re of [SRC_RE, POSTER_RE]) {
|
|
528
|
+
for (const match of html.matchAll(re)) {
|
|
529
|
+
const { start, value } = attrValue(match);
|
|
530
|
+
slots.push({ start, end: start + value.length, ref: value.trim() });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
for (const match of html.matchAll(SRCSET_RE)) {
|
|
534
|
+
const { start, value } = attrValue(match);
|
|
535
|
+
for (const url of srcsetUrls(value)) slots.push({ start: start + url.offset, end: start + url.offset + url.ref.length, ref: url.ref });
|
|
536
|
+
}
|
|
537
|
+
for (const match of html.matchAll(LINK_RE)) {
|
|
538
|
+
const tag = match[0];
|
|
539
|
+
let rel = null;
|
|
540
|
+
let href = null;
|
|
541
|
+
for (const attr of tag.matchAll(ATTR_RE)) {
|
|
542
|
+
const name = attr[1].toLowerCase();
|
|
543
|
+
const value = attr[3] ?? attr[4];
|
|
544
|
+
const end = attr.index + attr[0].length - (attr[3] === void 0 ? 0 : 1);
|
|
545
|
+
if (name === "rel") rel = value;
|
|
546
|
+
if (name === "href") href = { offset: end - value.length, value };
|
|
547
|
+
}
|
|
548
|
+
if (!rel || !href || !rel.toLowerCase().split(/\s+/).some((token) => ICON_RELS.has(token))) continue;
|
|
549
|
+
const start = match.index + href.offset;
|
|
550
|
+
slots.push({ start, end: start + href.value.length, ref: href.value.trim() });
|
|
551
|
+
}
|
|
552
|
+
for (const match of html.matchAll(STYLE_RE)) {
|
|
553
|
+
const cssStart = match.index + match[1].length;
|
|
554
|
+
const css = match[2].replace(/\/\*[\s\S]*?\*\//g, (comment) => " ".repeat(comment.length));
|
|
555
|
+
for (const url of css.matchAll(CSS_URL_RE)) {
|
|
556
|
+
const value = url[1] ?? url[2] ?? url[3];
|
|
557
|
+
const quoted = url[1] !== void 0 || url[2] !== void 0;
|
|
558
|
+
const paren = url[0].indexOf("(") + 1;
|
|
559
|
+
const open2 = paren + url[0].slice(paren).search(/\S/);
|
|
560
|
+
const start = cssStart + url.index + open2 + (quoted ? 1 : 0);
|
|
561
|
+
slots.push({ start, end: start + value.length, ref: value.trim() });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
slots.sort((a, b) => a.start - b.start);
|
|
565
|
+
const ordered = [];
|
|
566
|
+
for (const slot of slots) {
|
|
567
|
+
if (slot.ref && (ordered.length === 0 || slot.start >= ordered[ordered.length - 1].end)) ordered.push(slot);
|
|
568
|
+
}
|
|
569
|
+
return ordered;
|
|
570
|
+
}
|
|
571
|
+
function srcsetUrls(value) {
|
|
572
|
+
const urls = [];
|
|
573
|
+
let i = 0;
|
|
574
|
+
while (i < value.length) {
|
|
575
|
+
while (i < value.length && (/\s/.test(value[i]) || value[i] === ",")) i++;
|
|
576
|
+
if (i >= value.length) break;
|
|
577
|
+
const start = i;
|
|
578
|
+
while (i < value.length && !/\s/.test(value[i])) i++;
|
|
579
|
+
const raw = value.slice(start, i);
|
|
580
|
+
const ref = raw.replace(/,+$/, "");
|
|
581
|
+
if (ref) urls.push({ offset: start, ref });
|
|
582
|
+
if (ref.length !== raw.length) continue;
|
|
583
|
+
let depth = 0;
|
|
584
|
+
for (; i < value.length; i++) {
|
|
585
|
+
const c = value[i];
|
|
586
|
+
if (c === "(") depth++;
|
|
587
|
+
else if (c === ")") depth = Math.max(0, depth - 1);
|
|
588
|
+
else if (c === "," && depth === 0) {
|
|
589
|
+
i++;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return urls;
|
|
595
|
+
}
|
|
524
596
|
function inside(base, path) {
|
|
525
597
|
const rel = relative(base, path);
|
|
526
598
|
return !!rel && !rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-z]:/i.test(rel);
|
|
@@ -534,8 +606,7 @@ function findLocalImages(html, htmlFile) {
|
|
|
534
606
|
return [];
|
|
535
607
|
}
|
|
536
608
|
const seen = /* @__PURE__ */ new Map();
|
|
537
|
-
for (const
|
|
538
|
-
const ref = match[3].trim();
|
|
609
|
+
for (const { ref } of imageSlots(html)) {
|
|
539
610
|
if (seen.has(ref) || /^(?:[a-z][a-z0-9+.-]*:|\/|\\|#)/i.test(ref)) continue;
|
|
540
611
|
let decoded;
|
|
541
612
|
try {
|
|
@@ -543,7 +614,7 @@ function findLocalImages(html, htmlFile) {
|
|
|
543
614
|
} catch {
|
|
544
615
|
continue;
|
|
545
616
|
}
|
|
546
|
-
if (!
|
|
617
|
+
if (!MEDIA_EXTS.has(extname(decoded).toLowerCase())) continue;
|
|
547
618
|
const path = resolve(base, decoded);
|
|
548
619
|
if (!inside(base, path) || !existsSync(path)) continue;
|
|
549
620
|
let real;
|
|
@@ -558,17 +629,33 @@ function findLocalImages(html, htmlFile) {
|
|
|
558
629
|
return [...seen.values()];
|
|
559
630
|
}
|
|
560
631
|
function rewriteImageRefs(html, urls) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
632
|
+
let out = "";
|
|
633
|
+
let last = 0;
|
|
634
|
+
for (const slot of imageSlots(html)) {
|
|
635
|
+
const url = urls.get(slot.ref);
|
|
636
|
+
if (!url) continue;
|
|
637
|
+
out += html.slice(last, slot.start) + url;
|
|
638
|
+
last = slot.end;
|
|
639
|
+
}
|
|
640
|
+
return out + html.slice(last);
|
|
565
641
|
}
|
|
566
|
-
var IMAGE_EXTS, SRC_RE;
|
|
642
|
+
var IMAGE_EXTS, VIDEO_EXTS, MEDIA_EXTS, attrRe, SRC_RE, SRCSET_RE, POSTER_RE, LINK_RE, ATTR_RE, ICON_RELS, STYLE_RE, CSS_URL_RE, isVideoFile;
|
|
567
643
|
var init_html_images = __esm({
|
|
568
644
|
"src/lib/html-images.ts"() {
|
|
569
645
|
"use strict";
|
|
570
646
|
IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
|
|
571
|
-
|
|
647
|
+
VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".webm"]);
|
|
648
|
+
MEDIA_EXTS = /* @__PURE__ */ new Set([...IMAGE_EXTS, ...VIDEO_EXTS]);
|
|
649
|
+
attrRe = (tags, name) => new RegExp(`(<(?:${tags})\\b[^>]*?\\s${name}\\s*=\\s*)(["'])([^"']+)\\2`, "gi");
|
|
650
|
+
SRC_RE = attrRe("img|source|video", "src");
|
|
651
|
+
SRCSET_RE = attrRe("img|source", "srcset");
|
|
652
|
+
POSTER_RE = attrRe("video", "poster");
|
|
653
|
+
LINK_RE = /<link\b[^>]*>/gi;
|
|
654
|
+
ATTR_RE = /\s([a-z][a-z0-9-]*)\s*=\s*(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
|
|
655
|
+
ICON_RELS = /* @__PURE__ */ new Set(["icon", "apple-touch-icon", "apple-touch-icon-precomposed"]);
|
|
656
|
+
STYLE_RE = /(<style\b[^>]*>)([\s\S]*?)<\/style\s*>/gi;
|
|
657
|
+
CSS_URL_RE = /\burl\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'()]+))\s*\)/gi;
|
|
658
|
+
isVideoFile = (path) => VIDEO_EXTS.has(extname(path).toLowerCase());
|
|
572
659
|
}
|
|
573
660
|
});
|
|
574
661
|
|
|
@@ -589,6 +676,7 @@ var init_share_url = __esm({
|
|
|
589
676
|
});
|
|
590
677
|
|
|
591
678
|
// src/services/images.ts
|
|
679
|
+
import { createHash } from "crypto";
|
|
592
680
|
import { readFile, stat } from "fs/promises";
|
|
593
681
|
import { basename } from "path";
|
|
594
682
|
async function accessToken() {
|
|
@@ -617,10 +705,18 @@ async function call(path, init) {
|
|
|
617
705
|
}
|
|
618
706
|
return body;
|
|
619
707
|
}
|
|
708
|
+
function asUploaded(body) {
|
|
709
|
+
const image = body;
|
|
710
|
+
if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
|
|
711
|
+
throw new ImageRequestError("unexpected response from the image API", 0);
|
|
712
|
+
}
|
|
713
|
+
return image;
|
|
714
|
+
}
|
|
620
715
|
async function uploadImage(file, slug, { update = false } = {}) {
|
|
621
716
|
const info = await stat(file).catch(() => null);
|
|
622
717
|
if (!info?.isFile()) throw new ImageRequestError(`file not found: ${file}`, 0);
|
|
623
718
|
if (info.size === 0) throw new ImageRequestError(`file is empty: ${file}`, 0);
|
|
719
|
+
if (isVideoFile(file)) return uploadVideo(file, info.size, slug, update);
|
|
624
720
|
if (info.size > MAX_IMAGE_BYTES) {
|
|
625
721
|
throw new ImageRequestError(`image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB (${info.size} bytes): ${file}`, 413);
|
|
626
722
|
}
|
|
@@ -639,11 +735,48 @@ async function uploadImage(file, slug, { update = false } = {}) {
|
|
|
639
735
|
if (info.size > HEAVY_IMAGE_BYTES && /\.(png|jpe?g|gif)$/i.test(file)) {
|
|
640
736
|
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
737
|
}
|
|
642
|
-
|
|
643
|
-
|
|
738
|
+
return asUploaded(body);
|
|
739
|
+
}
|
|
740
|
+
async function uploadVideo(file, size, slug, update) {
|
|
741
|
+
if (size > MAX_VIDEO_BYTES) {
|
|
742
|
+
throw new ImageRequestError(`video is larger than ${MAX_VIDEO_BYTES / 1e6} MB (${size} bytes): ${file}`, 413);
|
|
743
|
+
}
|
|
744
|
+
const bytes = await readFile(file);
|
|
745
|
+
const token = await accessToken();
|
|
746
|
+
const started = await call("/api/images/uploads", {
|
|
747
|
+
method: "POST",
|
|
748
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
749
|
+
body: JSON.stringify({
|
|
750
|
+
page: slug,
|
|
751
|
+
bytes: bytes.length,
|
|
752
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
753
|
+
filename: basename(file),
|
|
754
|
+
head: bytes.subarray(0, 64).toString("base64"),
|
|
755
|
+
update
|
|
756
|
+
})
|
|
757
|
+
});
|
|
758
|
+
if (typeof started?.url === "string") return asUploaded(started);
|
|
759
|
+
if (!started?.upload_url || !started.complete_url || !started.content_type) {
|
|
644
760
|
throw new ImageRequestError("unexpected response from the image API", 0);
|
|
645
761
|
}
|
|
646
|
-
|
|
762
|
+
let stored;
|
|
763
|
+
try {
|
|
764
|
+
stored = await fetch(started.upload_url, {
|
|
765
|
+
method: "PUT",
|
|
766
|
+
headers: { "Content-Type": started.content_type, "x-upsert": "false" },
|
|
767
|
+
body: bytes
|
|
768
|
+
});
|
|
769
|
+
} catch (err) {
|
|
770
|
+
throw new ImageRequestError(`could not upload the video: ${err instanceof Error ? err.message : String(err)}`, 0);
|
|
771
|
+
}
|
|
772
|
+
if (!stored.ok) {
|
|
773
|
+
const detail = await stored.text().catch(() => "");
|
|
774
|
+
throw new ImageRequestError(`could not upload the video (storage answered HTTP ${stored.status}${detail ? `: ${detail.slice(0, 200)}` : ""})`, stored.status);
|
|
775
|
+
}
|
|
776
|
+
return asUploaded(await call(started.complete_url, {
|
|
777
|
+
method: "POST",
|
|
778
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
779
|
+
}));
|
|
647
780
|
}
|
|
648
781
|
async function listImages(slug) {
|
|
649
782
|
const query = slug ? `?page=${encodeURIComponent(slug)}` : "";
|
|
@@ -668,20 +801,23 @@ async function deleteImage(idOrUrl) {
|
|
|
668
801
|
});
|
|
669
802
|
return id;
|
|
670
803
|
}
|
|
671
|
-
async function pruneImages(slug) {
|
|
804
|
+
async function pruneImages(slug, html) {
|
|
672
805
|
const body = await call(`/api/pages/${encodeURIComponent(slug)}/images/prune`, {
|
|
673
806
|
method: "POST",
|
|
674
|
-
headers: { Authorization: `Bearer ${await accessToken()}
|
|
807
|
+
headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json" },
|
|
808
|
+
body: JSON.stringify(html ? { html_sha256: createHash("sha256").update(html).digest("hex") } : {})
|
|
675
809
|
});
|
|
676
810
|
return Number(body?.removed ?? 0);
|
|
677
811
|
}
|
|
678
|
-
var MAX_IMAGE_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
|
|
812
|
+
var MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
|
|
679
813
|
var init_images = __esm({
|
|
680
814
|
"src/services/images.ts"() {
|
|
681
815
|
"use strict";
|
|
816
|
+
init_html_images();
|
|
682
817
|
init_share_url();
|
|
683
818
|
init_supabase();
|
|
684
819
|
MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
820
|
+
MAX_VIDEO_BYTES = 2e7;
|
|
685
821
|
HEAVY_IMAGE_BYTES = 300 * 1024;
|
|
686
822
|
ImageRequestError = class extends Error {
|
|
687
823
|
constructor(message, status) {
|
|
@@ -707,15 +843,24 @@ async function attachLocalImages(file, slug, { html, update = false } = {}) {
|
|
|
707
843
|
const result = await uploadImage(image.path, slug, { update });
|
|
708
844
|
urls.set(image.ref, result.url);
|
|
709
845
|
uploaded.push(result);
|
|
710
|
-
|
|
846
|
+
const kind = isVideoFile(image.path) ? "video" : "image";
|
|
847
|
+
console.error(`droppage: ${kind} ${basename2(image.path)} \u2192 ${result.url}`);
|
|
711
848
|
}
|
|
712
849
|
return { html: Buffer.from(rewriteImageRefs(text, urls), "utf8"), uploaded };
|
|
713
850
|
}
|
|
714
851
|
async function hasLocalImages(file) {
|
|
715
852
|
return findLocalImages((await readFile2(file)).toString("utf8"), file).length > 0;
|
|
716
853
|
}
|
|
717
|
-
async function pruneUnusedImages(slug) {
|
|
718
|
-
|
|
854
|
+
async function pruneUnusedImages(slug, html) {
|
|
855
|
+
try {
|
|
856
|
+
return await pruneImages(slug, html);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
if (err instanceof ImageRequestError && err.status === 409) {
|
|
859
|
+
console.error(`droppage: warning: unused images were kept (${err.message}); the next update removes them`);
|
|
860
|
+
return 0;
|
|
861
|
+
}
|
|
862
|
+
throw err;
|
|
863
|
+
}
|
|
719
864
|
}
|
|
720
865
|
var init_page_images = __esm({
|
|
721
866
|
"src/services/page-images.ts"() {
|
|
@@ -930,7 +1075,7 @@ function registerUpload(program2) {
|
|
|
930
1075
|
"--email <address>",
|
|
931
1076
|
"restrict access to an email allowlist (repeatable; comma-separated)",
|
|
932
1077
|
(val, prev = []) => prev.concat(val.split(","))
|
|
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(
|
|
1078
|
+
).option("--private", "restrict access to only you (owner-only draft)").option("--expires <when>", "delete the page (and its images and videos) after 30m, 12h, 7d, 2w or on a date").action(
|
|
934
1079
|
async (file, options) => {
|
|
935
1080
|
let user;
|
|
936
1081
|
try {
|
|
@@ -1141,7 +1286,7 @@ var init_pages = __esm({
|
|
|
1141
1286
|
// src/commands/update.ts
|
|
1142
1287
|
import { readFile as readFile5 } from "fs/promises";
|
|
1143
1288
|
function registerUpdate(program2) {
|
|
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) => {
|
|
1289
|
+
program2.command("update").description("Replace the HTML behind an existing page URL (its local images and videos 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) => {
|
|
1145
1290
|
let user;
|
|
1146
1291
|
try {
|
|
1147
1292
|
user = await requireAuth();
|
|
@@ -1156,8 +1301,8 @@ function registerUpdate(program2) {
|
|
|
1156
1301
|
validateHtmlUpload(file, await readFile5(file));
|
|
1157
1302
|
const { html } = await attachLocalImages(file, slug, { update: true });
|
|
1158
1303
|
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`);
|
|
1304
|
+
const removed = await pruneUnusedImages(slug, html);
|
|
1305
|
+
if (removed) console.error(`droppage: removed ${removed} image or video file(s) the page no longer uses`);
|
|
1161
1306
|
if (expiresAt !== void 0) {
|
|
1162
1307
|
await setPageExpiry(slug, expiresAt);
|
|
1163
1308
|
console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
|
|
@@ -1173,7 +1318,7 @@ function registerUpdate(program2) {
|
|
|
1173
1318
|
}
|
|
1174
1319
|
}
|
|
1175
1320
|
});
|
|
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) => {
|
|
1321
|
+
program2.command("expire").description("Set when a page (and its images and videos) 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
1322
|
try {
|
|
1178
1323
|
await requireAuth();
|
|
1179
1324
|
} catch (err) {
|
|
@@ -1871,8 +2016,8 @@ function fail(err) {
|
|
|
1871
2016
|
process.exitCode = 1;
|
|
1872
2017
|
}
|
|
1873
2018
|
function registerImage(program2) {
|
|
1874
|
-
const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local
|
|
1875
|
-
image.command("upload").description("Add images to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG
|
|
2019
|
+
const image = program2.command("image").alias("media").description("Images and videos that belong to a page (uploaded automatically by upload/update from local files the HTML references)");
|
|
2020
|
+
image.command("upload").description("Add images or videos to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG up to 4 MB each; MP4, WebM up to 20 MB each)").argument("<files...>", "image or video files").requiredOption("--page <slug>", "the page the files belong to (they go when it is deleted or expires)").option("--json", "print the results as JSON").action(async (files, options) => {
|
|
1876
2021
|
if (!await signedIn()) return;
|
|
1877
2022
|
const results = [];
|
|
1878
2023
|
for (const file of files) {
|
|
@@ -1890,7 +2035,7 @@ function registerImage(program2) {
|
|
|
1890
2035
|
}
|
|
1891
2036
|
if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
1892
2037
|
});
|
|
1893
|
-
image.command("list").description("List your pages' images (one page with --page, including its
|
|
2038
|
+
image.command("list").description("List your pages' images and videos (one page with --page, including its media budget)").option("--page <slug>", "only this page").option("--json", "print the list as JSON").action(async (options) => {
|
|
1894
2039
|
if (!await signedIn()) return;
|
|
1895
2040
|
try {
|
|
1896
2041
|
const list = await listImages(options.page);
|
|
@@ -1899,7 +2044,7 @@ function registerImage(program2) {
|
|
|
1899
2044
|
return;
|
|
1900
2045
|
}
|
|
1901
2046
|
if (list.images.length === 0) {
|
|
1902
|
-
console.error("droppage: no images. `droppage upload page.html` uploads the
|
|
2047
|
+
console.error("droppage: no images or videos. `droppage upload page.html` uploads the ones the page uses.");
|
|
1903
2048
|
} else {
|
|
1904
2049
|
process.stdout.write(renderTable(
|
|
1905
2050
|
["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
|
|
@@ -1909,13 +2054,13 @@ function registerImage(program2) {
|
|
|
1909
2054
|
}
|
|
1910
2055
|
if (list.page) {
|
|
1911
2056
|
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.`);
|
|
2057
|
+
console.error(`droppage: images and videos on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
|
|
1913
2058
|
}
|
|
1914
2059
|
} catch (err) {
|
|
1915
2060
|
fail(err);
|
|
1916
2061
|
}
|
|
1917
2062
|
});
|
|
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) => {
|
|
2063
|
+
image.command("delete").description("Delete images or videos (by id or URL); they stop being served within 5 minutes; a browser that already loaded one may keep it up to a day").argument("<images...>", "image or video ids or URLs").action(async (ids) => {
|
|
1919
2064
|
if (!await signedIn()) return;
|
|
1920
2065
|
for (const value of ids) {
|
|
1921
2066
|
try {
|