From a397884d3c6d1ee413e14f193a318be192e1cb39 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sat, 12 Jun 2021 01:18:07 +0200 Subject: [PATCH] added experimental SFTP implementation - fixes #296 --- terminus-core/src/api/platform.ts | 48 +++++ .../src/directives/dropZone.directive.pug | 1 + .../src/directives/dropZone.directive.scss | 24 +++ .../src/directives/dropZone.directive.ts | 49 +++++ terminus-core/src/index.ts | 3 + terminus-core/src/theme.vars.scss | 4 +- terminus-ssh/package.json | 1 + terminus-ssh/src/api.ts | 15 +- .../src/components/sftpPanel.component.pug | 33 +++ .../src/components/sftpPanel.component.scss | 40 ++++ .../src/components/sftpPanel.component.ts | 196 ++++++++++++++++++ .../src/components/sshTab.component.pug | 10 + .../src/components/sshTab.component.scss | 8 + .../src/components/sshTab.component.ts | 14 +- terminus-ssh/src/index.ts | 2 + terminus-ssh/yarn.lock | 20 ++ 16 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 terminus-core/src/directives/dropZone.directive.pug create mode 100644 terminus-core/src/directives/dropZone.directive.scss create mode 100644 terminus-core/src/directives/dropZone.directive.ts create mode 100644 terminus-ssh/src/components/sftpPanel.component.pug create mode 100644 terminus-ssh/src/components/sftpPanel.component.scss create mode 100644 terminus-ssh/src/components/sftpPanel.component.ts diff --git a/terminus-core/src/api/platform.ts b/terminus-core/src/api/platform.ts index 559f33b7..f169fdf2 100644 --- a/terminus-core/src/api/platform.ts +++ b/terminus-core/src/api/platform.ts @@ -88,6 +88,21 @@ export abstract class PlatformService { abstract startDownload (name: string, size: number): Promise abstract startUpload (options?: FileUploadOptions): Promise + startUploadFromDragEvent (event: DragEvent): FileUpload[] { + const result: FileUpload[] = [] + if (!event.dataTransfer) { + return [] + } + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < event.dataTransfer.files.length; i++) { + const file = event.dataTransfer.files[i] + const transfer = new DropUpload(file) + this.fileTransferStarted.next(transfer) + result.push(transfer) + } + return result + } + getConfigPath (): string|null { return null } @@ -144,3 +159,36 @@ export abstract class PlatformService { abstract showMessageBox (options: MessageBoxOptions): Promise abstract quit (): void } + + +class DropUpload extends FileUpload { + private stream: ReadableStream + private reader: ReadableStreamDefaultReader + + constructor (private file: File) { + super() + this.stream = this.file.stream() + this.reader = this.stream.getReader() + } + + getName (): string { + return this.file.name + } + + getSize (): number { + return this.file.size + } + + async read (): Promise { + const result: any = await this.reader.read() + if (result.done || !result.value) { + return Buffer.from('') + } + const chunk = Buffer.from(result.value) + this.increaseProgress(chunk.length) + return chunk + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + close (): void { } +} diff --git a/terminus-core/src/directives/dropZone.directive.pug b/terminus-core/src/directives/dropZone.directive.pug new file mode 100644 index 00000000..0915522f --- /dev/null +++ b/terminus-core/src/directives/dropZone.directive.pug @@ -0,0 +1 @@ +i.fas.fa-upload diff --git a/terminus-core/src/directives/dropZone.directive.scss b/terminus-core/src/directives/dropZone.directive.scss new file mode 100644 index 00000000..c2311ee4 --- /dev/null +++ b/terminus-core/src/directives/dropZone.directive.scss @@ -0,0 +1,24 @@ +.drop-zone-hint { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, .5); + pointer-events: none; + z-index: 1; + display: flex; + transition: .25s opacity ease-out; + opacity: 0; + + &.visible { + opacity: 1; + } + + i { + font-size: 48px; + align-self: center; + margin: auto; + text-align: center; + } +} diff --git a/terminus-core/src/directives/dropZone.directive.ts b/terminus-core/src/directives/dropZone.directive.ts new file mode 100644 index 00000000..efffb110 --- /dev/null +++ b/terminus-core/src/directives/dropZone.directive.ts @@ -0,0 +1,49 @@ +import { Directive, Output, ElementRef, EventEmitter, AfterViewInit } from '@angular/core' +import { FileUpload, PlatformService } from '../api/platform' +import './dropZone.directive.scss' + +/** @hidden */ +@Directive({ + selector: '[dropZone]', +}) +export class DropZoneDirective implements AfterViewInit { + @Output() transfer = new EventEmitter() + private dropHint?: HTMLElement + + constructor ( + private el: ElementRef, + private platform: PlatformService, + ) { } + + ngAfterViewInit (): void { + this.el.nativeElement.addEventListener('dragover', () => { + if (!this.dropHint) { + this.dropHint = document.createElement('div') + this.dropHint.className = 'drop-zone-hint' + this.dropHint.innerHTML = require('./dropZone.directive.pug') + this.el.nativeElement.appendChild(this.dropHint) + setTimeout(() => { + this.dropHint!.classList.add('visible') + }) + } + }) + this.el.nativeElement.addEventListener('drop', (event: DragEvent) => { + this.removeHint() + for (const transfer of this.platform.startUploadFromDragEvent(event)) { + this.transfer.emit(transfer) + } + }) + this.el.nativeElement.addEventListener('dragleave', () => { + this.removeHint() + }) + } + + private removeHint () { + const element = this.dropHint + delete this.dropHint + element?.classList.remove('visible') + setTimeout(() => { + element?.remove() + }, 500) + } +} diff --git a/terminus-core/src/index.ts b/terminus-core/src/index.ts index 3aadcd58..d79ee031 100644 --- a/terminus-core/src/index.ts +++ b/terminus-core/src/index.ts @@ -25,6 +25,7 @@ import { TransfersMenuComponent } from './components/transfersMenu.component' import { AutofocusDirective } from './directives/autofocus.directive' import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive' +import { DropZoneDirective } from './directives/dropZone.directive' import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider } from './api' @@ -83,6 +84,7 @@ const PROVIDERS = [ UnlockVaultModalComponent, WelcomeTabComponent, TransfersMenuComponent, + DropZoneDirective, ], entryComponents: [ RenameTabModalComponent, @@ -96,6 +98,7 @@ const PROVIDERS = [ CheckboxComponent, ToggleComponent, AutofocusDirective, + DropZoneDirective, ], }) export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class diff --git a/terminus-core/src/theme.vars.scss b/terminus-core/src/theme.vars.scss index a7eb6177..1f275d54 100644 --- a/terminus-core/src/theme.vars.scss +++ b/terminus-core/src/theme.vars.scss @@ -38,7 +38,7 @@ $theme-colors: ( warning: $orange, danger: $red, light: $gray-300, - dark: $gray-800, + dark: #0e151d, rare: $purple ); @@ -150,7 +150,7 @@ $navbar-padding-x: 0; $dropdown-bg: $content-bg-solid; $dropdown-color: $body-color; $dropdown-border-width: 1px; -$dropdown-box-shadow: 0 .5rem 1rem rgba($black,.175); +$dropdown-box-shadow: 0 0 1rem rgba($black, .25), 0 1px 1px rgba($black, .12); $dropdown-header-color: $gray-500; $dropdown-link-color: $body-color; diff --git a/terminus-ssh/package.json b/terminus-ssh/package.json index e06cc828..6c0421fe 100644 --- a/terminus-ssh/package.json +++ b/terminus-ssh/package.json @@ -22,6 +22,7 @@ "license": "MIT", "devDependencies": { "@types/node": "14.14.31", + "@types/ssh2": "^0.5.46", "ansi-colors": "^4.1.1", "cli-spinner": "^0.2.10", "clone-deep": "^4.0.1", diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index af9edf44..ce74c838 100644 --- a/terminus-ssh/src/api.ts +++ b/terminus-ssh/src/api.ts @@ -10,11 +10,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { HostAppService, Logger, NotificationsService, Platform, PlatformService } from 'terminus-core' import { BaseSession } from 'terminus-terminal' import { Server, Socket, createServer, createConnection } from 'net' -import { Client, ClientChannel } from 'ssh2' +import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' import { ProxyCommandStream } from './services/ssh.service' import { PasswordStorageService } from './services/passwordStorage.service' import { PromptModalComponent } from './components/promptModal.component' +import { promisify } from 'util' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' @@ -140,6 +141,7 @@ export class SSHSession extends BaseSession { scripts?: LoginScript[] shell?: ClientChannel ssh: Client + sftp?: SFTPWrapper forwardedPorts: ForwardedPort[] = [] logger: Logger jumpStream: any @@ -221,6 +223,13 @@ export class SSHSession extends BaseSession { this.remainingAuthMethods.push({ type: 'hostbased' }) } + async openSFTP (): Promise { + if (!this.sftp) { + this.sftp = await promisify(f => this.ssh.sftp(f))() + } + return this.sftp + } + async start (): Promise { this.open = true @@ -273,7 +282,7 @@ export class SSHSession extends BaseSession { if (match) { this.logger.info('Executing script: "' + cmd + '"') - this.shell.write(cmd + '\n') + this.shell?.write(cmd + '\n') this.scripts = this.scripts.filter(x => x !== script) } else { if (script.optional) { @@ -569,7 +578,7 @@ export class SSHSession extends BaseSession { for (const script of this.scripts) { if (!script.expect) { console.log('Executing script:', script.send) - this.shell.write(script.send + '\n') + this.shell?.write(script.send + '\n') this.scripts = this.scripts.filter(x => x !== script) } else { break diff --git a/terminus-ssh/src/components/sftpPanel.component.pug b/terminus-ssh/src/components/sftpPanel.component.pug new file mode 100644 index 00000000..f540ef4b --- /dev/null +++ b/terminus-ssh/src/components/sftpPanel.component.pug @@ -0,0 +1,33 @@ +.header + .breadcrumb.mr-auto + a.breadcrumb-item((click)='navigate("/")') SFTP + a.breadcrumb-item( + *ngFor='let segment of pathSegments', + (click)='navigate(segment.path)' + ) {{segment.name}} + + button.btn.btn-link.btn-sm.d-flex((click)='upload()') + i.fas.fa-upload.mr-1 + div Upload + + button.btn.btn-link.btn-close((click)='close()') !{require('../../../terminus-core/src/icons/times.svg')} + +.body(dropZone, (transfer)='uploadOne($event)') + div(*ngIf='!sftp') Connecting + div(*ngIf='sftp') + div(*ngIf='fileList === null') Loading + .list-group.list-group-light(*ngIf='fileList !== null') + .list-group-item.list-group-item-action.d-flex.align-items-center( + *ngIf='path !== "/"', + (click)='goUp()' + ) + i.fas.fa-fw.fa-level-up-alt + div Go up + .list-group-item.list-group-item-action.d-flex.align-items-center( + *ngFor='let item of fileList', + (click)='open(item)' + ) + i.fa-fw([class]='getIcon(item)') + div {{item.filename}} + .mr-auto + .mode {{getModeString(item)}} diff --git a/terminus-ssh/src/components/sftpPanel.component.scss b/terminus-ssh/src/components/sftpPanel.component.scss new file mode 100644 index 00000000..7b8d4bf9 --- /dev/null +++ b/terminus-ssh/src/components/sftpPanel.component.scss @@ -0,0 +1,40 @@ +:host { + display: flex; + flex-direction: column;; + + > * { + } + + > .header { + padding: 5px 15px 0 20px; + display: flex; + align-items: center; + flex: none; + } + + > .body { + padding: 10px 20px; + flex: 1 1 0; + overflow-y: auto; + } + + .breadcrumb { + background: none; + padding: 0; + margin: 0; + } + + .breadcrumb-item:first-child { + font-weight: bold; + } + + .mode { + font-family: monospace; + opacity: .5; + font-size: 12px; + } +} + +.btn-close svg { + width: 12px; +} diff --git a/terminus-ssh/src/components/sftpPanel.component.ts b/terminus-ssh/src/components/sftpPanel.component.ts new file mode 100644 index 00000000..22fd5560 --- /dev/null +++ b/terminus-ssh/src/components/sftpPanel.component.ts @@ -0,0 +1,196 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core' +import { SFTPWrapper } from 'ssh2' +import type { FileEntry, Stats } from 'ssh2-streams' +import { promisify } from 'util' +import { SSHSession } from '../api' +import * as path from 'path' +import * as C from 'constants' +import { FileUpload, PlatformService } from 'terminus-core' + +interface PathSegment { + name: string + path: string +} + +/** @hidden */ +@Component({ + selector: 'sftp-panel', + template: require('./sftpPanel.component.pug'), + styles: [require('./sftpPanel.component.scss')], +}) +export class SFTPPanelComponent { + @Input() session: SSHSession + @Output() closed = new EventEmitter() + sftp: SFTPWrapper + fileList: FileEntry[]|null = null + path = '/' + pathSegments: PathSegment[] = [] + + constructor ( + private platform: PlatformService, + ) { } + + async ngOnInit (): Promise { + this.sftp = await this.session.openSFTP() + this.navigate('/') + } + + async navigate (newPath: string): Promise { + this.path = newPath + + let p = newPath + this.pathSegments = [] + while (p !== '/') { + this.pathSegments.unshift({ + name: path.basename(p), + path: p, + }) + p = path.dirname(p) + } + + this.fileList = null + this.fileList = await promisify(f => this.sftp.readdir(this.path, f))() + console.log(this.fileList) + + const dirKey = a => (a.attrs.mode & C.S_IFDIR) === C.S_IFDIR ? 1 : 0 + this.fileList.sort((a, b) => + dirKey(b) - dirKey(a) || + a.filename.localeCompare(b.filename)) + } + + getIcon (item: FileEntry): string { + if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { + return 'fas fa-folder text-info' + } + if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) { + return 'fas fa-link text-warning' + } + return 'fas fa-file' + } + + goUp (): void { + this.navigate(path.dirname(this.path)) + } + + async open (item: FileEntry): Promise { + const itemPath = path.join(this.path, item.filename) + if ((item.attrs.mode & C.S_IFDIR) === C.S_IFDIR) { + this.navigate(path.join(this.path, item.filename)) + } else if ((item.attrs.mode & C.S_IFLNK) === C.S_IFLNK) { + const target = await promisify(f => this.sftp.readlink(itemPath, f))() + const stat = await promisify(f => this.sftp.stat(target, f))() + if (stat.isDirectory()) { + this.navigate(itemPath) + } else { + this.download(itemPath, stat.size) + } + } else { + this.download(itemPath, item.attrs.size) + } + } + + async upload (): Promise { + const transfers = await this.platform.startUpload({ multiple: true }) + const savedPath = this.path + for (const transfer of transfers) { + this.uploadOne(transfer).then(() => { + if (this.path === savedPath) { + this.navigate(this.path) + } + }) + } + } + + async uploadOne (transfer: FileUpload): Promise { + const itemPath = path.join(this.path, transfer.getName()) + try { + const handle = await promisify(f => this.sftp.open(itemPath, 'w', f))() + let position = 0 + while (true) { + const chunk = await transfer.read() + if (!chunk.length) { + break + } + const p = position + await new Promise((resolve, reject) => { + while (true) { + const wait = this.sftp.write(handle, chunk, 0, chunk.length, p, err => { + if (err) { + return reject(err) + } + resolve() + }) + if (!wait) { + break + } + } + }) + position += chunk.length + } + this.sftp.close(handle, () => null) + transfer.close() + } catch (e) { + transfer.cancel() + throw e + } + } + + async download (itemPath: string, size: number): Promise { + const transfer = await this.platform.startDownload(path.basename(itemPath), size) + if (!transfer) { + return + } + try { + const handle = await promisify(f => this.sftp.open(itemPath, 'r', f))() + const buffer = Buffer.alloc(256 * 1024) + let position = 0 + while (true) { + const p = position + const chunk: Buffer = await new Promise((resolve, reject) => { + while (true) { + const wait = this.sftp.read(handle, buffer, 0, buffer.length, p, (err, read) => { + if (err) { + reject(err) + return + } + resolve(buffer.slice(0, read)) + }) + if (!wait) { + break + } + } + }) + if (!chunk.length) { + break + } + await transfer.write(chunk) + position += chunk.length + } + transfer.close() + this.sftp.close(handle, () => null) + } catch (e) { + transfer.cancel() + throw e + } + } + + getModeString (item: FileEntry): string { + const s = 'SGdrwxrwxrwx' + const e = ' ---------' + const c = [ + 0o4000, 0o2000, C.S_IFDIR, + C.S_IRUSR, C.S_IWUSR, C.S_IXUSR, + C.S_IRGRP, C.S_IWGRP, C.S_IXGRP, + C.S_IROTH, C.S_IWOTH, C.S_IXOTH, + ] + let result = '' + for (let i = 0; i < c.length; i++) { + result += item.attrs.mode & c[i] ? s[i] : e[i] + } + return result + } + + close (): void { + this.closed.emit() + } +} diff --git a/terminus-ssh/src/components/sshTab.component.pug b/terminus-ssh/src/components/sshTab.component.pug index f18f50d1..71e79be6 100644 --- a/terminus-ssh/src/components/sshTab.component.pug +++ b/terminus-ssh/src/components/sshTab.component.pug @@ -9,6 +9,16 @@ button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open') span Reconnect + button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open') + span SFTP + button.btn.btn-secondary((click)='showPortForwarding()', *ngIf='session && session.open') i.fas.fa-plug span Ports + +sftp-panel.bg-dark( + *ngIf='sftpPanelVisible', + (click)='$event.stopPropagation()', + [session]='session', + (closed)='sftpPanelVisible = false' +) diff --git a/terminus-ssh/src/components/sshTab.component.scss b/terminus-ssh/src/components/sshTab.component.scss index 324138dc..d229f8ff 100644 --- a/terminus-ssh/src/components/sshTab.component.scss +++ b/terminus-ssh/src/components/sshTab.component.scss @@ -70,3 +70,11 @@ } } } + +sftp-panel { + position: absolute; + height: 80%; + width: 100%; + bottom: 0; + z-index: 5; +} diff --git a/terminus-ssh/src/components/sshTab.component.ts b/terminus-ssh/src/components/sshTab.component.ts index 3ee065bd..6bec6875 100644 --- a/terminus-ssh/src/components/sshTab.component.ts +++ b/terminus-ssh/src/components/sshTab.component.ts @@ -1,6 +1,6 @@ import colors from 'ansi-colors' import { Spinner } from 'cli-spinner' -import { Component, Injector } from '@angular/core' +import { Component, Injector, HostListener } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { first } from 'rxjs/operators' import { RecoveryToken } from 'terminus-core' @@ -20,6 +20,7 @@ import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.compon export class SSHTabComponent extends BaseTerminalTabComponent { connection?: SSHConnection session: SSHSession|null = null + sftpPanelVisible = false private sessionStack: SSHSession[] = [] private recentInputs = '' private reconnectOffered = false @@ -225,6 +226,17 @@ export class SSHTabComponent extends BaseTerminalTabComponent { )).response === 1 } + openSFTP (): void { + setTimeout(() => { + this.sftpPanelVisible = true + }, 100) + } + + @HostListener('click') + onClick (): void { + this.sftpPanelVisible = false + } + private startSpinner () { this.spinner.setSpinnerString(6) this.spinner.start() diff --git a/terminus-ssh/src/index.ts b/terminus-ssh/src/index.ts index ef0153eb..5c7a04bc 100644 --- a/terminus-ssh/src/index.ts +++ b/terminus-ssh/src/index.ts @@ -13,6 +13,7 @@ import { SSHPortForwardingConfigComponent } from './components/sshPortForwarding import { PromptModalComponent } from './components/promptModal.component' import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' import { SSHTabComponent } from './components/sshTab.component' +import { SFTPPanelComponent } from './components/sftpPanel.component' import { ButtonProvider } from './buttonProvider' import { SSHConfigProvider } from './config' @@ -55,6 +56,7 @@ import { SSHCLIHandler } from './cli' SSHPortForwardingConfigComponent, SSHSettingsTabComponent, SSHTabComponent, + SFTPPanelComponent, ], }) export default class SSHModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class diff --git a/terminus-ssh/yarn.lock b/terminus-ssh/yarn.lock index 881d74c7..6e39061a 100644 --- a/terminus-ssh/yarn.lock +++ b/terminus-ssh/yarn.lock @@ -2,11 +2,31 @@ # yarn lockfile v1 +"@types/node@*": + version "15.12.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.2.tgz#1f2b42c4be7156ff4a6f914b2fb03d05fa84e38d" + integrity sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww== + "@types/node@14.14.31": version "14.14.31" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055" integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g== +"@types/ssh2-streams@*": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.8.tgz#142af404dae059931aea7fcd1511b5478964feb6" + integrity sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA== + dependencies: + "@types/node" "*" + +"@types/ssh2@^0.5.46": + version "0.5.46" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.46.tgz#e12341a242aea0e98ac2dec89e039bf421fd3584" + integrity sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA== + dependencies: + "@types/node" "*" + "@types/ssh2-streams" "*" + ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"