diff --git a/README.md b/README.md index 0d9363e..5cf1094 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ upstash qstash stats --qstash-id $QSTASH_ID --period 7d # Blob upstash blob create --name my-bucket --visibility private +upstash blob get my-bucket --env-file .env.local # writes UPSTASH_BLOB_TOKEN, omits it from output upstash blob ls upstash blob ls my-bucket upstash blob cp ./assets blob://my-bucket/assets -r diff --git a/src/commands/blob/create.ts b/src/commands/blob/create.ts index dd87b53..8a59c97 100644 --- a/src/commands/blob/create.ts +++ b/src/commands/blob/create.ts @@ -4,6 +4,7 @@ import { request } from "../../client.js"; import { printJSON } from "../../output.js"; import { BLOB_VISIBILITIES } from "../../types.js"; import type { BlobBucket, BlobVisibility } from "../../types.js"; +import { checkEnvFileTarget, ENV_FILE_HELP, saveBucketToken } from "./env-file.js"; export function parseVisibility(value: string): BlobVisibility { if ((BLOB_VISIBILITIES as readonly string[]).includes(value)) { @@ -14,10 +15,67 @@ export function parseVisibility(value: string): BlobVisibility { ); } +export const CORS_HELP = + "Allowed CORS origins, space-separated, each http(s)://host[:port] " + + "(e.g. http://localhost:3000 https://app.example.com). " + + "Omit --cors to allow all origins (*). Browser uploads are refused from any origin not listed"; + +const ORIGIN_EXAMPLE = "expected http(s)://host[:port], e.g. https://app.example.com or http://localhost:3000"; +const WILDCARD = "wildcard.invalid"; + +/** + * One origin as the browser sends it in the Origin header: `*`, or http(s)://host[:port] with no + * path, query or trailing slash. A wildcard may stand in for part of the host (https://*.example.com). + * Anything else would be stored and never match, so it is refused here rather than by the API. + */ +export function corsOriginProblem(value: string): string | undefined { + if (value === "*") return undefined; + const shown = JSON.stringify(value); + if (value.trim() === "") return `${shown} is not a valid origin, ${ORIGIN_EXAMPLE}`; + if (/[\s,]/.test(value.trim())) { + return `${shown} is not a valid origin: pass each origin as its own argument, separated by spaces (--cors https://a.example.com https://b.example.com)`; + } + if (!value.includes("://")) { + const local = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(value); + const guess = suggestion(`${local ? "http" : "https"}://${value}`, true); + return `${shown} is not a valid origin, ${ORIGIN_EXAMPLE}${guess ? ` (did you mean ${guess}?)` : ""}`; + } + if (/^https?:\/\//i.test(value)) { + const guess = suggestion(value); + if (guess === value) return undefined; + return `${shown} is not a valid origin, ${ORIGIN_EXAMPLE}${guess ? ` (did you mean ${guess}?)` : ""}`; + } + // The console takes http and https only, and so does this. + return `${shown} is not a valid origin, ${ORIGIN_EXAMPLE}`; +} + +/** + * The http(s) origin a value most likely meant, or undefined when it does not parse as one. A + * scheme we added ourselves only counts on a host that looks like one: a bare word is a typo. + */ +function suggestion(value: string, guessedScheme = false): string | undefined { + try { + const url = new URL(value.replaceAll("*", WILDCARD)); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + if (!url.hostname || url.username || url.password) return undefined; + if (guessedScheme && !/[.[]/.test(url.hostname) && url.hostname !== "localhost") return undefined; + return url.origin.replaceAll(WILDCARD, "*"); + } catch { + return undefined; + } +} + +/** Commander parser for the variadic --cors: validates each origin and collects them. */ +export function parseCorsOrigin(value: string, previous: string[] | undefined): string[] { + const problem = corsOriginProblem(value); + if (problem) throw new InvalidArgumentError(problem); + return [...(previous ?? []), value]; +} + export function registerBlobCreate(blob: Command): void { blob .command("create") - .description("Create a Blob bucket") + .description("Create a Blob bucket. Add --env-file .env.local to save its token for @upstash/blob") .requiredOption("--name ", "Bucket name") .option( "--visibility ", @@ -25,19 +83,41 @@ export function registerBlobCreate(blob: Command): void { parseVisibility, "private", ) - .option("--cors ", "Allowed CORS origins (space-separated)") + .option("--cors ", CORS_HELP, parseCorsOrigin) + .option("--env-file ", ENV_FILE_HELP) + .addHelpText( + "after", + "\nExample:\n upstash blob create --name my-bucket --env-file .env.local", + ) .action( async ( - flags: { name: string; visibility: BlobVisibility; cors?: string[] }, + flags: { name: string; visibility: BlobVisibility; cors?: string[]; envFile?: string }, command: Command, ) => { const auth = resolveAuth(command); + if (flags.envFile !== undefined) await checkEnvFileTarget(flags.envFile); const bucket = await request(auth, "POST", "/v2/blob/bucket", { name: flags.name, visibility: flags.visibility, cors: flags.cors, }); - printJSON(bucket); + if (flags.envFile === undefined) { + printJSON(bucket); + return; + } + try { + printJSON(await saveBucketToken(bucket, flags.envFile)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `bucket ${bucket.name} was created, but its token could not be written (${reason}); ` + + `run: upstash blob get ${bucket.name} --env-file ${shellQuote(flags.envFile)}`, + ); + } }, ); } + +function shellQuote(value: string): string { + return /^[\w./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/src/commands/blob/env-file.ts b/src/commands/blob/env-file.ts new file mode 100644 index 0000000..5abe1d7 --- /dev/null +++ b/src/commands/blob/env-file.ts @@ -0,0 +1,75 @@ +import { constants } from "node:fs"; +import { access, chmod, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { BlobBucket } from "../../types.js"; + +export const BLOB_TOKEN_ENV = "UPSTASH_BLOB_TOKEN"; + +export const ENV_FILE_HELP = + `Write the bucket token to this env file as ${BLOB_TOKEN_ENV} (e.g. .env.local), ` + + "and print the bucket without its tokens. The file is created if missing, an existing " + + `${BLOB_TOKEN_ENV} line is replaced, other lines are kept, and the mode is set to 600`; + +/** Fails before a bucket is created when the token would have nowhere to go. */ +export async function checkEnvFileTarget(path: string): Promise { + if (path.trim() === "") throw new Error("--env-file needs a path, e.g. --env-file .env.local"); + const existing = await stat(path).catch(() => undefined); + if (existing?.isDirectory() || /[\\/]$/.test(path)) throw new Error(`--env-file: ${path} is a directory`); + // Only the owner can narrow the mode to 600, and the token is not written without it. + if (existing && process.getuid && existing.uid !== process.getuid()) { + throw new Error(`--env-file: ${path} belongs to another user, so its mode cannot be set to 600`); + } + const target = existing ? path : dirname(path); + if (!existing) { + const dir = await stat(target).catch(() => undefined); + if (!dir?.isDirectory()) throw new Error(`--env-file: directory ${target} does not exist`); + } + await access(target, constants.W_OK).catch(() => { + throw new Error(`--env-file: ${target} is not writable`); + }); +} + +/** + * Sets NAME=value in a dotenv file: every existing NAME line (with or without `export`) is + * rewritten in place, otherwise one line is appended. Every other line and line ending is kept. + */ +export async function writeEnvVar(path: string, name: string, value: string): Promise { + await checkEnvFileTarget(path); + let content = ""; + let exists = true; + try { + content = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + exists = false; + } + const eol = /\r?\n/.exec(content)?.[0] ?? "\n"; + const line = new RegExp(`^(\\s*)(export\\s+)?${name}\\s*=.*$`); + const encoded = /^[\w.+/=:-]*$/.test(value) ? value : JSON.stringify(value); + let found = false; + // Odd indexes are the separators, kept as they were. + const parts = content.split(/(\r?\n)/).map((text, i) => { + const match = i % 2 === 0 ? line.exec(text) : null; + if (!match) return text; + found = true; + return `${match[1]}${match[2] ?? ""}${name}=${encoded}`; + }); + let next = parts.join(""); + if (!found) { + if (next.length > 0 && !next.endsWith("\n")) next += eol; + next += `${name}=${encoded}${eol}`; + } + // Narrowed before the token is written, so an existing world-readable file never holds it. + if (exists) await chmod(path, 0o600); + await writeFile(path, next, { mode: 0o600 }); + await chmod(path, 0o600); +} + +/** Writes the bucket's token to the env file and returns what is safe to print. */ +export async function saveBucketToken(bucket: BlobBucket, path: string): Promise> { + const { token, token_next: _tokenNext, ...rest } = bucket; + if (!token) throw new Error(`--env-file: the response carried no token, so ${path} was not changed`); + await writeEnvVar(path, BLOB_TOKEN_ENV, token); + console.error(`Wrote ${BLOB_TOKEN_ENV} to ${path}`); + return rest; +} diff --git a/src/commands/blob/get.ts b/src/commands/blob/get.ts index 10e2e6d..05edcdb 100644 --- a/src/commands/blob/get.ts +++ b/src/commands/blob/get.ts @@ -4,6 +4,7 @@ import { request } from "../../client.js"; import { printJSON } from "../../output.js"; import type { BlobBucket } from "../../types.js"; import { bucketIdArgument } from "./buckets.js"; +import { ENV_FILE_HELP, saveBucketToken } from "./env-file.js"; export function registerBlobGet(blob: Command): void { blob @@ -11,9 +12,15 @@ export function registerBlobGet(blob: Command): void { .description("Get details of a Blob bucket, by name or id") .addOption(new Option("--bucket-id ").hideHelp()) .option("--hide-credentials", "Omit bucket tokens from output") - .action(async (name: string | undefined, flags: { bucketId?: string; hideCredentials?: boolean }, command: Command) => { + .option("--env-file ", ENV_FILE_HELP) + .addHelpText("after", "\nExample:\n upstash blob get my-bucket --env-file .env.local") + .action(async (name: string | undefined, flags: { bucketId?: string; hideCredentials?: boolean; envFile?: string }, command: Command) => { const id = await bucketIdArgument(command, name, flags); const bucket = await request(resolveAuth(command), "GET", `/v2/blob/bucket/${id}`); + if (flags.envFile !== undefined) { + printJSON(await saveBucketToken(bucket, flags.envFile)); + return; + } if (!flags.hideCredentials) { printJSON(bucket); return; diff --git a/src/commands/blob/mb.ts b/src/commands/blob/mb.ts index 66690b1..49f968e 100644 --- a/src/commands/blob/mb.ts +++ b/src/commands/blob/mb.ts @@ -5,7 +5,7 @@ import { printJSON } from "../../output.js"; import { BLOB_VISIBILITIES } from "../../types.js"; import type { BlobBucket, BlobVisibility } from "../../types.js"; import { BucketResolver, findAccountBucket } from "./buckets.js"; -import { parseVisibility } from "./create.js"; +import { CORS_HELP, parseCorsOrigin, parseVisibility } from "./create.js"; import { deleteBlobBucket } from "./delete.js"; import { listBlobs, parseBucket, runOperations } from "./transfer.js"; @@ -19,7 +19,7 @@ export function registerBlobMb(blob: Command): void { parseVisibility, "private", ) - .option("--cors ", "Allowed CORS origins (space-separated)") + .option("--cors ", CORS_HELP, parseCorsOrigin) .action(async (uri: string, flags: { visibility: BlobVisibility; cors?: string[] }, cmd: Command) => { const name = parseBucket(uri); const bucket = await request(resolveAuth(cmd), "POST", "/v2/blob/bucket", { diff --git a/tests/unit/blob.test.ts b/tests/unit/blob.test.ts index d095572..ebde04c 100644 --- a/tests/unit/blob.test.ts +++ b/tests/unit/blob.test.ts @@ -1,8 +1,12 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBlobProgram, runCommand } from "../helpers/program.js"; import { fetchBlobCredentials } from "../../src/commands/blob/credentials.js"; import { deleteBlobBucket } from "../../src/commands/blob/delete.js"; import { isFreshlyCreated } from "../../src/commands/blob/retry.js"; +import { corsOriginProblem } from "../../src/commands/blob/create.js"; import type { BlobBucket, BlobS3Credentials } from "../../src/types.js"; const originalEnv = { ...process.env }; @@ -572,3 +576,148 @@ describe("blob delete retries", () => { expect(await pending).toEqual({ deleted: true, bucket_id: "bucket_123" }); }); }); + +describe("--env-file", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "upstash-env-file-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const tokenless = () => { + const { token: _t, token_next: _n, ...rest } = makeBucket(); + return rest; + }; + const mode = async (path: string) => (await stat(path)).mode & 0o777; + + it("create writes a new file with mode 600 and prints the bucket without tokens", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(makeBucket()), { status: 200 })); + const path = join(dir, ".env.local"); + + const result = await runCommand(await createBlobProgram(), ["blob", "create", "--name", "my-bucket", "--env-file", path]); + + expect(result).toEqual(tokenless()); + expect(JSON.stringify(result)).not.toContain("token_"); + expect(await readFile(path, "utf8")).toBe("UPSTASH_BLOB_TOKEN=token_current\n"); + expect(await mode(path)).toBe(0o600); + }); + + it("get replaces an existing token line, keeps the rest, and narrows the mode", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(makeBucket()), { status: 200 })); + const path = join(dir, ".env.local"); + await writeFile(path, "# app\nDATABASE_URL=postgres://x\nexport UPSTASH_BLOB_TOKEN=old\nOTHER=1\n# UPSTASH_BLOB_TOKEN=commented\n", { mode: 0o644 }); + + const result = await runCommand(await createBlobProgram(), ["blob", "get", "--bucket-id", "bucket_123", "--env-file", path]); + + expect(result).toEqual(tokenless()); + expect(await readFile(path, "utf8")).toBe( + "# app\nDATABASE_URL=postgres://x\nexport UPSTASH_BLOB_TOKEN=token_current\nOTHER=1\n# UPSTASH_BLOB_TOKEN=commented\n", + ); + expect(await mode(path)).toBe(0o600); + }); + + it("appends to a file that has no token line and no trailing newline", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(makeBucket()), { status: 200 })); + const path = join(dir, ".env"); + await writeFile(path, "A=1\r\nB=2"); + + await runCommand(await createBlobProgram(), ["blob", "get", "--bucket-id", "bucket_123", "--env-file", path]); + + expect(await readFile(path, "utf8")).toBe("A=1\r\nB=2\r\nUPSTASH_BLOB_TOKEN=token_current\r\n"); + }); + + it("create refuses a target directory that does not exist before creating anything", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await expect( + runCommand(await createBlobProgram(), ["blob", "create", "--name", "my-bucket", "--env-file", join(dir, "missing", ".env")]), + ).rejects.toThrow(/does not exist/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("keeps mixed line endings as they were", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(makeBucket()), { status: 200 })); + const path = join(dir, ".env"); + await writeFile(path, "A=1\r\nUPSTASH_BLOB_TOKEN=old\nB=2\n"); + + await runCommand(await createBlobProgram(), ["blob", "get", "--bucket-id", "bucket_123", "--env-file", path]); + + expect(await readFile(path, "utf8")).toBe("A=1\r\nUPSTASH_BLOB_TOKEN=token_current\nB=2\n"); + }); + + it("create refuses an empty path, a read-only file or a directory path before creating anything", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const readOnly = join(dir, ".env"); + await writeFile(readOnly, "A=1\n", { mode: 0o400 }); + for (const [path, message] of [["", /needs a path/], [readOnly, /not writable/], [join(dir, "new") + "/", /is a directory/]] as const) { + await expect( + runCommand(await createBlobProgram(), ["blob", "create", "--name", "my-bucket", "--env-file", path]), + ).rejects.toThrow(message); + } + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("refuses a response without a token and leaves the file alone", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(makeBucket({ token: undefined, token_next: undefined })), { status: 200 }), + ); + const path = join(dir, ".env"); + await writeFile(path, "A=1\n"); + await expect( + runCommand(await createBlobProgram(), ["blob", "get", "--bucket-id", "bucket_123", "--env-file", path]), + ).rejects.toThrow(/no token/); + expect(await readFile(path, "utf8")).toBe("A=1\n"); + }); + + it("is advertised in create and get help", async () => { + const program = await createBlobProgram(); + const blob = program.commands.find((c) => c.name() === "blob")!; + for (const name of ["create", "get"]) { + const help = blob.commands.find((c) => c.name() === name)!.helpInformation(); + expect(help).toContain("--env-file "); + expect(help).toContain("UPSTASH_BLOB_TOKEN"); + } + }); +}); + +describe("--cors validation", () => { + it.each([ + ["", undefined], + ["localhost:3000", "http://localhost:3000"], + ["app.example.com", "https://app.example.com"], + ["http://localhost:3000/", "http://localhost:3000"], + ["https://app.example.com/upload", "https://app.example.com"], + ["HTTPS://App.Example.com", "https://app.example.com"], + ["https://a.com,https://b.com", undefined], + ["capacitor://localhost", undefined], + ])("rejects %j", (value, guess) => { + const problem = corsOriginProblem(value); + expect(problem).toContain(`${JSON.stringify(value)} is not a valid origin`); + if (guess) expect(problem).toContain(`did you mean ${guess}?`); + }); + + it.each(["*", "http://localhost:3000", "https://app.example.com", "https://*.example.com", "http://127.0.0.1:8080", "http://intranet:8080"])( + "accepts %j", + (value) => { + expect(corsOriginProblem(value)).toBeUndefined(); + }, + ); + + for (const command of [["create", "--name", "my-bucket"], ["mb", "blob://my-bucket"]]) { + it(`${command[0]} refuses an empty origin before calling the API`, async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await expect(runCommand(await createBlobProgram(), ["blob", ...command, "--cors", ""])).rejects.toThrow(/is not a valid origin/); + expect(fetchSpy).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); + } + + it("create help says omitting --cors allows all origins", async () => { + const program = await createBlobProgram(); + const blob = program.commands.find((c) => c.name() === "blob")!; + const help = blob.commands.find((c) => c.name() === "create")!.helpInformation().replace(/\s+/g, " "); + expect(help).toContain("Omit --cors to allow all origins (*)"); + }); +});