@ethersphere/swarm-cli
Version:
CLI tool for Bee
516 lines (428 loc) • 15.2 kB
text/typescript
import { RedundancyLevel, Reference, Tag, Utils } from '@ethersphere/bee-js'
import { Numbers, Optional, System } from 'cafe-utility'
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 { pickStamp, printStamp } from '../service/stamp'
import { fileExists, readStdin } from '../utils'
import { CommandLineError } from '../utils/error'
import { getMime } from '../utils/mime'
import { stampProperties } from '../utils/option'
import { createSpinner } from '../utils/spinner'
import { createKeyValue, warningSymbol, warningText } from '../utils/text'
import { RootCommand } from './root-command'
import { VerbosityLevel } from './root-command/command-log'
export class Upload extends RootCommand implements LeafCommand {
public readonly name = 'upload'
public readonly alias = 'up'
public readonly description = 'Upload file to Swarm'
({
key: 'path',
description: 'Path to the file or folder',
required: true,
autocompletePath: true,
conflicts: 'stdin',
})
public path!: string
({ key: 'stdin', type: 'boolean', description: 'Take data from standard input', conflicts: 'path' })
public stdin!: boolean
(stampProperties)
public stamp!: string
({ key: 'pin', type: 'boolean', description: 'Persist the uploaded data on the node' })
public pin!: boolean
({ key: 'encrypt', type: 'boolean', description: 'Encrypt uploaded data' })
public encrypt!: boolean
({ key: 'deferred', type: 'boolean', description: 'Do not wait for network sync', default: true })
public deferred!: boolean
({
key: 'act',
type: 'boolean',
description: 'Upload with ACT',
default: false,
required: { when: 'act-history-address' },
})
public act!: boolean
({ key: 'act-history-address', type: 'string', description: 'ACT history address' })
public optHistoryAddress!: string
({
key: 'sync',
type: 'boolean',
description: 'Wait for chunk synchronization over the network',
})
public sync!: boolean
({
key: 'drop-name',
type: 'boolean',
description: 'Erase file name when uploading a single file',
conflicts: 'name',
})
public dropName!: boolean
({
key: 'sync-polling-time',
description: 'Waiting time in ms between sync pollings',
type: 'number',
default: 1000,
})
public syncPollingTime!: number
({
key: 'sync-polling-trials',
description: 'After the given trials the sync polling will stop',
type: 'number',
default: 60,
})
public syncPollingTrials!: number
({
key: 'index-document',
description: 'Default retrieval file on bzz request without provided filepath',
})
public indexDocument!: string
({
key: 'error-document',
description: 'Default error file on bzz request without with wrong filepath',
})
public errorDocument!: string
({
key: 'name',
description: 'Set the name of the uploaded file',
conflicts: 'drop-name',
})
public fileName!: string
({
key: 'content-type',
description: 'Content type when uploading a single file',
})
public contentType!: string
({
key: 'redundancy',
description: 'Redundancy of the upload (MEDIUM, STRONG, INSANE, PARANOID)',
})
public redundancy!: string
public stdinData!: Buffer
public historyAddress: Optional<Reference> = Optional.empty()
// eslint-disable-next-line complexity
public async run(usedFromOtherCommand = false): Promise<void> {
super.init()
if (await this.hasUnsupportedGatewayOptions()) {
exit(1)
}
await this.maybePrintSyncWarning()
if (!this.stdin && !FS.existsSync(this.path)) {
throw new CommandLineError(`Given filepath '${this.path}' doesn't exist`)
}
if (this.stdin) {
if (!this.stamp) {
throw new CommandLineError('Stamp must be passed when reading data from stdin')
}
this.stdinData = await readStdin(this.console)
}
if (!this.stamp) {
if (await this.bee.isGateway()) {
this.stamp = '0'.repeat(64)
} else {
this.stamp = await pickStamp(this.bee, this.console)
}
}
await this.maybePrintRedundancyStats()
const tag = this.sync ? await this.bee.createTag() : undefined
const uploadingFolder = !this.stdin && FS.statSync(this.path).isDirectory()
if (uploadingFolder && !this.indexDocument && fileExists(join(this.path, 'index.html'))) {
this.console.info('Setting --index-document to index.html')
this.indexDocument = 'index.html'
}
const url = await this.uploadAnyWithSpinner(tag, uploadingFolder)
this.console.dim('Data has been sent to the Bee node successfully!')
this.console.log(createKeyValue('Swarm hash', this.result.getOrThrow().toHex()))
if (this.act) {
this.console.log(createKeyValue('Swarm history address', this.historyAddress.getOrThrow().toHex()))
}
this.console.dim('Waiting for file chunks to be synced on Swarm network...')
if (this.sync && tag) {
await this.waitForFileSynced(tag)
}
this.console.dim('Uploading was successful!')
this.console.log(createKeyValue('URL', url))
if (!usedFromOtherCommand) {
this.console.quiet(this.result.getOrThrow().toHex())
if (!(await this.bee.isGateway()) && !this.quiet) {
printStamp(await this.bee.getPostageBatch(this.stamp), this.console, { shortenBatchId: true })
}
}
}
private async uploadAnyWithSpinner(tag: Tag | undefined, isFolder: boolean): Promise<string> {
const spinner = createSpinner(this.path ? `Uploading ${this.path}...` : 'Uploading data from stdin...')
if (this.verbosity !== VerbosityLevel.Quiet && !this.curl) {
spinner.start()
}
try {
const url = await this.uploadAny(tag, isFolder)
return url
} finally {
if (spinner.isSpinning) {
spinner.stop()
}
}
}
private uploadAny(tag: Tag | undefined, isFolder: boolean): Promise<string> {
if (this.stdin) {
return this.uploadStdin(tag)
} else {
if (isFolder) {
return this.uploadFolder(tag)
} else {
return this.uploadSingleFile(tag)
}
}
}
private async uploadStdin(tag?: Tag): Promise<string> {
if (this.fileName) {
const contentType = this.contentType || getMime(this.fileName) || undefined
const { reference, historyAddress } = await this.bee.uploadFile(this.stamp, this.stdinData, this.fileName, {
tag: tag && tag.uid,
pin: this.pin,
encrypt: this.encrypt,
contentType,
deferred: this.deferred,
redundancyLevel: this.determineRedundancyLevel(),
act: this.act,
actHistoryAddress: this.optHistoryAddress,
})
this.result = Optional.of(reference)
if (this.act) {
this.historyAddress = historyAddress
}
return `${this.bee.url}/bzz/${reference.toHex()}/`
} else {
const { reference, historyAddress } = await this.bee.uploadData(this.stamp, this.stdinData, {
tag: tag?.uid,
deferred: this.deferred,
encrypt: this.encrypt,
redundancyLevel: this.determineRedundancyLevel(),
act: this.act,
actHistoryAddress: this.optHistoryAddress,
})
this.result = Optional.of(reference)
if (this.act) {
this.historyAddress = historyAddress
}
return `${this.bee.url}/bytes/${reference.toHex()}`
}
}
private async uploadFolder(tag?: Tag): Promise<string> {
setCurlStore({
path: this.path,
folder: true,
type: 'buffer',
})
const { reference, historyAddress } = await this.bee.uploadFilesFromDirectory(this.stamp, this.path, {
indexDocument: this.indexDocument,
errorDocument: this.errorDocument,
tag: tag && tag.uid,
pin: this.pin,
encrypt: this.encrypt,
deferred: this.deferred,
redundancyLevel: this.determineRedundancyLevel(),
act: this.act,
actHistoryAddress: this.optHistoryAddress,
})
this.result = Optional.of(reference)
if (this.act) {
this.historyAddress = historyAddress
}
return `${this.bee.url}/bzz/${reference.toHex()}/`
}
private async uploadSingleFile(tag?: Tag): Promise<string> {
const contentType = this.contentType || getMime(this.path) || undefined
setCurlStore({
path: this.path,
folder: false,
type: 'stream',
})
const readable = FS.createReadStream(this.path)
const parsedPath = parse(this.path)
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,
},
)
this.result = Optional.of(reference)
if (this.act) {
this.historyAddress = historyAddress
}
return `${this.bee.url}/bzz/${reference.toHex()}/`
}
/**
* Waits until the data syncing is successful on Swarm network
*
* @param tag had to be attached to the uploaded file
*
* @returns whether the file sync was successful or not.
*/
private async waitForFileSynced(tag: Tag): Promise<void | never> {
const tagUid = tag.uid
const pollingTime = this.syncPollingTime
const pollingTrials = this.syncPollingTrials
let synced = false
let syncProgress = 0
const progressBar = new SingleBar({ clearOnComplete: true }, Presets.rect)
if (this.verbosity !== VerbosityLevel.Quiet && !this.curl) {
progressBar.start(tag.split, 0)
}
for (let i = 0; i < pollingTrials; i++) {
tag = await this.bee.retrieveTag(tagUid)
const newSyncProgress = tag.seen + tag.synced
if (newSyncProgress > syncProgress) {
i = 0
}
syncProgress = newSyncProgress
if (syncProgress >= tag.split) {
synced = true
break
}
if (this.curl) {
this.console.log(`${syncProgress} / ${tag.split}`)
} else {
progressBar.setTotal(tag.split)
progressBar.update(syncProgress)
}
await System.sleepMillis(pollingTime)
}
progressBar.stop()
if (synced) {
this.console.dim('Data has been synced on Swarm network')
} else {
this.console.error(
this.path
? `Data syncing timeout for ${this.path} (${syncProgress} / ${tag.split})`
: `Data syncing timeout (${syncProgress} / ${tag.split})`,
)
exit(1)
}
}
private async maybePrintRedundancyStats(): Promise<void> {
if (!this.redundancy || this.quiet) {
return
}
const currentSetting = Utils.getRedundancyStat(this.redundancy)
const originalSize = await this.getUploadSize()
const originalChunks = Math.ceil(originalSize / 4e3)
const sizeMultiplier = Utils.approximateOverheadForRedundancyLevel(
originalChunks,
currentSetting.value,
this.encrypt,
)
const newSize = originalChunks * 4e3 * (1 + sizeMultiplier)
const extraSize = newSize - originalSize
this.console.log(createKeyValue('Redundancy setting', currentSetting.label))
this.console.log(`This setting will provide ${Math.round(currentSetting.errorTolerance * 100)}% error tolerance.`)
this.console.log(`An additional ${Numbers.convertBytes(extraSize)} of data will be uploaded approximately.`)
this.console.log(
`${Numbers.convertBytes(originalSize)} → ${Numbers.convertBytes(newSize)} (+${Numbers.convertBytes(extraSize)})`,
)
if (!this.yes && !this.quiet) {
const confirmation = await this.console.confirm('Do you want to proceed?')
if (!confirmation) {
exit(0)
}
}
}
private async getUploadSize(): Promise<number> {
let size = -1
if (this.stdin) {
size = this.stdinData.length
} else {
const stats = FS.lstatSync(this.path)
size = stats.isDirectory() ? await Utils.getFolderSize(this.path) : stats.size
}
this.console.verbose(`Upload size is approximately ${Numbers.convertBytes(size)}`)
return size
}
private async hasUnsupportedGatewayOptions(): Promise<boolean> {
if (!(await this.bee.isGateway())) {
return false
}
if (this.act) {
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.')
return true
}
if (this.pin) {
this.console.error('You are trying to upload to the gateway which does not support pinning.')
this.console.error('Please try again without the --pin option.')
return true
}
if (this.sync) {
this.console.error('You are trying to upload to the gateway which does not support syncing.')
this.console.error('Please try again without the --sync option.')
return true
}
if (this.encrypt) {
this.console.error('You are trying to upload to the gateway which does not support encryption.')
this.console.error('Please try again without the --encrypt option.')
return true
}
return false
}
private async maybePrintSyncWarning(): Promise<void> {
if (this.quiet || !this.sync) {
return
}
const connectedPeers = await this.getConnectedPeers()
if (connectedPeers === null) {
this.console.log(warningSymbol())
this.console.log(warningText('Could not fetch connected peers info.'))
this.console.log(warningText('Are you uploading to a gateway node?'))
this.console.log(warningText('Synchronization may time out.'))
} else if (connectedPeers === 0) {
this.console.log(warningSymbol())
this.console.log(warningText('Your Bee node has no connected peers.'))
this.console.log(warningText('Synchronization may time out.'))
}
}
private async getConnectedPeers(): Promise<number | null> {
try {
const { connected } = await this.bee.getTopology()
return connected
} catch {
return null
}
}
private determineFileName(defaultName: string): string | undefined {
if (this.dropName) {
return undefined
}
if (this.fileName) {
return this.fileName
}
return defaultName
}
private determineRedundancyLevel(): RedundancyLevel | undefined {
if (!this.redundancy) {
return undefined
}
switch (this.redundancy.toUpperCase()) {
case 'MEDIUM':
return RedundancyLevel.MEDIUM
case 'STRONG':
return RedundancyLevel.STRONG
case 'INSANE':
return RedundancyLevel.INSANE
case 'PARANOID':
return RedundancyLevel.PARANOID
default:
throw new CommandLineError(`Invalid redundancy level: ${this.redundancy}`)
}
}
}