From b9f31cc19c1dabc7efde25ef2486eab8cd6e1bc9 Mon Sep 17 00:00:00 2001 From: William Harrell Date: Fri, 11 Nov 2022 20:27:15 -0500 Subject: [PATCH] added file downloads to frontend --- client/src/components/files.vue | 148 ++++++++++++++++++++++++++--- client/src/neko/base.ts | 8 -- client/src/neko/index.ts | 8 ++ client/src/neko/types.ts | 14 ++- client/src/store/files.ts | 24 ++++- server/internal/http/http.go | 1 + server/internal/types/websocket.go | 1 + server/internal/utils/files.go | 6 ++ 8 files changed, 185 insertions(+), 25 deletions(-) diff --git a/client/src/components/files.vue b/client/src/components/files.vue index 6dc82cf1..1c99496f 100644 --- a/client/src/components/files.vue +++ b/client/src/components/files.vue @@ -8,13 +8,28 @@

{{ item.name }}

+

{{ fileSize(item.size) }}

-
- -

Drag files here to upload

+
+
+

Downloads

+
+
+

{{ download.name }}

+

{{ Math.max(100, Math.round(download.progress / download.size * 100))}}%

+ +
+ +
+
+
+ +

Drag files here to upload

+
@@ -79,30 +94,63 @@ border-bottom: 0px; } - .refresh, .download { + .refresh { margin-left: auto; } - .refresh:hover, .download:hover { + .file-size { + margin-left: auto; + margin-right: 0.5em; + color: rgba($color: #fff, $alpha: 0.40); + } + + .refresh:hover, .download:hover, .remove-transfer:hover { cursor: pointer; } - .files-transfer { - display: flex; - flex-direction: column; - text-align: center; - justify-content: center; - margin: auto 10px 10px 10px; + .transfer-area { + margin-top: auto; + } + + .transfers { + margin: 10px 10px 10px 10px; background-color: rgba($color: #fff, $alpha: 0.05); border-radius: 5px; } - .files-transfer > i { + .transfers > p { + padding: 10px; + font-weight: 600; + } + + .transfer-info { + display: flex; + flex-direction: row; + max-width: 100%; + padding: 10px; + } + + .transfer-progress { + margin: 0px 10px 10px 10px; + width: 95%; + } + + .upload-area { + display: flex; + flex-direction: column; + text-align: center; + justify-content: center; + margin: 10px 10px 10px 10px; + background-color: rgba($color: #fff, $alpha: 0.05); + border-radius: 5px; + } + + .upload-area > i { font-size: 4em; margin: 10px 10px 10px 10px; } - .files-transfer > p { + .upload-area > p { margin: 0px 10px 10px 10px; } @@ -115,6 +163,7 @@ import Markdown from './markdown' import Content from './context.vue' +import { FileTransfer } from '~/neko/types' @Component({ name: 'neko-files', @@ -132,13 +181,68 @@ get files() { return this.$accessor.files.files } + + get transfers() { + return this.$accessor.files.transfers + } + + get downloads() { + return this.$accessor.files.transfers.filter((t => t.direction === 'download')) + } + + get uploads() { + return this.$accessor.files.transfers.filter((t => t.direction === 'upload')) + } refresh() { this.$accessor.files.refresh() } download(item: any) { - console.log(item.name); + const url = `/file?pwd=${this.$accessor.password}&filename=${item.name}` + let transfer: FileTransfer = { + id: Math.round(Math.random() * 10000), + name: item.name, + direction: 'download', + // this is just an estimation, but for large files the content length + // is not sent (chunked transfer) + size: item.size, + progress: 0, + status: 'pending', + axios: null, + // TODO add support for aborting in progress requests, requires axios >=0.22 + abortController: null + } + transfer.axios = this.$http.get(url, { + responseType: 'blob', + onDownloadProgress: (x) => { + transfer.progress = x.loaded + + if (x.lengthComputable) { + transfer.size = x.total + } + if (transfer.progress === transfer.size) { + transfer.status = 'completed' + } + } + }).then((res) => { + const url = window.URL + .createObjectURL(new Blob([res.data])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', item.name) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }).catch((err) => { + this.$log.error(err) + }) + this.$accessor.files.addTransfer(transfer) + } + + removeTransfer(item: FileTransfer) { + console.log(item) + this.$accessor.files.removeTransfer(item) } fileIcon(file: any) { @@ -169,6 +273,22 @@ return className; } + fileSize(size: number) { + if (size < 1000) { + return `${size} b` + } + if (size < 1000 ** 2) { + return `${(size / 1000).toFixed(2)} kb` + } + if (size < 1000 ** 3) { + return `${(size / 1000 ** 2).toFixed(2)} mb` + } + if (size < 1000 ** 4) { + return `${(size / 1000 ** 3).toFixed(2)} gb` + } + return `${(size / 1000 ** 4).toFixed(3)} tb` + } + onFileDrop(e: any) { console.log('file dropped', e) console.log(e.dataTransfer.files) diff --git a/client/src/neko/base.ts b/client/src/neko/base.ts index dc767b5b..31ec8add 100644 --- a/client/src/neko/base.ts +++ b/client/src/neko/base.ts @@ -187,14 +187,6 @@ export abstract class BaseClient extends EventEmitter { this._ws!.send(JSON.stringify({ event, ...payload })) } - public refreshFiles() { - if (!this.connected) { - this.emit('warn', 'attempting to refresh files while disconnected') - } - this.emit('debug', `sending event '${EVENT.FILETRANSFER.REFRESH}'`) - this._ws!.send(JSON.stringify({ event: EVENT.FILETRANSFER.REFRESH })) - } - public async createPeer(lite: boolean, servers: RTCIceServer[]) { this.emit('debug', `creating peer`) if (!this.socketOpen) { diff --git a/client/src/neko/index.ts b/client/src/neko/index.ts index a066b3b9..15033db3 100644 --- a/client/src/neko/index.ts +++ b/client/src/neko/index.ts @@ -71,6 +71,14 @@ export class NekoClient extends BaseClient implements EventEmitter { }) } + public refreshFiles() { + if (!this.connected) { + this.emit('warn', 'attempting to refresh files while disconnected') + } + this.emit('debug', `sending event '${EVENT.FILETRANSFER.REFRESH}'`) + this._ws!.send(JSON.stringify({ event: EVENT.FILETRANSFER.REFRESH })) + } + ///////////////////////////// // Internal Events ///////////////////////////// diff --git a/client/src/neko/types.ts b/client/src/neko/types.ts index 938fc8cd..31bb44ab 100644 --- a/client/src/neko/types.ts +++ b/client/src/neko/types.ts @@ -25,5 +25,17 @@ export interface ScreenResolution { export interface FileListItem { name: string, - type: 'file' | 'dir' + type: 'file' | 'dir', + size: number +} + +export interface FileTransfer { + id: number, + name: string, + direction: 'upload' | 'download', + size: number, + progress: number, + status: 'pending' | 'inprogress' | 'completed', + axios: Promise | null, + abortController: AbortController | null } diff --git a/client/src/store/files.ts b/client/src/store/files.ts index 9c8ce065..5eb2c1f7 100644 --- a/client/src/store/files.ts +++ b/client/src/store/files.ts @@ -1,10 +1,11 @@ import { actionTree, getterTree, mutationTree } from 'typed-vuex' -import { FileListItem } from '~/neko/types' +import { FileListItem, FileTransfer } from '~/neko/types' import { accessor } from '~/store' export const state = () => ({ cwd: '', - files: [] as FileListItem[] + files: [] as FileListItem[], + transfers: [] as FileTransfer[] }) export const getters = getterTree(state, { @@ -18,6 +19,14 @@ export const mutations = mutationTree(state, { _setFileList(state, files: FileListItem[]) { state.files = files + }, + + _addTransfer(state, transfer: FileTransfer) { + state.transfers = [...state.transfers, transfer] + }, + + _removeTransfer(state, transfer: FileTransfer) { + state.transfers = state.transfers.filter((t) => t.id !== transfer.id) } }) @@ -32,6 +41,17 @@ export const actions = actionTree( accessor.files._setFileList(files) }, + addTransfer(store, transfer: FileTransfer) { + if (transfer.status !== 'pending') { + return + } + accessor.files._addTransfer(transfer) + }, + + removeTransfer(store, transfer: FileTransfer) { + accessor.files._removeTransfer(transfer) + }, + refresh(store) { if (!accessor.connected) { return diff --git a/server/internal/http/http.go b/server/internal/http/http.go index 4b8989c4..82eea6b4 100644 --- a/server/internal/http/http.go +++ b/server/internal/http/http.go @@ -35,6 +35,7 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t router.Use(middleware.RequestID) // Create a request ID for each request router.Use(middleware.RequestLogger(&logformatter{logger})) router.Use(middleware.Recoverer) // Recover from panics without crashing server + router.Use(middleware.Compress(5, "application/octet-stream")) if conf.PathPrefix != "/" { router.Use(func(h http.Handler) http.Handler { diff --git a/server/internal/types/websocket.go b/server/internal/types/websocket.go index 2fa14796..cb80fccd 100644 --- a/server/internal/types/websocket.go +++ b/server/internal/types/websocket.go @@ -41,4 +41,5 @@ type WebSocketHandler interface { type FileListItem struct { Filename string `json:"name"` Type string `json:"type"` + Size int64 `json:"size"` } diff --git a/server/internal/utils/files.go b/server/internal/utils/files.go index 7ac3005f..56c714d7 100644 --- a/server/internal/utils/files.go +++ b/server/internal/utils/files.go @@ -15,14 +15,20 @@ func ListFiles(path string) (*[]types.FileListItem, error) { out := make([]types.FileListItem, len(items)) for i, item := range items { var itemType string = "" + var size int64 = 0 if item.IsDir() { itemType = "dir" } else { itemType = "file" + info, err := item.Info() + if err == nil { + size = info.Size() + } } out[i] = types.FileListItem{ Filename: item.Name(), Type: itemType, + Size: size, } }