Skip to content
Open
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
19 changes: 17 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@
"category": "B2",
"icon": "$(key)"
},
{
"command": "b2.copyShareLink",
"title": "Copy Share Link",
"category": "B2",
"icon": "$(link)"
},
{
"command": "b2.openFile",
"title": "Open File",
Expand Down Expand Up @@ -195,6 +201,10 @@
"command": "b2.copyFileId",
"when": "false"
},
{
"command": "b2.copyShareLink",
"when": "false"
},
{
"command": "b2.openFile",
"when": "false"
Expand Down Expand Up @@ -320,6 +330,11 @@
"command": "b2.copyFileId",
"when": "view == b2Buckets && viewItem == file",
"group": "1_copy@2"
},
{
"command": "b2.copyShareLink",
"when": "view == b2Buckets && viewItem == file",
"group": "1_copy@3"
}
]
},
Expand Down Expand Up @@ -546,9 +561,9 @@
"expiresIn": {
"type": "integer",
"minimum": 1,
"maximum": 3600,
"maximum": 604800,
"default": 300,
"description": "URL validity duration in seconds. Default: 300 (5 minutes). Maximum: 3600 (1 hour)."
"description": "URL validity duration in seconds. Default: 300 (5 minutes). Maximum: 604800 (7 days)."
}
},
"required": [
Expand Down
3 changes: 2 additions & 1 deletion scripts/release-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const manifestContract = {
"b2.loadMore",
"b2.copyPath",
"b2.copyFileId",
"b2.copyShareLink",
"b2.openFile",
"b2.createBucket",
"b2.changeBucketVisibility",
Expand All @@ -59,7 +60,7 @@ const manifestContract = {
"b2_deleteFile",
"b2_presignUrl",
],
contributesSha256: "40a5f09a30986dfcb974032113ec6afaa2a694fa9fd12421b222955dac64eda7",
contributesSha256: "7bce248c46603d2f26fde83914d6f5a146ce56bb4eea5ea8a6c617848609cc22",
};

function stableStringify(value) {
Expand Down
198 changes: 198 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,25 @@ import {
import {
B2MutationTimeoutError,
B2PartialFailureError,
B2ShareLinkError,
formatB2DiagnosticMessage,
formatB2UserMessage,
isBucketRevisionConflict,
isPostRequestB2MutationStateAmbiguous,
redactSensitiveText,
} from "../errors";
import { log, logError } from "../logger";
import {
createPrefixScopedDownloadUrl,
SHARE_LINK_AUTHORIZATION_TIMEOUT_MS,
throwIfAborted,
type LateShareLinkAuthorizationEvent,
} from "../services/shareLink";
import { withTimeout } from "../services/transferTimeout";
import {
DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS,
MAX_PRESIGN_URL_EXPIRES_IN_SECONDS,
} from "../services/shareLinkLimits";
import {
bucketTypeLabel,
buildPublicBucketUnknownStateWarningMessage,
Expand Down Expand Up @@ -265,11 +279,89 @@ export interface OpenFileCommandServices {
getClient: () => B2Client | null;
}

export interface CopyShareLinkCommandServices {
getClient: () => Pick<B2Client, "accountInfo"> | null;
writeClipboardText?: (value: string) => Thenable<void>;
shareLinkTimeoutMs?: number;
onLateAuthorization?: (event: LateShareLinkAuthorizationEvent) => void;
now?: () => Date;
}

export interface CreateFolderCommandServices {
treeProvider: Pick<B2TreeProvider, "refresh">;
getClient: () => B2Client | null;
}

function parseShareLinkExpiresIn(input: string): number | undefined {
const trimmed = input.trim();
if (!/^\d+$/.test(trimmed)) {
return undefined;
}

const expiresIn = Number(trimmed);
return Number.isSafeInteger(expiresIn) ? expiresIn : undefined;
}

export function validateShareLinkExpiresInInput(value: string): string | undefined {
const expiresIn = parseShareLinkExpiresIn(value);
if (expiresIn === undefined || expiresIn < 1 || expiresIn > MAX_PRESIGN_URL_EXPIRES_IN_SECONDS) {
return `Enter a whole number of seconds from 1 to ${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS}.`;
}
return undefined;
}

function signalFromCancellationToken(token: vscode.CancellationToken): {
readonly signal: AbortSignal;
dispose(): void;
} {
const controller = new AbortController();
const abort = () => {
if (!controller.signal.aborted) {
controller.abort(new vscode.CancellationError());
}
};
if (token.isCancellationRequested) {
abort();
}
const subscription = token.onCancellationRequested(abort);
return {
signal: controller.signal,
dispose: () => subscription.dispose(),
};
}

function redactedCommandLateAuthorizationError(error: unknown): unknown {
if (error instanceof Error) {
const redactedError = new Error(redactSensitiveText(error.message));
redactedError.name = error.name;
return redactedError;
}
return error === undefined ? undefined : redactSensitiveText(String(error));
}

function logShareLinkLateAuthorization(event: LateShareLinkAuthorizationEvent): void {
const message =
event.status === "completed"
? `Share-link download authorization completed after timeout or cancellation for prefix ${event.filePath}; the discarded B2 token may remain valid until expiry.`
: `Share-link download authorization failed after timeout or cancellation for prefix ${event.filePath}`;
const detail = redactedCommandLateAuthorizationError(
event.status === "completed" ? event.reason : event.error,
);
const safeMessage = redactSensitiveText(message);

if (detail === undefined) {
log(safeMessage);
return;
}

log(`${safeMessage} - ${formatB2DiagnosticMessage(detail)}`);
}

function formatShareLinkTimeoutForUser(timeoutMs: number): string {
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
return `${seconds} second${seconds === 1 ? "" : "s"}`;
}

export async function openFileCommand(
item: FileTreeItem,
services: OpenFileCommandServices,
Expand Down Expand Up @@ -321,6 +413,105 @@ export async function openFileCommand(
}
}

export async function copyShareLinkCommand(
item: FileTreeItem | undefined,
services: CopyShareLinkCommandServices,
): Promise<void> {
const client = services.getClient();
if (!client) {
vscode.window.showErrorMessage("B2: Not authenticated.");
return;
}
if (!item) {
vscode.window.showErrorMessage("B2: Select a file first.");
return;
}

const expiresInInput = await vscode.window.showInputBox({
title: "Copy Share Link",
prompt: `Enter link TTL in seconds (1-${MAX_PRESIGN_URL_EXPIRES_IN_SECONDS})`,
value: String(DEFAULT_PRESIGN_URL_EXPIRES_IN_SECONDS),
placeHolder: "3600",
ignoreFocusOut: true,
validateInput: validateShareLinkExpiresInInput,
});
if (expiresInInput === undefined) {
return;
}

const validationError = validateShareLinkExpiresInInput(expiresInInput);
if (validationError) {
vscode.window.showErrorMessage(`B2: ${validationError}`);
return;
}

const expiresIn = parseShareLinkExpiresIn(expiresInInput);
if (expiresIn === undefined) {
vscode.window.showErrorMessage("B2: Invalid share link TTL.");
return;
}

try {
const { expiresAt } = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Creating share link for "${item.file.fileName}"...`,
cancellable: true,
},
async (_progress, token) => {
const writeClipboardText =
services.writeClipboardText ?? ((value: string) => vscode.env.clipboard.writeText(value));
const cancellation = signalFromCancellationToken(token);
try {
return await withTimeout(
async (signal) => {
const shareLink = await createPrefixScopedDownloadUrl({
bucket: item.bucket,
bucketName: item.bucketName,
filePath: item.file.fileName,
downloadUrl: client.accountInfo.getDownloadUrl(),
expiresIn,
signal,
onLateAuthorization: services.onLateAuthorization ?? logShareLinkLateAuthorization,
});
throwIfAborted(signal);
await writeClipboardText(shareLink.url);
throwIfAborted(signal);
const now = services.now?.() ?? new Date();
return {
expiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
};
},
services.shareLinkTimeoutMs ?? SHARE_LINK_AUTHORIZATION_TIMEOUT_MS,
`Share link for b2://${item.bucketName}/${item.file.fileName}`,
{
signal: cancellation.signal,
createTimeoutError: (description, timeoutMs) =>
new B2ShareLinkError(
`${description} timed out after ${formatShareLinkTimeoutForUser(timeoutMs)}.`,
),
},
);
} finally {
cancellation.dispose();
}
},
);

log(
`Created prefix-scoped share link for b2://${item.bucketName}/${item.file.fileName} expiring at ${expiresAt}.`,
);
vscode.window.showInformationMessage(
`B2: Share link copied. Expires at ${expiresAt}. Future same-prefix objects may also be downloadable until then.`,
);
} catch (error) {
if (error instanceof vscode.CancellationError) {
return;
}
showCommandError("B2: Failed to create share link", error);
}
}

export async function authenticateCommand(services: CommandServices): Promise<void> {
const { authService, context, treeProvider, setClient } = services;
const createClient = services.createClient ?? createConfiguredB2Client;
Expand Down Expand Up @@ -877,6 +1068,13 @@ export function registerCommands(services: CommandServices): void {
}),
);

// ── Copy Share Link ─────────────────────────────────────────────────────
context.subscriptions.push(
vscode.commands.registerCommand("b2.copyShareLink", (item?: FileTreeItem) =>
copyShareLinkCommand(item, { getClient }),
),
);

// ── Open File ───────────────────────────────────────────────────────────
context.subscriptions.push(
vscode.commands.registerCommand("b2.openFile", (item: FileTreeItem) =>
Expand Down
11 changes: 11 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ export class B2ToolInputError extends Error {
}
}

export class B2ShareLinkError extends Error {
constructor(message: string) {
super(message);
this.name = "B2ShareLinkError";
}
}

/** Error used when extension-side lookup confirms a B2 resource is absent. */
export class B2ResourceNotFoundError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -452,6 +459,10 @@ export function formatB2UserMessage(error: unknown): string {
return redactSensitiveText(getErrorMessage(error));
}

if (error instanceof B2ShareLinkError || matchesErrorName(error, "B2ShareLinkError")) {
return redactSensitiveText(getErrorMessage(error));
}

if (isMutationTimeout(error)) {
return "The B2 request timed out before the extension could confirm the final state. Refresh the bucket tree and verify the bucket in Backblaze before retrying.";
}
Expand Down
Loading
Loading