From ee0e8a960f8f4c9b75de43f7616663f1a8e8d329 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 13 Jul 2026 10:59:42 +0200 Subject: [PATCH 1/3] feat(upload): --private (signed URLs) + --expires lifetime `pixelvault upload --private` sends visibility=private to POST /v1/images and prints the returned signed URL (works for link holders, expires on a schedule). `--expires <30m|12h|7d|seconds>` sets the signed link's lifetime (default 7d server-side; min 60s, max 30d), validated locally before upload via a new parseExpires helper. --expires without --private is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/upload.ts | 35 +++++++++++++++++++++++++++++++++++ src/lib/duration.ts | 31 +++++++++++++++++++++++++++++++ tests/lib/duration.test.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/lib/duration.ts create mode 100644 tests/lib/duration.test.ts diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 56f6e81..3ca2ace 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -3,17 +3,20 @@ import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { apiRequest } from "../lib/client.js"; import { requireApiKey } from "../lib/config.js"; +import { parseExpires } from "../lib/duration.js"; import { stdout, stderr, jsonOut } from "../lib/output.js"; interface UploadResponse { data: { id: string; url: string; + visibility?: "public" | "private"; mime_type: string; size: number; filename: string; folder: string | null; created_at: string; + signed_expires?: number; }; } @@ -32,6 +35,17 @@ export default defineCommand({ type: "string", description: "Folder to organize images in", }, + private: { + type: "boolean", + description: + "Upload privately — returns a signed URL only holders of the link can open (needs a secret key)", + default: false, + }, + expires: { + type: "string", + description: + "Lifetime of a --private signed link: e.g. 30m, 12h, 7d (default 7d; min 60s, max 30d)", + }, json: { type: "boolean", description: "Output full JSON response", @@ -41,6 +55,17 @@ export default defineCommand({ async run({ args }) { const apiKey = requireApiKey(); + // Resolve the signed-link lifetime up front so a bad value fails before any + // upload happens. `--expires` only makes sense for a private (signed) URL. + let signExpires: number | null = null; + if (args.expires !== undefined) { + if (!args.private) { + stderr("--expires only applies to --private uploads (it sets the signed link's lifetime)."); + process.exit(1); + } + signExpires = parseExpires(String(args.expires)); + } + // citty passes positional as a single string; we handle globs via shell expansion const files = Array.isArray(args.files) ? args.files : [args.files]; let hadError = false; @@ -62,6 +87,16 @@ export default defineCommand({ formData.append("folder", args.folder); } + if (args.private) { + // The server routes private images to a signed-URL-only path and + // returns the ready-to-share signed URL in `data.url`. A user-supplied + // folder is ignored for private uploads (server-side). + formData.append("visibility", "private"); + if (signExpires !== null) { + formData.append("sign_expires_in", String(signExpires)); + } + } + const res = await apiRequest({ method: "POST", path: "/v1/images", diff --git a/src/lib/duration.ts b/src/lib/duration.ts new file mode 100644 index 0000000..71ef1dd --- /dev/null +++ b/src/lib/duration.ts @@ -0,0 +1,31 @@ +import { CliError } from "./errors.js"; + +/** Signed-URL lifetime bounds, matching the API's `sign_expires_in` (60s..30d). */ +export const MIN_EXPIRES_SECONDS = 60; +export const MAX_EXPIRES_SECONDS = 30 * 24 * 60 * 60; + +/** + * Parse a signed-link lifetime like `30m`, `12h`, `7d`, `90s`, or a bare number + * of seconds, into seconds. Rejects anything outside the API's accepted range so + * a bad value fails locally with a clear message instead of a 400 from the server. + */ +export function parseExpires(input: string): number { + const match = /^(\d+)\s*([smhd]?)$/i.exec(input.trim()); + if (!match) { + throw new CliError( + `Invalid --expires "${input}". Use e.g. 30m, 12h, 7d, or a number of seconds.` + ); + } + + const unit = (match[2] ?? "").toLowerCase(); + const multiplier = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1; + const seconds = Number(match[1] ?? "") * multiplier; + + if (seconds < MIN_EXPIRES_SECONDS || seconds > MAX_EXPIRES_SECONDS) { + throw new CliError( + `--expires must be between 60s and 30d (got ${seconds}s).` + ); + } + + return seconds; +} diff --git a/tests/lib/duration.test.ts b/tests/lib/duration.test.ts new file mode 100644 index 0000000..eccbb3e --- /dev/null +++ b/tests/lib/duration.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { parseExpires, MIN_EXPIRES_SECONDS, MAX_EXPIRES_SECONDS } from "../../src/lib/duration.js"; +import { CliError } from "../../src/lib/errors.js"; + +describe("parseExpires", () => { + it("parses unit suffixes into seconds", () => { + expect(parseExpires("90s")).toBe(90); + expect(parseExpires("30m")).toBe(1800); + expect(parseExpires("12h")).toBe(43200); + expect(parseExpires("7d")).toBe(604800); + }); + + it("treats a bare number as seconds and tolerates whitespace/case", () => { + expect(parseExpires("3600")).toBe(3600); + expect(parseExpires(" 7D ")).toBe(604800); + }); + + it("accepts the inclusive bounds exactly (60s .. 30d)", () => { + expect(parseExpires("60")).toBe(MIN_EXPIRES_SECONDS); + expect(parseExpires("30d")).toBe(MAX_EXPIRES_SECONDS); + }); + + it("rejects values outside the accepted range", () => { + expect(() => parseExpires("59")).toThrow(CliError); // below min + expect(() => parseExpires("0")).toThrow(CliError); + expect(() => parseExpires("31d")).toThrow(CliError); // above max + expect(() => parseExpires("2592001")).toThrow(CliError); + }); + + it("rejects malformed input", () => { + for (const bad of ["", "abc", "7w", "-1", "7.5d", "d7", "7 days"]) { + expect(() => parseExpires(bad)).toThrow(CliError); + } + }); +}); From f70797f5a3cfcd9e8e7ddf976ddfe788585ee017 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 13 Jul 2026 11:03:22 +0200 Subject: [PATCH 2/3] review: fail closed if server doesn't confirm a private upload Defense-in-depth from code review: after a --private upload, verify res.data.visibility === 'private' before reporting the URL; otherwise error out (don't hand back a link the user would wrongly trust as private, e.g. if a stale deploy ignored the flag). Also warn that --folder is ignored with --private. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/upload.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 3ca2ace..6efbbfa 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -66,6 +66,10 @@ export default defineCommand({ signExpires = parseExpires(String(args.expires)); } + if (args.private && args.folder) { + stderr("Note: --folder is ignored for --private uploads."); + } + // citty passes positional as a single string; we handle globs via shell expansion const files = Array.isArray(args.files) ? args.files : [args.files]; let hadError = false; @@ -104,6 +108,14 @@ export default defineCommand({ formData, }); + // Fail closed: if we asked for private but the server didn't confirm it, + // don't hand back a URL the user would wrongly trust as private. + if (args.private && res.data.visibility !== "private") { + hadError = true; + stderr(`Refusing to report ${filePath}: server did not confirm a private upload.`); + continue; + } + if (args.json) { results.push(res.data); } else { From 3ed0fb8de63df0b23464d54321b38477de6584d8 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 13 Jul 2026 11:04:28 +0200 Subject: [PATCH 3/3] chore: bump to 0.4.0 (private uploads) Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 365510c..526dd5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pixelvault-cli", - "version": "0.3.0", + "version": "0.4.0", "description": "CLI for PixelVault — agent-first image hosting", "type": "module", "bin": {