added experimental SFTP implementation - fixes #296

This commit is contained in:
Eugene Pankov 2021-06-12 01:18:07 +02:00
parent 019cba06d4
commit a397884d3c
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
16 changed files with 462 additions and 6 deletions

View file

@ -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 { }
}

View file

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

View file

@ -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;
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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;

View file

@ -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",

View file

@ -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

View file

@ -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)}}

View file

@ -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;
}

View file

@ -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()
}
}

View file

@ -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'
)

View file

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

View file

@ -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()

View file

@ -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

View file

@ -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"