droppage 0.2.2 → 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.
Files changed (3) hide show
  1. package/README.md +40 -8
  2. package/dist/cli.js +78 -24
  3. 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. They stop being served within 5 minutes; a browser that already loaded one may keep it up to a day. |
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. |
@@ -120,6 +120,38 @@ only the page URL.
120
120
  `{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
121
121
  `droppage image delete <id-or-url...>` removes them.
122
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
+
123
155
  ## Expiry
124
156
 
125
157
  Pages can expire. Nothing expires unless you set it, on any plan.
@@ -159,9 +191,9 @@ team, a weekly KPI or stakeholder report, a client deliverable, a notebook expor
159
191
  droppage keeps a strict stream contract so it is safe to call from automation:
160
192
 
161
193
  - `upload` and `update` print **only the URL** on stdout, even when they upload images;
162
- `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.
163
195
  - `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
164
- - Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
196
+ - Progress, warnings, prompts, errors, and the per-file `droppage: image … → …` / `droppage: video … → …` lines go to **stderr**.
165
197
  - Non-zero exit code on any failure.
166
198
  - Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
167
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.2.2",
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",
@@ -524,9 +524,11 @@ import { dirname, extname, relative, resolve, sep } from "path";
524
524
  function imageSlots(html) {
525
525
  const slots = [];
526
526
  const attrValue = (match) => ({ start: match.index + match[1].length + 1, value: match[3] });
527
- for (const match of html.matchAll(SRC_RE)) {
528
- const { start, value } = attrValue(match);
529
- slots.push({ start, end: start + value.length, ref: value.trim() });
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
+ }
530
532
  }
531
533
  for (const match of html.matchAll(SRCSET_RE)) {
532
534
  const { start, value } = attrValue(match);
@@ -612,7 +614,7 @@ function findLocalImages(html, htmlFile) {
612
614
  } catch {
613
615
  continue;
614
616
  }
615
- if (!IMAGE_EXTS.has(extname(decoded).toLowerCase())) continue;
617
+ if (!MEDIA_EXTS.has(extname(decoded).toLowerCase())) continue;
616
618
  const path = resolve(base, decoded);
617
619
  if (!inside(base, path) || !existsSync(path)) continue;
618
620
  let real;
@@ -637,19 +639,23 @@ function rewriteImageRefs(html, urls) {
637
639
  }
638
640
  return out + html.slice(last);
639
641
  }
640
- var IMAGE_EXTS, imgAttrRe, SRC_RE, SRCSET_RE, LINK_RE, ATTR_RE, ICON_RELS, STYLE_RE, CSS_URL_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;
641
643
  var init_html_images = __esm({
642
644
  "src/lib/html-images.ts"() {
643
645
  "use strict";
644
646
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
645
- imgAttrRe = (name) => new RegExp(`(<(?:img|source)\\b[^>]*?\\s${name}\\s*=\\s*)(["'])([^"']+)\\2`, "gi");
646
- SRC_RE = imgAttrRe("src");
647
- SRCSET_RE = imgAttrRe("srcset");
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");
648
653
  LINK_RE = /<link\b[^>]*>/gi;
649
654
  ATTR_RE = /\s([a-z][a-z0-9-]*)\s*=\s*(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
650
655
  ICON_RELS = /* @__PURE__ */ new Set(["icon", "apple-touch-icon", "apple-touch-icon-precomposed"]);
651
656
  STYLE_RE = /(<style\b[^>]*>)([\s\S]*?)<\/style\s*>/gi;
652
657
  CSS_URL_RE = /\burl\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'()]+))\s*\)/gi;
658
+ isVideoFile = (path) => VIDEO_EXTS.has(extname(path).toLowerCase());
653
659
  }
654
660
  });
655
661
 
@@ -699,10 +705,18 @@ async function call(path, init) {
699
705
  }
700
706
  return body;
701
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
+ }
702
715
  async function uploadImage(file, slug, { update = false } = {}) {
703
716
  const info = await stat(file).catch(() => null);
704
717
  if (!info?.isFile()) throw new ImageRequestError(`file not found: ${file}`, 0);
705
718
  if (info.size === 0) throw new ImageRequestError(`file is empty: ${file}`, 0);
719
+ if (isVideoFile(file)) return uploadVideo(file, info.size, slug, update);
706
720
  if (info.size > MAX_IMAGE_BYTES) {
707
721
  throw new ImageRequestError(`image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB (${info.size} bytes): ${file}`, 413);
708
722
  }
@@ -721,11 +735,48 @@ async function uploadImage(file, slug, { update = false } = {}) {
721
735
  if (info.size > HEAVY_IMAGE_BYTES && /\.(png|jpe?g|gif)$/i.test(file)) {
722
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).`);
723
737
  }
724
- const image = body;
725
- if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
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) {
726
760
  throw new ImageRequestError("unexpected response from the image API", 0);
727
761
  }
728
- return image;
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
+ }));
729
780
  }
730
781
  async function listImages(slug) {
731
782
  const query = slug ? `?page=${encodeURIComponent(slug)}` : "";
@@ -758,13 +809,15 @@ async function pruneImages(slug, html) {
758
809
  });
759
810
  return Number(body?.removed ?? 0);
760
811
  }
761
- var MAX_IMAGE_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
812
+ var MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
762
813
  var init_images = __esm({
763
814
  "src/services/images.ts"() {
764
815
  "use strict";
816
+ init_html_images();
765
817
  init_share_url();
766
818
  init_supabase();
767
819
  MAX_IMAGE_BYTES = 4 * 1024 * 1024;
820
+ MAX_VIDEO_BYTES = 2e7;
768
821
  HEAVY_IMAGE_BYTES = 300 * 1024;
769
822
  ImageRequestError = class extends Error {
770
823
  constructor(message, status) {
@@ -790,7 +843,8 @@ async function attachLocalImages(file, slug, { html, update = false } = {}) {
790
843
  const result = await uploadImage(image.path, slug, { update });
791
844
  urls.set(image.ref, result.url);
792
845
  uploaded.push(result);
793
- console.error(`droppage: image ${basename2(image.path)} \u2192 ${result.url}`);
846
+ const kind = isVideoFile(image.path) ? "video" : "image";
847
+ console.error(`droppage: ${kind} ${basename2(image.path)} \u2192 ${result.url}`);
794
848
  }
795
849
  return { html: Buffer.from(rewriteImageRefs(text, urls), "utf8"), uploaded };
796
850
  }
@@ -1021,7 +1075,7 @@ function registerUpload(program2) {
1021
1075
  "--email <address>",
1022
1076
  "restrict access to an email allowlist (repeatable; comma-separated)",
1023
1077
  (val, prev = []) => prev.concat(val.split(","))
1024
- ).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(
1025
1079
  async (file, options) => {
1026
1080
  let user;
1027
1081
  try {
@@ -1232,7 +1286,7 @@ var init_pages = __esm({
1232
1286
  // src/commands/update.ts
1233
1287
  import { readFile as readFile5 } from "fs/promises";
1234
1288
  function registerUpdate(program2) {
1235
- 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) => {
1236
1290
  let user;
1237
1291
  try {
1238
1292
  user = await requireAuth();
@@ -1248,7 +1302,7 @@ function registerUpdate(program2) {
1248
1302
  const { html } = await attachLocalImages(file, slug, { update: true });
1249
1303
  const result = await updatePageHtml({ slug, file, user, html });
1250
1304
  const removed = await pruneUnusedImages(slug, html);
1251
- if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
1305
+ if (removed) console.error(`droppage: removed ${removed} image or video file(s) the page no longer uses`);
1252
1306
  if (expiresAt !== void 0) {
1253
1307
  await setPageExpiry(slug, expiresAt);
1254
1308
  console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
@@ -1264,7 +1318,7 @@ function registerUpdate(program2) {
1264
1318
  }
1265
1319
  }
1266
1320
  });
1267
- 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) => {
1268
1322
  try {
1269
1323
  await requireAuth();
1270
1324
  } catch (err) {
@@ -1962,8 +2016,8 @@ function fail(err) {
1962
2016
  process.exitCode = 1;
1963
2017
  }
1964
2018
  function registerImage(program2) {
1965
- const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local files the HTML references)");
1966
- 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) => {
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) => {
1967
2021
  if (!await signedIn()) return;
1968
2022
  const results = [];
1969
2023
  for (const file of files) {
@@ -1981,7 +2035,7 @@ function registerImage(program2) {
1981
2035
  }
1982
2036
  if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1983
2037
  });
1984
- 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) => {
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) => {
1985
2039
  if (!await signedIn()) return;
1986
2040
  try {
1987
2041
  const list = await listImages(options.page);
@@ -1990,7 +2044,7 @@ function registerImage(program2) {
1990
2044
  return;
1991
2045
  }
1992
2046
  if (list.images.length === 0) {
1993
- console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
2047
+ console.error("droppage: no images or videos. `droppage upload page.html` uploads the ones the page uses.");
1994
2048
  } else {
1995
2049
  process.stdout.write(renderTable(
1996
2050
  ["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
@@ -2000,13 +2054,13 @@ function registerImage(program2) {
2000
2054
  }
2001
2055
  if (list.page) {
2002
2056
  const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
2003
- 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.`);
2004
2058
  }
2005
2059
  } catch (err) {
2006
2060
  fail(err);
2007
2061
  }
2008
2062
  });
2009
- image.command("delete").description("Delete images (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 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) => {
2010
2064
  if (!await signedIn()) return;
2011
2065
  for (const value of ids) {
2012
2066
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droppage",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Publish HTML files as shareable, access-controlled live pages",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",