Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 84 additions & 4 deletions src/commands/blob/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -14,30 +15,109 @@ 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 <name>", "Bucket name")
.option(
"--visibility <visibility>",
`Bucket visibility. Available: ${BLOB_VISIBILITIES.join(", ")}`,
parseVisibility,
"private",
)
.option("--cors <origins...>", "Allowed CORS origins (space-separated)")
.option("--cors <origins...>", CORS_HELP, parseCorsOrigin)
.option("--env-file <path>", 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<BlobBucket>(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("'", "'\\''")}'`;
}
75 changes: 75 additions & 0 deletions src/commands/blob/env-file.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<Omit<BlobBucket, "token" | "token_next">> {
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;
}
9 changes: 8 additions & 1 deletion src/commands/blob/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ 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
.command("get [bucket]")
.description("Get details of a Blob bucket, by name or id")
.addOption(new Option("--bucket-id <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 <path>", 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<BlobBucket>(resolveAuth(command), "GET", `/v2/blob/bucket/${id}`);
if (flags.envFile !== undefined) {
printJSON(await saveBucketToken(bucket, flags.envFile));
return;
}
if (!flags.hideCredentials) {
printJSON(bucket);
return;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/blob/mb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -19,7 +19,7 @@ export function registerBlobMb(blob: Command): void {
parseVisibility,
"private",
)
.option("--cors <origins...>", "Allowed CORS origins (space-separated)")
.option("--cors <origins...>", CORS_HELP, parseCorsOrigin)
.action(async (uri: string, flags: { visibility: BlobVisibility; cors?: string[] }, cmd: Command) => {
const name = parseBucket(uri);
const bucket = await request<BlobBucket>(resolveAuth(cmd), "POST", "/v2/blob/bucket", {
Expand Down
Loading
Loading