mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[PM-19054] configure send with email otp authentication via cli (#15360)
This commit is contained in:
@@ -25,7 +25,6 @@ import { LoginExport } from "@bitwarden/common/models/export/login.export";
|
|||||||
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
|
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
|
||||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
|
||||||
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
@@ -44,7 +43,6 @@ import { SelectionReadOnly } from "../admin-console/models/selection-read-only";
|
|||||||
import { Response } from "../models/response";
|
import { Response } from "../models/response";
|
||||||
import { StringResponse } from "../models/response/string.response";
|
import { StringResponse } from "../models/response/string.response";
|
||||||
import { TemplateResponse } from "../models/response/template.response";
|
import { TemplateResponse } from "../models/response/template.response";
|
||||||
import { SendResponse } from "../tools/send/models/send.response";
|
|
||||||
import { CliUtils } from "../utils";
|
import { CliUtils } from "../utils";
|
||||||
import { CipherResponse } from "../vault/models/cipher.response";
|
import { CipherResponse } from "../vault/models/cipher.response";
|
||||||
import { FolderResponse } from "../vault/models/folder.response";
|
import { FolderResponse } from "../vault/models/folder.response";
|
||||||
@@ -577,11 +575,11 @@ export class GetCommand extends DownloadCommand {
|
|||||||
case "org-collection":
|
case "org-collection":
|
||||||
template = OrganizationCollectionRequest.template();
|
template = OrganizationCollectionRequest.template();
|
||||||
break;
|
break;
|
||||||
case "send.text":
|
|
||||||
template = SendResponse.template(SendType.Text);
|
|
||||||
break;
|
|
||||||
case "send.file":
|
case "send.file":
|
||||||
template = SendResponse.template(SendType.File);
|
case "send.text":
|
||||||
|
template = Response.badRequest(
|
||||||
|
`Invalid template object. Use \`bw send template ${id}\` instead.`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return Response.badRequest("Unknown template object.");
|
return Response.badRequest("Unknown template object.");
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ export class SendCreateCommand {
|
|||||||
const filePath = req.file?.fileName ?? options.file;
|
const filePath = req.file?.fileName ?? options.file;
|
||||||
const text = req.text?.text ?? options.text;
|
const text = req.text?.text ?? options.text;
|
||||||
const hidden = req.text?.hidden ?? options.hidden;
|
const hidden = req.text?.hidden ?? options.hidden;
|
||||||
const password = req.password ?? options.password;
|
const password = req.password ?? options.password ?? undefined;
|
||||||
|
const emails = req.emails ?? options.emails ?? undefined;
|
||||||
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
|
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
|
||||||
|
|
||||||
|
if (emails !== undefined && password !== undefined) {
|
||||||
|
return Response.badRequest("--password and --emails are mutually exclusive.");
|
||||||
|
}
|
||||||
|
|
||||||
req.key = null;
|
req.key = null;
|
||||||
req.maxAccessCount = maxAccessCount;
|
req.maxAccessCount = maxAccessCount;
|
||||||
|
|
||||||
@@ -133,6 +138,7 @@ export class SendCreateCommand {
|
|||||||
// Add dates from template
|
// Add dates from template
|
||||||
encSend.deletionDate = sendView.deletionDate;
|
encSend.deletionDate = sendView.deletionDate;
|
||||||
encSend.expirationDate = sendView.expirationDate;
|
encSend.expirationDate = sendView.expirationDate;
|
||||||
|
encSend.emails = emails && emails.join(",");
|
||||||
|
|
||||||
await this.sendApiService.save([encSend, fileData]);
|
await this.sendApiService.save([encSend, fileData]);
|
||||||
const newSend = await this.sendService.getFromState(encSend.id);
|
const newSend = await this.sendService.getFromState(encSend.id);
|
||||||
@@ -151,12 +157,14 @@ class Options {
|
|||||||
text: string;
|
text: string;
|
||||||
maxAccessCount: number;
|
maxAccessCount: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
emails: Array<string>;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
|
||||||
constructor(passedOptions: Record<string, any>) {
|
constructor(passedOptions: Record<string, any>) {
|
||||||
this.file = passedOptions?.file;
|
this.file = passedOptions?.file;
|
||||||
this.text = passedOptions?.text;
|
this.text = passedOptions?.text;
|
||||||
this.password = passedOptions?.password;
|
this.password = passedOptions?.password;
|
||||||
|
this.emails = passedOptions?.email;
|
||||||
this.hidden = CliUtils.convertBooleanOption(passedOptions?.hidden);
|
this.hidden = CliUtils.convertBooleanOption(passedOptions?.hidden);
|
||||||
this.maxAccessCount =
|
this.maxAccessCount =
|
||||||
passedOptions?.maxAccessCount != null ? parseInt(passedOptions.maxAccessCount, null) : null;
|
passedOptions?.maxAccessCount != null ? parseInt(passedOptions.maxAccessCount, null) : null;
|
||||||
|
|||||||
@@ -50,11 +50,21 @@ export class SendEditCommand {
|
|||||||
|
|
||||||
const normalizedOptions = new Options(cmdOptions);
|
const normalizedOptions = new Options(cmdOptions);
|
||||||
req.id = normalizedOptions.itemId || req.id;
|
req.id = normalizedOptions.itemId || req.id;
|
||||||
|
if (normalizedOptions.emails) {
|
||||||
if (req.id != null) {
|
req.emails = normalizedOptions.emails;
|
||||||
req.id = req.id.toLowerCase();
|
req.password = undefined;
|
||||||
|
} else if (normalizedOptions.password) {
|
||||||
|
req.emails = undefined;
|
||||||
|
req.password = normalizedOptions.password;
|
||||||
|
} else if (req.password && (typeof req.password !== "string" || req.password === "")) {
|
||||||
|
req.password = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!req.id) {
|
||||||
|
return Response.error("`itemid` was not provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.id = req.id.toLowerCase();
|
||||||
const send = await this.sendService.getFromState(req.id);
|
const send = await this.sendService.getFromState(req.id);
|
||||||
|
|
||||||
if (send == null) {
|
if (send == null) {
|
||||||
@@ -76,10 +86,6 @@ export class SendEditCommand {
|
|||||||
let sendView = await send.decrypt();
|
let sendView = await send.decrypt();
|
||||||
sendView = SendResponse.toView(req, sendView);
|
sendView = SendResponse.toView(req, sendView);
|
||||||
|
|
||||||
if (typeof req.password !== "string" || req.password === "") {
|
|
||||||
req.password = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
|
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
|
||||||
// Add dates from template
|
// Add dates from template
|
||||||
@@ -97,8 +103,12 @@ export class SendEditCommand {
|
|||||||
|
|
||||||
class Options {
|
class Options {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
password: string;
|
||||||
|
emails: string[];
|
||||||
|
|
||||||
constructor(passedOptions: Record<string, any>) {
|
constructor(passedOptions: Record<string, any>) {
|
||||||
this.itemId = passedOptions?.itemId || passedOptions?.itemid;
|
this.itemId = passedOptions?.itemId || passedOptions?.itemid;
|
||||||
|
this.password = passedOptions.password;
|
||||||
|
this.emails = passedOptions.email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from "./get.command";
|
|||||||
export * from "./list.command";
|
export * from "./list.command";
|
||||||
export * from "./receive.command";
|
export * from "./receive.command";
|
||||||
export * from "./remove-password.command";
|
export * from "./remove-password.command";
|
||||||
|
export * from "./template.command";
|
||||||
|
|||||||
35
apps/cli/src/tools/send/commands/template.command.ts
Normal file
35
apps/cli/src/tools/send/commands/template.command.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||||
|
|
||||||
|
import { Response } from "../../../models/response";
|
||||||
|
import { TemplateResponse } from "../../../models/response/template.response";
|
||||||
|
import { SendResponse } from "../models/send.response";
|
||||||
|
|
||||||
|
export class SendTemplateCommand {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
run(type: string): Response {
|
||||||
|
let template: SendResponse | undefined;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "send.text":
|
||||||
|
case "text":
|
||||||
|
template = SendResponse.template(SendType.Text);
|
||||||
|
break;
|
||||||
|
case "send.file":
|
||||||
|
case "file":
|
||||||
|
template = SendResponse.template(SendType.File);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
response = Response.badRequest("Unknown template object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
response = Response.success(new TemplateResponse(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
response ??= Response.badRequest("An error occurred while retrieving the template.");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export class SendResponse implements BaseResponse {
|
|||||||
req.deletionDate = this.getStandardDeletionDate(deleteInDays);
|
req.deletionDate = this.getStandardDeletionDate(deleteInDays);
|
||||||
req.expirationDate = null;
|
req.expirationDate = null;
|
||||||
req.password = null;
|
req.password = null;
|
||||||
|
req.emails = null;
|
||||||
req.disabled = false;
|
req.disabled = false;
|
||||||
req.hideEmail = false;
|
req.hideEmail = false;
|
||||||
return req;
|
return req;
|
||||||
@@ -50,6 +51,7 @@ export class SendResponse implements BaseResponse {
|
|||||||
view.deletionDate = send.deletionDate;
|
view.deletionDate = send.deletionDate;
|
||||||
view.expirationDate = send.expirationDate;
|
view.expirationDate = send.expirationDate;
|
||||||
view.password = send.password;
|
view.password = send.password;
|
||||||
|
view.emails = send.emails ?? [];
|
||||||
view.disabled = send.disabled;
|
view.disabled = send.disabled;
|
||||||
view.hideEmail = send.hideEmail;
|
view.hideEmail = send.hideEmail;
|
||||||
return view;
|
return view;
|
||||||
@@ -87,6 +89,7 @@ export class SendResponse implements BaseResponse {
|
|||||||
expirationDate: Date;
|
expirationDate: Date;
|
||||||
password: string;
|
password: string;
|
||||||
passwordSet: boolean;
|
passwordSet: boolean;
|
||||||
|
emails?: Array<string>;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hideEmail: boolean;
|
hideEmail: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import * as chalk from "chalk";
|
import * as chalk from "chalk";
|
||||||
import { program, Command, OptionValues } from "commander";
|
import { program, Command, Option, OptionValues } from "commander";
|
||||||
|
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||||
|
|
||||||
import { BaseProgram } from "../../base-program";
|
import { BaseProgram } from "../../base-program";
|
||||||
import { GetCommand } from "../../commands/get.command";
|
|
||||||
import { Response } from "../../models/response";
|
import { Response } from "../../models/response";
|
||||||
import { CliUtils } from "../../utils";
|
import { CliUtils } from "../../utils";
|
||||||
|
|
||||||
@@ -22,10 +21,12 @@ import {
|
|||||||
SendListCommand,
|
SendListCommand,
|
||||||
SendReceiveCommand,
|
SendReceiveCommand,
|
||||||
SendRemovePasswordCommand,
|
SendRemovePasswordCommand,
|
||||||
|
SendTemplateCommand,
|
||||||
} from "./commands";
|
} from "./commands";
|
||||||
import { SendFileResponse } from "./models/send-file.response";
|
import { SendFileResponse } from "./models/send-file.response";
|
||||||
import { SendTextResponse } from "./models/send-text.response";
|
import { SendTextResponse } from "./models/send-text.response";
|
||||||
import { SendResponse } from "./models/send.response";
|
import { SendResponse } from "./models/send.response";
|
||||||
|
import { parseEmail } from "./util";
|
||||||
|
|
||||||
const writeLn = CliUtils.writeLn;
|
const writeLn = CliUtils.writeLn;
|
||||||
|
|
||||||
@@ -48,6 +49,17 @@ export class SendProgram extends BaseProgram {
|
|||||||
"The number of days in the future to set deletion date, defaults to 7",
|
"The number of days in the future to set deletion date, defaults to 7",
|
||||||
"7",
|
"7",
|
||||||
)
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--password <password>",
|
||||||
|
"optional password to access this Send. Can also be specified in JSON.",
|
||||||
|
).conflicts("email"),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--email <email>",
|
||||||
|
"optional emails to access this Send. Can also be specified in JSON.",
|
||||||
|
parseEmail,
|
||||||
|
)
|
||||||
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
|
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
|
||||||
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
|
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
|
||||||
.option(
|
.option(
|
||||||
@@ -139,26 +151,9 @@ export class SendProgram extends BaseProgram {
|
|||||||
return new Command("template")
|
return new Command("template")
|
||||||
.argument("<object>", "Valid objects are: send.text, send.file")
|
.argument("<object>", "Valid objects are: send.text, send.file")
|
||||||
.description("Get json templates for send objects")
|
.description("Get json templates for send objects")
|
||||||
.action(async (object) => {
|
.action((options: OptionValues) =>
|
||||||
const cmd = new GetCommand(
|
this.processResponse(new SendTemplateCommand().run(options.object)),
|
||||||
this.serviceContainer.cipherService,
|
);
|
||||||
this.serviceContainer.folderService,
|
|
||||||
this.serviceContainer.collectionService,
|
|
||||||
this.serviceContainer.totpService,
|
|
||||||
this.serviceContainer.auditService,
|
|
||||||
this.serviceContainer.keyService,
|
|
||||||
this.serviceContainer.encryptService,
|
|
||||||
this.serviceContainer.searchService,
|
|
||||||
this.serviceContainer.apiService,
|
|
||||||
this.serviceContainer.organizationService,
|
|
||||||
this.serviceContainer.eventCollectionService,
|
|
||||||
this.serviceContainer.billingAccountProfileStateService,
|
|
||||||
this.serviceContainer.accountService,
|
|
||||||
this.serviceContainer.cliRestrictedItemTypesService,
|
|
||||||
);
|
|
||||||
const response = await cmd.run("template", object, null);
|
|
||||||
this.processResponse(response);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCommand(): Command {
|
private getCommand(): Command {
|
||||||
@@ -208,10 +203,6 @@ export class SendProgram extends BaseProgram {
|
|||||||
.option("--file <path>", "file to Send. Can also be specified in parent's JSON.")
|
.option("--file <path>", "file to Send. Can also be specified in parent's JSON.")
|
||||||
.option("--text <text>", "text to Send. Can also be specified in parent's JSON.")
|
.option("--text <text>", "text to Send. Can also be specified in parent's JSON.")
|
||||||
.option("--hidden", "text hidden flag. Valid only with the --text option.")
|
.option("--hidden", "text hidden flag. Valid only with the --text option.")
|
||||||
.option(
|
|
||||||
"--password <password>",
|
|
||||||
"optional password to access this Send. Can also be specified in JSON",
|
|
||||||
)
|
|
||||||
.on("--help", () => {
|
.on("--help", () => {
|
||||||
writeLn("");
|
writeLn("");
|
||||||
writeLn("Note:");
|
writeLn("Note:");
|
||||||
@@ -219,13 +210,13 @@ export class SendProgram extends BaseProgram {
|
|||||||
writeLn("", true);
|
writeLn("", true);
|
||||||
})
|
})
|
||||||
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
|
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
|
||||||
// Work-around to support `--fullObject` option for `send create --fullObject`
|
// subcommands inherit flags from their parent; they cannot override them
|
||||||
// Calling `option('--fullObject', ...)` above won't work due to Commander doesn't like same option
|
const { fullObject = false, email = undefined, password = undefined } = args.parent.opts();
|
||||||
// to be defind on both parent-command and sub-command
|
|
||||||
const { fullObject = false } = args.parent.opts();
|
|
||||||
const mergedOptions = {
|
const mergedOptions = {
|
||||||
...options,
|
...options,
|
||||||
fullObject: fullObject,
|
fullObject: fullObject,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.runCreate(encodedJson, mergedOptions);
|
const response = await this.runCreate(encodedJson, mergedOptions);
|
||||||
@@ -247,7 +238,7 @@ export class SendProgram extends BaseProgram {
|
|||||||
writeLn(" You cannot update a File-type Send's file. Just delete and remake it");
|
writeLn(" You cannot update a File-type Send's file. Just delete and remake it");
|
||||||
writeLn("", true);
|
writeLn("", true);
|
||||||
})
|
})
|
||||||
.action(async (encodedJson: string, options: OptionValues) => {
|
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
|
||||||
await this.exitIfLocked();
|
await this.exitIfLocked();
|
||||||
const getCmd = new SendGetCommand(
|
const getCmd = new SendGetCommand(
|
||||||
this.serviceContainer.sendService,
|
this.serviceContainer.sendService,
|
||||||
@@ -264,7 +255,16 @@ export class SendProgram extends BaseProgram {
|
|||||||
this.serviceContainer.billingAccountProfileStateService,
|
this.serviceContainer.billingAccountProfileStateService,
|
||||||
this.serviceContainer.accountService,
|
this.serviceContainer.accountService,
|
||||||
);
|
);
|
||||||
const response = await cmd.run(encodedJson, options);
|
|
||||||
|
// subcommands inherit flags from their parent; they cannot override them
|
||||||
|
const { email = undefined, password = undefined } = args.parent.opts();
|
||||||
|
const mergedOptions = {
|
||||||
|
...options,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await cmd.run(encodedJson, mergedOptions);
|
||||||
this.processResponse(response);
|
this.processResponse(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
194
apps/cli/src/tools/send/util.spec.ts
Normal file
194
apps/cli/src/tools/send/util.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { parseEmail } from "./util";
|
||||||
|
|
||||||
|
describe("parseEmail", () => {
|
||||||
|
describe("single email address parsing", () => {
|
||||||
|
it("should parse a valid single email address", () => {
|
||||||
|
const result = parseEmail("test@example.com", []);
|
||||||
|
expect(result).toEqual(["test@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse email with dots in local part", () => {
|
||||||
|
const result = parseEmail("test.user@example.com", []);
|
||||||
|
expect(result).toEqual(["test.user@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse email with underscores and hyphens", () => {
|
||||||
|
const result = parseEmail("test_user-name@example.com", []);
|
||||||
|
expect(result).toEqual(["test_user-name@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse email with plus sign", () => {
|
||||||
|
const result = parseEmail("test+user@example.com", []);
|
||||||
|
expect(result).toEqual(["test+user@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse email with dots and hyphens in domain", () => {
|
||||||
|
const result = parseEmail("user@test-domain.co.uk", []);
|
||||||
|
expect(result).toEqual(["user@test-domain.co.uk"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add single email to existing previousInput array", () => {
|
||||||
|
const result = parseEmail("new@example.com", ["existing@test.com"]);
|
||||||
|
expect(result).toEqual(["existing@test.com", "new@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comma-separated email lists", () => {
|
||||||
|
it("should parse comma-separated email list", () => {
|
||||||
|
const result = parseEmail("test@example.com,user@domain.com", []);
|
||||||
|
expect(result).toEqual(["test@example.com", "user@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse comma-separated emails with spaces", () => {
|
||||||
|
const result = parseEmail("test@example.com, user@domain.com, admin@site.org", []);
|
||||||
|
expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine comma-separated emails with previousInput", () => {
|
||||||
|
const result = parseEmail("new1@example.com,new2@domain.com", ["existing@test.com"]);
|
||||||
|
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid email in comma-separated list", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("valid@example.com,invalid-email,another@domain.com", []);
|
||||||
|
}).toThrow("Invalid email address: invalid-email");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("space-separated email lists", () => {
|
||||||
|
it("should parse space-separated email list", () => {
|
||||||
|
const result = parseEmail("test@example.com user@domain.com", []);
|
||||||
|
expect(result).toEqual(["test@example.com", "user@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse space-separated emails with multiple spaces", () => {
|
||||||
|
const result = parseEmail("test@example.com user@domain.com admin@site.org", []);
|
||||||
|
expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine space-separated emails with previousInput", () => {
|
||||||
|
const result = parseEmail("new1@example.com new2@domain.com", ["existing@test.com"]);
|
||||||
|
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid email in space-separated list", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("valid@example.com invalid-email another@domain.com", []);
|
||||||
|
}).toThrow("Invalid email address: invalid-email");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON array input format", () => {
|
||||||
|
it("should parse valid JSON array of emails", () => {
|
||||||
|
const result = parseEmail('["test@example.com", "user@domain.com"]', []);
|
||||||
|
expect(result).toEqual(["test@example.com", "user@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse single email in JSON array", () => {
|
||||||
|
const result = parseEmail('["test@example.com"]', []);
|
||||||
|
expect(result).toEqual(["test@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse empty JSON array", () => {
|
||||||
|
const result = parseEmail("[]", []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine JSON array with previousInput", () => {
|
||||||
|
const result = parseEmail('["new1@example.com", "new2@domain.com"]', ["existing@test.com"]);
|
||||||
|
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for malformed JSON", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail('["test@example.com", "user@domain.com"', []);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for JSON that is not an array", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail('{"email": "test@example.com"}', []);
|
||||||
|
}).toThrow("Invalid email address:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for JSON string instead of array", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail('"test@example.com"', []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for JSON number instead of array", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("123", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("`previousInput` parameter handling", () => {
|
||||||
|
it("should handle undefined previousInput", () => {
|
||||||
|
const result = parseEmail("test@example.com", undefined as any);
|
||||||
|
expect(result).toEqual(["test@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null previousInput", () => {
|
||||||
|
const result = parseEmail("test@example.com", null as any);
|
||||||
|
expect(result).toEqual(["test@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve existing emails in previousInput", () => {
|
||||||
|
const existing = ["existing1@test.com", "existing2@test.com"];
|
||||||
|
const result = parseEmail("new@example.com", existing);
|
||||||
|
expect(result).toEqual(["existing1@test.com", "existing2@test.com", "new@example.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error cases and edge conditions", () => {
|
||||||
|
it("should throw error for empty string input", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for whitespace-only input", () => {
|
||||||
|
const result = parseEmail(" ", []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for invalid single email", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("invalid-email", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for email without @ symbol", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("testexample.com", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for email without domain", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("test@", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for email without local part", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("@example.com", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for input that looks like file path", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("/path/to/file.txt", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for input that looks like URL", () => {
|
||||||
|
expect(() => {
|
||||||
|
parseEmail("https://example.com", []);
|
||||||
|
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
apps/cli/src/tools/send/util.ts
Normal file
55
apps/cli/src/tools/send/util.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Parses email addresses from various input formats and combines them with previously parsed emails.
|
||||||
|
*
|
||||||
|
* Supports: single email, JSON array, comma-separated, or space-separated lists.
|
||||||
|
* Note: Function signature follows Commander.js option parsing pattern.
|
||||||
|
*
|
||||||
|
* @param input - Email input string in any supported format
|
||||||
|
* @param previousInput - Previously parsed email addresses to append to
|
||||||
|
* @returns Combined array of email addresses
|
||||||
|
* @throws {Error} For invalid JSON, non-array JSON, invalid email addresses, or unrecognized format
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseEmail("user@example.com", []) // ["user@example.com"]
|
||||||
|
* parseEmail('["user1@example.com", "user2@example.com"]', []) // ["user1@example.com", "user2@example.com"]
|
||||||
|
* parseEmail("user1@example.com, user2@example.com", []) // ["user1@example.com", "user2@example.com"]
|
||||||
|
*/
|
||||||
|
export function parseEmail(input: string, previousInput: string[]) {
|
||||||
|
let result = previousInput ?? [];
|
||||||
|
|
||||||
|
if (isEmail(input)) {
|
||||||
|
result.push(input);
|
||||||
|
} else if (input.startsWith("[")) {
|
||||||
|
const json = JSON.parse(input);
|
||||||
|
if (!Array.isArray(json)) {
|
||||||
|
throw new Error("invalid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.concat(json);
|
||||||
|
} else if (input.includes(",")) {
|
||||||
|
result = result.concat(parseList(input, ","));
|
||||||
|
} else if (input.includes(" ")) {
|
||||||
|
result = result.concat(parseList(input, " "));
|
||||||
|
} else {
|
||||||
|
throw new Error("`input` must be a single address, a comma-separated list, or a JSON array");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmail(input: string) {
|
||||||
|
return !!input && !!input.match(/^([\w._+-]+?)@([\w._+-]+?)$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(value: string, separator: string) {
|
||||||
|
const parts = value
|
||||||
|
.split(separator)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => !!v.length);
|
||||||
|
const invalid = parts.find((v) => !isEmail(v));
|
||||||
|
if (invalid) {
|
||||||
|
throw new Error(`Invalid email address: ${invalid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export class SendData {
|
|||||||
expirationDate: string;
|
expirationDate: string;
|
||||||
deletionDate: string;
|
deletionDate: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
emails: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hideEmail: boolean;
|
hideEmail: boolean;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export class SendData {
|
|||||||
this.expirationDate = response.expirationDate;
|
this.expirationDate = response.expirationDate;
|
||||||
this.deletionDate = response.deletionDate;
|
this.deletionDate = response.deletionDate;
|
||||||
this.password = response.password;
|
this.password = response.password;
|
||||||
|
this.emails = response.emails;
|
||||||
this.disabled = response.disable;
|
this.disabled = response.disable;
|
||||||
this.hideEmail = response.hideEmail;
|
this.hideEmail = response.hideEmail;
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,15 @@ describe("Send", () => {
|
|||||||
text: "encText",
|
text: "encText",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
file: null,
|
file: null!,
|
||||||
key: "encKey",
|
key: "encKey",
|
||||||
maxAccessCount: null,
|
maxAccessCount: null!,
|
||||||
accessCount: 10,
|
accessCount: 10,
|
||||||
revisionDate: "2022-01-31T12:00:00.000Z",
|
revisionDate: "2022-01-31T12:00:00.000Z",
|
||||||
expirationDate: "2022-01-31T12:00:00.000Z",
|
expirationDate: "2022-01-31T12:00:00.000Z",
|
||||||
deletionDate: "2022-01-31T12:00:00.000Z",
|
deletionDate: "2022-01-31T12:00:00.000Z",
|
||||||
password: "password",
|
password: "password",
|
||||||
|
emails: null!,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
hideEmail: true,
|
hideEmail: true,
|
||||||
};
|
};
|
||||||
@@ -86,6 +87,7 @@ describe("Send", () => {
|
|||||||
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||||
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
|
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||||
password: "password",
|
password: "password",
|
||||||
|
emails: null!,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
hideEmail: true,
|
hideEmail: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class Send extends Domain {
|
|||||||
expirationDate: Date;
|
expirationDate: Date;
|
||||||
deletionDate: Date;
|
deletionDate: Date;
|
||||||
password: string;
|
password: string;
|
||||||
|
emails: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hideEmail: boolean;
|
hideEmail: boolean;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export class Send extends Domain {
|
|||||||
this.maxAccessCount = obj.maxAccessCount;
|
this.maxAccessCount = obj.maxAccessCount;
|
||||||
this.accessCount = obj.accessCount;
|
this.accessCount = obj.accessCount;
|
||||||
this.password = obj.password;
|
this.password = obj.password;
|
||||||
|
this.emails = obj.emails;
|
||||||
this.disabled = obj.disabled;
|
this.disabled = obj.disabled;
|
||||||
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
|
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
|
||||||
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
|
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class SendRequest {
|
|||||||
text: SendTextApi;
|
text: SendTextApi;
|
||||||
file: SendFileApi;
|
file: SendFileApi;
|
||||||
password: string;
|
password: string;
|
||||||
|
emails: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hideEmail: boolean;
|
hideEmail: boolean;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export class SendRequest {
|
|||||||
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
|
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
|
||||||
this.key = send.key != null ? send.key.encryptedString : null;
|
this.key = send.key != null ? send.key.encryptedString : null;
|
||||||
this.password = send.password;
|
this.password = send.password;
|
||||||
|
this.emails = send.emails;
|
||||||
this.disabled = send.disabled;
|
this.disabled = send.disabled;
|
||||||
this.hideEmail = send.hideEmail;
|
this.hideEmail = send.hideEmail;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class SendResponse extends BaseResponse {
|
|||||||
expirationDate: string;
|
expirationDate: string;
|
||||||
deletionDate: string;
|
deletionDate: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
emails: string;
|
||||||
disable: boolean;
|
disable: boolean;
|
||||||
hideEmail: boolean;
|
hideEmail: boolean;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export class SendResponse extends BaseResponse {
|
|||||||
this.expirationDate = this.getResponseProperty("ExpirationDate");
|
this.expirationDate = this.getResponseProperty("ExpirationDate");
|
||||||
this.deletionDate = this.getResponseProperty("DeletionDate");
|
this.deletionDate = this.getResponseProperty("DeletionDate");
|
||||||
this.password = this.getResponseProperty("Password");
|
this.password = this.getResponseProperty("Password");
|
||||||
|
this.emails = this.getResponseProperty("Emails");
|
||||||
this.disable = this.getResponseProperty("Disabled") || false;
|
this.disable = this.getResponseProperty("Disabled") || false;
|
||||||
this.hideEmail = this.getResponseProperty("HideEmail") || false;
|
this.hideEmail = this.getResponseProperty("HideEmail") || false;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class SendView implements View {
|
|||||||
deletionDate: Date = null;
|
deletionDate: Date = null;
|
||||||
expirationDate: Date = null;
|
expirationDate: Date = null;
|
||||||
password: string = null;
|
password: string = null;
|
||||||
|
emails: string[] = [];
|
||||||
disabled = false;
|
disabled = false;
|
||||||
hideEmail = false;
|
hideEmail = false;
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,12 @@ export class SendService implements InternalSendServiceAbstraction {
|
|||||||
model.key = key.material;
|
model.key = key.material;
|
||||||
model.cryptoKey = key.derivedKey;
|
model.cryptoKey = key.derivedKey;
|
||||||
}
|
}
|
||||||
if (password != null) {
|
|
||||||
|
const hasEmails = (model.emails?.length ?? 0) > 0;
|
||||||
|
if (hasEmails) {
|
||||||
|
send.emails = model.emails.join(",");
|
||||||
|
send.password = null;
|
||||||
|
} else if (password != null) {
|
||||||
// Note: Despite being called key, the passwordKey is not used for encryption.
|
// Note: Despite being called key, the passwordKey is not used for encryption.
|
||||||
// It is used as a static proof that the client knows the password, and has the encryption key.
|
// It is used as a static proof that the client knows the password, and has the encryption key.
|
||||||
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
|
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
|
||||||
|
|||||||
Reference in New Issue
Block a user