Browse Source

added experimental SFTP implementation - fixes #296

Eugene Pankov 4 years ago
parent
commit
a397884d3c

+ 48 - 0
terminus-core/src/api/platform.ts

@@ -88,6 +88,21 @@ export abstract class PlatformService {
     abstract startDownload (name: string, size: number): Promise<FileDownload|null>
     abstract startUpload (options?: FileUploadOptions): Promise<FileUpload[]>
 
+    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<MessageBoxResult>
     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<Buffer> {
+        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 { }
+}

+ 1 - 0
terminus-core/src/directives/dropZone.directive.pug

@@ -0,0 +1 @@
+i.fas.fa-upload

+ 24 - 0
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;
+    }
+}

+ 49 - 0
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<FileUpload>()
+    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)
+    }
+}

+ 3 - 0
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

+ 2 - 2
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;

+ 1 - 0
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",

+ 12 - 3
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<SFTPWrapper> {
+        if (!this.sftp) {
+            this.sftp = await promisify<SFTPWrapper>(f => this.ssh.sftp(f))()
+        }
+        return this.sftp
+    }
+
     async start (): Promise<void> {
         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

+ 33 - 0
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)}}

+ 40 - 0
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;
+}

+ 196 - 0
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<void>()
+    sftp: SFTPWrapper
+    fileList: FileEntry[]|null = null
+    path = '/'
+    pathSegments: PathSegment[] = []
+
+    constructor (
+        private platform: PlatformService,
+    ) { }
+
+    async ngOnInit (): Promise<void> {
+        this.sftp = await this.session.openSFTP()
+        this.navigate('/')
+    }
+
+    async navigate (newPath: string): Promise<void> {
+        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<FileEntry[]>(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<void> {
+        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<string>(f => this.sftp.readlink(itemPath, f))()
+            const stat = await promisify<Stats>(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<void> {
+        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<void> {
+        const itemPath = path.join(this.path, transfer.getName())
+        try {
+            const handle = await promisify<Buffer>(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<void>((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<void> {
+        const transfer = await this.platform.startDownload(path.basename(itemPath), size)
+        if (!transfer) {
+            return
+        }
+        try {
+            const handle = await promisify<Buffer>(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()
+    }
+}

+ 10 - 0
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'
+)

+ 8 - 0
terminus-ssh/src/components/sshTab.component.scss

@@ -70,3 +70,11 @@
         }
     }
 }
+
+sftp-panel {
+    position: absolute;
+    height: 80%;
+    width: 100%;
+    bottom: 0;
+    z-index: 5;
+}

+ 13 - 1
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()

+ 2 - 0
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

+ 20 - 0
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/[email protected]":
   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"