diff --git a/src/command/upload.ts b/src/command/upload.ts index b6b20672..b01ea6f8 100644 --- a/src/command/upload.ts +++ b/src/command/upload.ts @@ -1,11 +1,14 @@ -import { RedundancyLevel, Reference, Tag, Utils } from '@ethersphere/bee-js' +import { FileUploadOptions, RedundancyLevel, Reference, Tag, Utils } from '@ethersphere/bee-js' import { Numbers, Optional, System } from 'cafe-utility' +import chalk from 'chalk' import { Presets, SingleBar } from 'cli-progress' import * as FS from 'fs' import { Argument, LeafCommand, Option } from 'furious-commander' import { join, parse } from 'path' import { exit } from 'process' import { setCurlStore } from '../curl' +import { AccessHistory } from '../service/access' +import { AccessHistoryOperation } from '../service/access/types/history-event' import { History } from '../service/history' import { pickStamp, printStamp } from '../service/stamp' import { fileExists, readStdin } from '../utils' @@ -14,7 +17,7 @@ import { getMime } from '../utils/mime' import { stampProperties } from '../utils/option' import { printQRCodeWithLabel } from '../utils/qr' import { createSpinner } from '../utils/spinner' -import { createKeyValue, warningSymbol, warningText } from '../utils/text' +import { createKeyValue, deprecationWarningText, warningSymbol, warningText } from '../utils/text' import { RootCommand } from './root-command' import { VerbosityLevel } from './root-command/command-log' @@ -56,6 +59,14 @@ export class Upload extends RootCommand implements LeafCommand { }) public act!: boolean + @Option({ + key: 'share-with', + type: 'string', + description: 'Name of the grantee list to share the uploaded content with', + conflicts: 'act', + }) + public shareWith!: string + @Option({ key: 'act-history-address', type: 'string', description: 'ACT history address' }) public optHistoryAddress!: string @@ -137,6 +148,14 @@ export class Upload extends RootCommand implements LeafCommand { public async run(usedFromOtherCommand = false): Promise { super.init() + if (this.act || this.optHistoryAddress) { + this.console.log( + deprecationWarningText( + '--act and --act-history-address options are deprecated and will be removed in future versions. Please use --share-with option instead.', + ), + ) + } + if (await this.hasUnsupportedGatewayOptions()) { exit(1) } @@ -179,7 +198,7 @@ export class Upload extends RootCommand implements LeafCommand { const swarmHash = this.result.getOrThrow().toHex() this.console.log(createKeyValue('Swarm hash', swarmHash)) - if (this.act) { + if (this.usingACT()) { this.console.log(createKeyValue('Swarm history address', this.historyAddress.getOrThrow().toHex())) } this.console.dim('Waiting for file chunks to be synced on Swarm network...') @@ -213,6 +232,11 @@ export class Upload extends RootCommand implements LeafCommand { if (this.qr) { printQRCodeWithLabel(url, 'QR for URL', this.console) } + + if (this.shareWith) { + this.addNewAccessHistoryEvent() + await this.printShareInstructions() + } } private async uploadAnyWithSpinner(tag: Tag | undefined, isFolder: boolean): Promise { @@ -248,35 +272,42 @@ export class Upload extends RootCommand implements LeafCommand { private async uploadStdin(tag?: Tag): Promise { if (this.fileName) { const contentType = this.contentType || getMime(this.fileName) || undefined - const { reference, historyAddress } = await this.bee.uploadFile(this.stamp, this.stdinData, this.fileName, { + let uploadOptions = { tag: tag && tag.uid, pin: this.pin, encrypt: this.encrypt, contentType, deferred: this.deferred, redundancyLevel: this.determineRedundancyLevel(), - act: this.act, - actHistoryAddress: this.optHistoryAddress, - }) + } as FileUploadOptions + uploadOptions = this.prepareACTUploadOptions(uploadOptions) + + const { reference, historyAddress } = await this.bee.uploadFile( + this.stamp, + this.stdinData, + this.fileName, + uploadOptions, + ) this.result = Optional.of(reference) - if (this.act) { + if (this.usingACT()) { this.historyAddress = historyAddress } return `${this.bee.url}/bzz/${reference.toHex()}/` } else { - const { reference, historyAddress } = await this.bee.uploadData(this.stamp, this.stdinData, { + let uploadOptions = { tag: tag?.uid, deferred: this.deferred, encrypt: this.encrypt, redundancyLevel: this.determineRedundancyLevel(), - act: this.act, - actHistoryAddress: this.optHistoryAddress, - }) + } as FileUploadOptions + uploadOptions = this.prepareACTUploadOptions(uploadOptions) + + const { reference, historyAddress } = await this.bee.uploadData(this.stamp, this.stdinData, uploadOptions) this.result = Optional.of(reference) - if (this.act) { + if (this.usingACT()) { this.historyAddress = historyAddress } @@ -290,7 +321,7 @@ export class Upload extends RootCommand implements LeafCommand { folder: true, type: 'buffer', }) - const { reference, historyAddress } = await this.bee.uploadFilesFromDirectory(this.stamp, this.path, { + let uploadOptions = { indexDocument: this.indexDocument, errorDocument: this.errorDocument, tag: tag && tag.uid, @@ -298,12 +329,12 @@ export class Upload extends RootCommand implements LeafCommand { encrypt: this.encrypt, deferred: this.deferred, redundancyLevel: this.determineRedundancyLevel(), - act: this.act, - actHistoryAddress: this.optHistoryAddress, - }) + } as FileUploadOptions + uploadOptions = this.prepareACTUploadOptions(uploadOptions) + const { reference, historyAddress } = await this.bee.uploadFilesFromDirectory(this.stamp, this.path, uploadOptions) this.result = Optional.of(reference) - if (this.act) { + if (this.usingACT()) { this.historyAddress = historyAddress } @@ -319,24 +350,24 @@ export class Upload extends RootCommand implements LeafCommand { }) const readable = FS.createReadStream(this.path) const parsedPath = parse(this.path) + let uploadOptions = { + tag: tag && tag.uid, + pin: this.pin, + encrypt: this.encrypt, + contentType, + deferred: this.deferred, + redundancyLevel: this.determineRedundancyLevel(), + } as FileUploadOptions + uploadOptions = this.prepareACTUploadOptions(uploadOptions) const { reference, historyAddress } = await this.bee.uploadFile( this.stamp, readable, this.determineFileName(parsedPath.base), - { - tag: tag && tag.uid, - pin: this.pin, - encrypt: this.encrypt, - contentType, - deferred: this.deferred, - redundancyLevel: this.determineRedundancyLevel(), - act: this.act, - actHistoryAddress: this.optHistoryAddress, - }, + uploadOptions, ) this.result = Optional.of(reference) - if (this.act) { + if (this.usingACT()) { this.historyAddress = historyAddress } @@ -450,7 +481,7 @@ export class Upload extends RootCommand implements LeafCommand { return false } - if (this.act) { + if (this.usingACT()) { this.console.error('You are trying to upload to the gateway which does not support ACT.') this.console.error('Please try again without the --act option.') @@ -551,4 +582,66 @@ export class Upload extends RootCommand implements LeafCommand { return 'file' } } + + private usingACT(): boolean { + return this.act || Boolean(this.shareWith) + } + + private addNewAccessHistoryEvent() { + const accessHistory = new AccessHistory(this.commandConfig, this.console) + const lastHistoryEvent = accessHistory.getLatestEvent(this.shareWith) + + if (!lastHistoryEvent) { + this.console.error(`Grantee list with name '${this.shareWith}' does not exist!`) + exit(1) + } + accessHistory.addEvent(this.shareWith, { + stampId: this.stamp, + historyAddress: this.historyAddress.getOrThrow().toHex(), + granteeListRef: lastHistoryEvent.granteeListRef, + operation: AccessHistoryOperation.Upload, + createdAt: Date.now(), + }) + } + + private async printShareInstructions() { + const { publicKey } = await this.bee.getNodeAddresses() + this.console.log( + '\nTo share the uploaded content with your grantees, please provide them with the following information:\n', + ) + const token = `${publicKey}:${this.historyAddress.getOrThrow().toHex()}` + this.console.log(chalk.bold(token)) + this.console.log( + '\nThey can use this information, when using the download command, providing it in the --access option.', + ) + this.console.log('Example:\n') + this.console.log(chalk.bold(`> swarm-cli download ${this.result.getOrThrow().toHex()} --access ${token}\n`)) + } + + private prepareACTUploadOptions(uploadOptions: FileUploadOptions): FileUploadOptions { + const options = { ...uploadOptions } + + if (this.act) { + options.act = this.act + + if (this.optHistoryAddress) { + options.actHistoryAddress = this.optHistoryAddress + } + } + + if (this.shareWith) { + const accessHistory = new AccessHistory(this.commandConfig, this.console) + const lastHistoryEvent = accessHistory.getLatestEvent(this.shareWith) + + if (!lastHistoryEvent) { + this.console.error(`Grantee list with name '${this.shareWith}' does not exist!`) + exit(1) + } + + options.act = true + options.actHistoryAddress = lastHistoryEvent.historyAddress + } + + return options + } } diff --git a/src/service/access/index.ts b/src/service/access/index.ts index 548ce7b6..5979be67 100644 --- a/src/service/access/index.ts +++ b/src/service/access/index.ts @@ -41,6 +41,12 @@ export class AccessHistory { return history[granteeListName] } + public getLatestEvent(granteeListName: string): AccessHistoryEvent | null { + const events = this.getEvents(granteeListName).sort((a, b) => b.createdAt - a.createdAt) + + return events.length > 0 ? events[0] : null + } + public getEventsByType(granteeListName: string, eventType: AccessHistoryOperation): AccessHistoryEvent[] { return this.getEvents(granteeListName).filter(event => event.operation === eventType) } diff --git a/src/service/access/types/history-event.ts b/src/service/access/types/history-event.ts index 8e1fca29..bc85f664 100644 --- a/src/service/access/types/history-event.ts +++ b/src/service/access/types/history-event.ts @@ -2,6 +2,7 @@ export enum AccessHistoryOperation { Init = 'init', Grant = 'grant', Revoke = 'revoke', + Upload = 'upload', } export type AccessHistoryEvent = { diff --git a/src/utils/text.ts b/src/utils/text.ts index 0fc46f50..1db4bc1b 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -21,6 +21,10 @@ export function warningText(string: string): string { return chalk.yellow(string) } +export function deprecationWarningText(string: string): string { + return chalk.yellow(`DEPRECATED: ${string}`) +} + export function errorText(string: string): string { return chalk.red(string) } diff --git a/test/command/upload.spec.ts b/test/command/upload.spec.ts index 427cd239..aa1292b5 100644 --- a/test/command/upload.spec.ts +++ b/test/command/upload.spec.ts @@ -1,3 +1,4 @@ +import { System } from 'cafe-utility' import { existsSync, unlinkSync, writeFileSync } from 'fs' import { LeafCommand } from 'furious-commander' import type { Upload } from '../../src/command/upload' @@ -25,162 +26,193 @@ function actUpload(command: { runnable?: LeafCommand | undefined }): [string, st return [ref, his] } -describeCommand('Test Upload command', ({ consoleMessages, hasMessageContaining, getLastMessage }) => { - if (existsSync('test/data/8mb.bin')) { - unlinkSync('test/data/8mb.bin') - } - - writeFileSync('test/data/8mb.bin', Buffer.alloc(8_000_000)) - - it('should upload testpage folder', async () => { - const commandKey = 'upload' - const uploadFolderPath = `${__dirname}/../testpage` - const commandBuilder = await invokeTestCli([commandKey, uploadFolderPath, ...getStampOption()]) - - expect(commandBuilder.initedCommands[0].command.name).toBe('upload') - const command = commandBuilder.initedCommands[0].command as Upload - expect(command.result.getOrThrow().toHex().length).toBe(64) - }) - - it('should upload file', async () => { - const commandKey = 'upload' - const uploadFolderPath = `${__dirname}/../testpage/images/swarm.png` - const commandBuilder = await invokeTestCli([commandKey, uploadFolderPath, ...getStampOption()]) - - expect(commandBuilder.initedCommands[0].command.name).toBe('upload') - const command = commandBuilder.initedCommands[0].command as Upload - expect(command.result.getOrThrow().toHex().length).toBe(64) - }) - - it('should upload file and encrypt', async () => { - const commandBuilder = await invokeTestCli(['upload', 'README.md', '--encrypt', ...getStampOption()]) - const uploadCommand = commandBuilder.runnable as Upload - expect(uploadCommand.result.getOrThrow().toHex()).toHaveLength(128) - }) - - it('should upload file with act', async () => { - const commandBuilder = await invokeTestCli(['upload', 'README.md', '--act', ...getStampOption()]) - const [ref, his] = actUpload(commandBuilder) - expect(ref).toHaveLength(64) - expect(his).toHaveLength(64) - }) - - it('should upload file with act and history', async () => { - const commandBuilder1 = await invokeTestCli(['upload', 'README.md', '--act', ...getStampOption()]) - const [ref1, his1] = actUpload(commandBuilder1) - expect(ref1).toHaveLength(64) - expect(his1).toHaveLength(64) - - // Upload same file with the same history address - const commandBuilder2 = await invokeTestCli([ - 'upload', - 'README.md', - '--act', - '--act-history-address', - his1, - ...getStampOption(), - ]) - const [ref2, his2] = actUpload(commandBuilder2) - expect(ref2).toHaveLength(64) - expect(his2).toHaveLength(64) - expect(ref1).toBe(ref2) // Same reference - expect(his1).toBe(his2) // Same history address - - // Upload another file with the same history address - const commandBuilder3 = await invokeTestCli([ - 'upload', - 'test/message.txt', - '--act', - '--act-history-address', - his1, - ...getStampOption(), - ]) - const [ref3, his3] = actUpload(commandBuilder3) - expect(ref3).toHaveLength(64) - expect(his3).toHaveLength(64) - expect(ref1).not.toBe(ref3) // Not same reference - expect(his1).toBe(his3) // Same history address - }) - - it('should upload folder and encrypt', async () => { - const commandBuilder = await invokeTestCli(['upload', 'test/testpage', '--encrypt', ...getStampOption()]) - const uploadCommand = commandBuilder.runnable as Upload - expect(uploadCommand.result.getOrThrow().toHex()).toHaveLength(128) - }) - - it('should not allow --encrypt for gateways', async () => { - await invokeTestCli([ - 'upload', - 'README.md', - '--bee-api-url', - 'https://api.gateway.ethswarm.org', - '--encrypt', - ...getStampOption(), - ]) - expect(hasMessageContaining('does not support encryption')).toBeTruthy() - }) - - it('should not allow --pin for gateways', async () => { - await invokeTestCli([ - 'upload', - 'README.md', - '--bee-api-url', - 'https://api.gateway.ethswarm.org', - '--pin', - ...getStampOption(), - ]) - expect(hasMessageContaining('does not support pinning')).toBeTruthy() - }) - - it('should not allow sync for gateways', async () => { - await invokeTestCli([ - 'upload', - 'README.md', - '--sync', - '--bee-api-url', - 'https://api.gateway.ethswarm.org', - '--encrypt', - ...getStampOption(), - ]) - expect(hasMessageContaining('does not support syncing')).toBeTruthy() - }) - - it('should succeed with --sync <1MB', async () => { - await invokeTestCli(['upload', 'README.md', '--sync', '-v', ...getStampOption()]) - expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) - }) - - it('should succeed with --sync >1MB', async () => { - await invokeTestCli(['upload', 'docs/stamp-buy.gif', '--sync', '-v', ...getStampOption()]) - expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) - }) - - it('should succeed with --sync and --encrypt <1MB', async () => { - await invokeTestCli(['upload', 'README.md', '--sync', '--encrypt', '-v', ...getStampOption()]) - expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) - }) - - it('should succeed with --sync and --encrypt >1MB', async () => { - await invokeTestCli(['upload', 'docs/stamp-buy.gif', '--sync', '--encrypt', '-v', ...getStampOption()]) - expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) - }) - - it('should not print double trailing slashes', async () => { - await invokeTestCli(['upload', 'README.md', '--bee-api-url', 'http://localhost:1633/', ...getStampOption()]) - expect(hasMessageContaining(':1633/bzz')).toBeTruthy() - expect(hasMessageContaining('//bzz')).toBeFalsy() - }) - - it('should be able to upload text file', async () => { - await invokeTestCli(['upload', 'test/message.txt', ...getStampOption()]) - expect(consoleMessages[0]).toContain('Swarm hash') - }) - - describe('when --qr flag provided', () => { - it('should print QR code to the console', async () => { - await invokeTestCli(['upload', 'test/message.txt', '--qr', ...getStampOption()]) - expect(hasMessageContaining('QR for URL:')).toBeTruthy() - expect(getLastMessage()).toBeQRCode() - }) - }) -}) +describeCommand( + 'Test Upload command', + ({ consoleMessages, hasMessageContaining, getLastMessage }) => { + if (existsSync('test/data/8mb.bin')) { + unlinkSync('test/data/8mb.bin') + } + + writeFileSync('test/data/8mb.bin', Buffer.alloc(8_000_000)) + + it('should upload testpage folder', async () => { + const commandKey = 'upload' + const uploadFolderPath = `${__dirname}/../testpage` + const commandBuilder = await invokeTestCli([commandKey, uploadFolderPath, ...getStampOption()]) + + expect(commandBuilder.initedCommands[0].command.name).toBe('upload') + const command = commandBuilder.initedCommands[0].command as Upload + expect(command.result.getOrThrow().toHex().length).toBe(64) + }) + + it('should upload file', async () => { + const commandKey = 'upload' + const uploadFolderPath = `${__dirname}/../testpage/images/swarm.png` + const commandBuilder = await invokeTestCli([commandKey, uploadFolderPath, ...getStampOption()]) + + expect(commandBuilder.initedCommands[0].command.name).toBe('upload') + const command = commandBuilder.initedCommands[0].command as Upload + expect(command.result.getOrThrow().toHex().length).toBe(64) + }) + + it('should upload file and encrypt', async () => { + const commandBuilder = await invokeTestCli(['upload', 'README.md', '--encrypt', ...getStampOption()]) + const uploadCommand = commandBuilder.runnable as Upload + expect(uploadCommand.result.getOrThrow().toHex()).toHaveLength(128) + }) + + describe('when --act flag provided', () => { + it('should upload file with act', async () => { + const commandBuilder = await invokeTestCli(['upload', 'README.md', '--act', ...getStampOption()]) + const [ref, his] = actUpload(commandBuilder) + expect(ref).toHaveLength(64) + expect(his).toHaveLength(64) + }) + + it('should upload file with act and history', async () => { + const commandBuilder1 = await invokeTestCli(['upload', 'README.md', '--act', ...getStampOption()]) + const [ref1, his1] = actUpload(commandBuilder1) + expect(ref1).toHaveLength(64) + expect(his1).toHaveLength(64) + + // Upload same file with the same history address + const commandBuilder2 = await invokeTestCli([ + 'upload', + 'README.md', + '--act', + '--act-history-address', + his1, + ...getStampOption(), + ]) + const [ref2, his2] = actUpload(commandBuilder2) + expect(ref2).toHaveLength(64) + expect(his2).toHaveLength(64) + expect(ref1).toBe(ref2) // Same reference + expect(his1).toBe(his2) // Same history address + + // Upload another file with the same history address + const commandBuilder3 = await invokeTestCli([ + 'upload', + 'test/message.txt', + '--act', + '--act-history-address', + his1, + ...getStampOption(), + ]) + const [ref3, his3] = actUpload(commandBuilder3) + expect(ref3).toHaveLength(64) + expect(his3).toHaveLength(64) + expect(ref1).not.toBe(ref3) // Not same reference + expect(his1).toBe(his3) // Same history address + }) + }) + + describe('when --share-with flag provided', () => { + afterEach(() => { + const historyFilePath = `${__dirname}/../testconfig/upload-access-history.json` + + if (existsSync(historyFilePath)) { + unlinkSync(historyFilePath) + } + }) + it('should upload file and share with the provided grantee list', async () => { + await invokeTestCli(['access', 'init', ...getStampOption(), '--list-name', 'test-share-with']) + await System.sleepMillis(1000) + const commandBuilder = await invokeTestCli([ + 'upload', + 'README.md', + '--share-with', + 'test-share-with', + ...getStampOption(), + ]) + + const [ref, his] = actUpload(commandBuilder) + expect(ref).toHaveLength(64) + expect(his).toHaveLength(64) + }) + }) + + it('should upload folder and encrypt', async () => { + const commandBuilder = await invokeTestCli(['upload', 'test/testpage', '--encrypt', ...getStampOption()]) + const uploadCommand = commandBuilder.runnable as Upload + expect(uploadCommand.result.getOrThrow().toHex()).toHaveLength(128) + }) + + it('should not allow --encrypt for gateways', async () => { + await invokeTestCli([ + 'upload', + 'README.md', + '--bee-api-url', + 'https://api.gateway.ethswarm.org', + '--encrypt', + ...getStampOption(), + ]) + expect(hasMessageContaining('does not support encryption')).toBeTruthy() + }) + + it('should not allow --pin for gateways', async () => { + await invokeTestCli([ + 'upload', + 'README.md', + '--bee-api-url', + 'https://api.gateway.ethswarm.org', + '--pin', + ...getStampOption(), + ]) + expect(hasMessageContaining('does not support pinning')).toBeTruthy() + }) + + it('should not allow sync for gateways', async () => { + await invokeTestCli([ + 'upload', + 'README.md', + '--sync', + '--bee-api-url', + 'https://api.gateway.ethswarm.org', + '--encrypt', + ...getStampOption(), + ]) + expect(hasMessageContaining('does not support syncing')).toBeTruthy() + }) + + it('should succeed with --sync <1MB', async () => { + await invokeTestCli(['upload', 'README.md', '--sync', '-v', ...getStampOption()]) + expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) + }) + + it('should succeed with --sync >1MB', async () => { + await invokeTestCli(['upload', 'docs/stamp-buy.gif', '--sync', '-v', ...getStampOption()]) + expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) + }) + + it('should succeed with --sync and --encrypt <1MB', async () => { + await invokeTestCli(['upload', 'README.md', '--sync', '--encrypt', '-v', ...getStampOption()]) + expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) + }) + + it('should succeed with --sync and --encrypt >1MB', async () => { + await invokeTestCli(['upload', 'docs/stamp-buy.gif', '--sync', '--encrypt', '-v', ...getStampOption()]) + expect(consoleMessages).toMatchLinesInOrder(SUCCESSFUL_SYNC_PATTERN) + }) + + it('should not print double trailing slashes', async () => { + await invokeTestCli(['upload', 'README.md', '--bee-api-url', 'http://localhost:1633/', ...getStampOption()]) + expect(hasMessageContaining(':1633/bzz')).toBeTruthy() + expect(hasMessageContaining('//bzz')).toBeFalsy() + }) + + it('should be able to upload text file', async () => { + await invokeTestCli(['upload', 'test/message.txt', ...getStampOption()]) + expect(consoleMessages[0]).toContain('Swarm hash') + }) + + describe('when --qr flag provided', () => { + it('should print QR code to the console', async () => { + await invokeTestCli(['upload', 'test/message.txt', '--qr', ...getStampOption()]) + expect(hasMessageContaining('QR for URL:')).toBeTruthy() + expect(getLastMessage()).toBeQRCode() + }) + }) + }, + { configFileName: 'upload' }, +)