Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a18e8b4bf | |||
| bf8213c1a6 | |||
| cffa734edb |
@@ -3,6 +3,7 @@ package handlers
|
||||
import codit_logger "codit/logger"
|
||||
|
||||
import "archive/zip"
|
||||
import "context"
|
||||
import "database/sql"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
@@ -59,17 +60,24 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
var ok bool
|
||||
var store *db.Store
|
||||
var sessionItem models.SSHSession
|
||||
var multipartReader *multipart.Reader
|
||||
var part *multipart.Part
|
||||
var sshClient *ssh.Client
|
||||
var sftpClient *sftp.Client
|
||||
var stopContextClose func()
|
||||
var targetDir string
|
||||
var overwrite bool
|
||||
var response sshSessionFileUploadResponse
|
||||
var files []*multipart.FileHeader
|
||||
var file multipart.File
|
||||
var item sshSessionFileUploadItem
|
||||
var remotePath string
|
||||
var fieldName string
|
||||
var fieldValue string
|
||||
var password string
|
||||
var otpCode string
|
||||
var uploadCount int
|
||||
var opened bool
|
||||
var copied int64
|
||||
var err error
|
||||
var i int
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || user.Disabled {
|
||||
@@ -77,61 +85,95 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, sshFileTransferMaxUploadBytes)
|
||||
err = r.ParseMultipartForm(16 * 1024 * 1024)
|
||||
multipartReader, err = r.MultipartReader()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
|
||||
return
|
||||
}
|
||||
targetDir = strings.TrimSpace(r.FormValue("target_dir"))
|
||||
if targetDir == "" {
|
||||
targetDir = "."
|
||||
}
|
||||
overwrite = parseSSHFileTransferBool(r.FormValue("overwrite"))
|
||||
if r.MultipartForm != nil {
|
||||
files = r.MultipartForm.File["files"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one file is required"})
|
||||
for {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if len(files) > sshFileTransferMaxFiles {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
part, err = multipartReader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
store = api.store(r)
|
||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
|
||||
return
|
||||
}
|
||||
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, r.FormValue("password"), r.FormValue("otp_code"))
|
||||
fieldName = part.FormName()
|
||||
if fieldName != "files" {
|
||||
fieldValue, err = readMultipartFieldValue(part, 64 * 1024)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if fieldName == "target_dir" {
|
||||
targetDir = strings.TrimSpace(fieldValue)
|
||||
if targetDir == "" {
|
||||
targetDir = "."
|
||||
}
|
||||
} else if fieldName == "overwrite" {
|
||||
overwrite = parseSSHFileTransferBool(fieldValue)
|
||||
} else if fieldName == "password" {
|
||||
password = fieldValue
|
||||
} else if fieldName == "otp_code" {
|
||||
otpCode = fieldValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
uploadCount += 1
|
||||
if uploadCount > sshFileTransferMaxFiles {
|
||||
_ = part.Close()
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
}
|
||||
if !opened {
|
||||
store = api.store(r)
|
||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
return
|
||||
}
|
||||
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, password, otpCode)
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
stopContextClose = closeSSHClientOnContext(r.Context(), sshClient)
|
||||
defer stopContextClose()
|
||||
defer sshClient.Close()
|
||||
sftpClient, err = sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
_ = part.Close()
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sftp subsystem unavailable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer sftpClient.Close()
|
||||
for i = 0; i < len(files); i++ {
|
||||
item = sshSessionFileUploadItem{Name: path.Base(files[i].Filename), Size: files[i].Size}
|
||||
opened = true
|
||||
}
|
||||
item = sshSessionFileUploadItem{Name: path.Base(part.FileName())}
|
||||
if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") {
|
||||
item.Error = "invalid file name"
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
remotePath = path.Join(targetDir, item.Name)
|
||||
item.Path = remotePath
|
||||
file, err = files[i].Open()
|
||||
if err == nil {
|
||||
err = api.sftpUploadFile(sftpClient, targetDir, item.Name, file, overwrite)
|
||||
_ = file.Close()
|
||||
}
|
||||
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
|
||||
_ = part.Close()
|
||||
item.Size = copied
|
||||
if err != nil {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
item.Error = err.Error()
|
||||
response.Failed += 1
|
||||
} else {
|
||||
@@ -139,6 +181,10 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
if uploadCount == 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one file is required"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -150,11 +196,13 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
var sessionItem models.SSHSession
|
||||
var sshClient *ssh.Client
|
||||
var sftpClient *sftp.Client
|
||||
var stopContextClose func()
|
||||
var items []sshSessionFileDownloadItem
|
||||
var item sshSessionFileDownloadItem
|
||||
var remotePath string
|
||||
var totalSize int64
|
||||
var contentDisposition string
|
||||
var archiveName string
|
||||
var err error
|
||||
var i int
|
||||
|
||||
@@ -187,6 +235,8 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
stopContextClose = closeSSHClientOnContext(r.Context(), sshClient)
|
||||
defer stopContextClose()
|
||||
defer sshClient.Close()
|
||||
sftpClient, err = sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
@@ -195,6 +245,9 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
defer sftpClient.Close()
|
||||
for i = 0; i < len(req.Paths); i++ {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
remotePath = strings.TrimSpace(req.Paths[i])
|
||||
if remotePath == "" || strings.Contains(remotePath, "\x00") {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"})
|
||||
@@ -216,16 +269,18 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": items[0].Name})
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
err = api.sftpDownloadFile(sftpClient, items[0].RemotePath, w)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", items[0].Size))
|
||||
err = api.sftpDownloadFile(r.Context(), sftpClient, items[0].RemotePath, w)
|
||||
if err != nil {
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file download failed session=%s path=%q err=%s", sessionItem.ID, items[0].RemotePath, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": fmt.Sprintf("ssh-session-%s-files.zip", sessionItem.ID)})
|
||||
archiveName = sshSessionFilesArchiveName(store, sessionItem)
|
||||
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": archiveName})
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
err = api.writeSSHSessionFileZip(sftpClient, items, w)
|
||||
err = api.writeSSHSessionFileZip(r.Context(), sftpClient, items, w)
|
||||
if err != nil {
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file zip download failed session=%s err=%s", sessionItem.ID, err.Error())
|
||||
}
|
||||
@@ -326,15 +381,46 @@ func (api *API) pinSSHSessionFileTransferHostKey(store *db.Store, sessionItem mo
|
||||
return fmt.Errorf("failed to pin host key for %s: %s", profile.Server.Name, err.Error())
|
||||
}
|
||||
|
||||
func (api *API) sftpUploadFile(client *sftp.Client, targetDir string, name string, reader io.Reader, overwrite bool) error {
|
||||
func closeSSHClientOnContext(ctx context.Context, client *ssh.Client) func() {
|
||||
var done chan struct{}
|
||||
var stop func()
|
||||
|
||||
done = make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = client.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
stop = func() {
|
||||
close(done)
|
||||
}
|
||||
return stop
|
||||
}
|
||||
|
||||
func (api *API) sftpUploadFile(ctx context.Context, client *sftp.Client, targetDir string, name string, reader io.Reader, overwrite bool) (int64, error) {
|
||||
var remotePath string
|
||||
var flags int
|
||||
var remoteFile *sftp.File
|
||||
var copied int64
|
||||
var statErr error
|
||||
var err error
|
||||
|
||||
if ctx.Err() != nil { return 0, ctx.Err() }
|
||||
err = client.MkdirAll(targetDir)
|
||||
if err != nil { return err }
|
||||
if err != nil { return 0, err }
|
||||
if ctx.Err() != nil { return 0, ctx.Err() }
|
||||
remotePath = path.Join(targetDir, name)
|
||||
if !overwrite {
|
||||
_, statErr = client.Stat(remotePath)
|
||||
if statErr == nil {
|
||||
return 0, fmt.Errorf("remote file already exists: %s", remotePath)
|
||||
}
|
||||
if !isSFTPNoSuchFile(statErr) {
|
||||
return 0, statErr
|
||||
}
|
||||
}
|
||||
flags = os.O_WRONLY | os.O_CREATE
|
||||
if overwrite {
|
||||
flags = flags | os.O_TRUNC
|
||||
@@ -342,10 +428,26 @@ func (api *API) sftpUploadFile(client *sftp.Client, targetDir string, name strin
|
||||
flags = flags | os.O_EXCL
|
||||
}
|
||||
remoteFile, err = client.OpenFile(remotePath, flags)
|
||||
if err != nil { return err }
|
||||
if err != nil { return 0, err }
|
||||
defer remoteFile.Close()
|
||||
_, err = io.Copy(remoteFile, reader)
|
||||
return err
|
||||
copied, err = copyWithContext(ctx, remoteFile, reader)
|
||||
return copied, err
|
||||
}
|
||||
|
||||
func isSFTPNoSuchFile(err error) bool {
|
||||
var statusErr *sftp.StatusError
|
||||
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return true
|
||||
}
|
||||
statusErr = nil
|
||||
if errors.As(err, &statusErr) && statusErr.FxCode() == sftp.ErrSSHFxNoSuchFile {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (api *API) sftpDownloadMetadata(client *sftp.Client, remotePath string) (sshSessionFileDownloadItem, error) {
|
||||
@@ -369,18 +471,50 @@ func (api *API) sftpDownloadMetadata(client *sftp.Client, remotePath string) (ss
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (api *API) sftpDownloadFile(client *sftp.Client, remotePath string, writer io.Writer) error {
|
||||
func sshSessionFilesArchiveName(store *db.Store, sessionItem models.SSHSession) string {
|
||||
var profile models.SSHAccessProfile
|
||||
var server models.SSHServer
|
||||
var profileName string
|
||||
var serverName string
|
||||
var timestamp string
|
||||
var err error
|
||||
|
||||
profileName = strings.TrimSpace(sessionItem.ProfileID)
|
||||
serverName = strings.TrimSpace(sessionItem.Host)
|
||||
if sessionItem.Port > 0 && serverName != "" {
|
||||
serverName = fmt.Sprintf("%s-%d", serverName, sessionItem.Port)
|
||||
}
|
||||
profile, err = store.GetSSHAccessProfile(sessionItem.ProfileID)
|
||||
if err == nil && strings.TrimSpace(profile.Name) != "" {
|
||||
profileName = strings.TrimSpace(profile.Name)
|
||||
}
|
||||
server, err = store.GetSSHServer(sessionItem.ServerID)
|
||||
if err == nil && strings.TrimSpace(server.Name) != "" {
|
||||
serverName = strings.TrimSpace(server.Name)
|
||||
}
|
||||
if profileName == "" {
|
||||
profileName = "ssh-profile"
|
||||
}
|
||||
if serverName == "" {
|
||||
serverName = "ssh-server"
|
||||
}
|
||||
timestamp = time.Now().Format("20060102-150405")
|
||||
return fmt.Sprintf("%s-%s-%s-files.zip", sanitizeBundleName(profileName), sanitizeBundleName(serverName), timestamp)
|
||||
}
|
||||
|
||||
func (api *API) sftpDownloadFile(ctx context.Context, client *sftp.Client, remotePath string, writer io.Writer) error {
|
||||
var remoteFile *sftp.File
|
||||
var err error
|
||||
|
||||
if ctx.Err() != nil { return ctx.Err() }
|
||||
remoteFile, err = client.Open(remotePath)
|
||||
if err != nil { return err }
|
||||
defer remoteFile.Close()
|
||||
_, err = io.Copy(writer, remoteFile)
|
||||
_, err = copyWithContext(ctx, writer, remoteFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
||||
func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
||||
var zipWriter *zip.Writer
|
||||
var zipFile io.Writer
|
||||
var closeErr error
|
||||
@@ -389,9 +523,13 @@ func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFi
|
||||
|
||||
zipWriter = zip.NewWriter(writer)
|
||||
for i = 0; i < len(items); i++ {
|
||||
if ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
break
|
||||
}
|
||||
zipFile, err = zipWriter.Create(sshDownloadEntryName(items[i], i))
|
||||
if err != nil { break }
|
||||
err = api.sftpDownloadFile(client, items[i].RemotePath, zipFile)
|
||||
err = api.sftpDownloadFile(ctx, client, items[i].RemotePath, zipFile)
|
||||
if err != nil { break }
|
||||
}
|
||||
closeErr = zipWriter.Close()
|
||||
@@ -401,6 +539,52 @@ func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFi
|
||||
return err
|
||||
}
|
||||
|
||||
func copyWithContext(ctx context.Context, writer io.Writer, reader io.Reader) (int64, error) {
|
||||
var buffer []byte
|
||||
var written int64
|
||||
var nr int
|
||||
var nw int
|
||||
var er error
|
||||
var ew error
|
||||
|
||||
buffer = make([]byte, 64 * 1024)
|
||||
for {
|
||||
if ctx.Err() != nil { return written, ctx.Err() }
|
||||
nr, er = reader.Read(buffer)
|
||||
if nr > 0 {
|
||||
if ctx.Err() != nil { return written, ctx.Err() }
|
||||
nw, ew = writer.Write(buffer[0:nr])
|
||||
if nw > 0 {
|
||||
written += int64(nw)
|
||||
}
|
||||
if ew != nil {
|
||||
return written, ew
|
||||
}
|
||||
if nr != nw {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er == io.EOF {
|
||||
return written, nil
|
||||
}
|
||||
return written, er
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readMultipartFieldValue(part *multipart.Part, maxBytes int64) (string, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
data, err = io.ReadAll(io.LimitReader(part, maxBytes + 1))
|
||||
if err != nil { return "", err }
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", errors.New("multipart field is too large")
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func sshDownloadEntryName(item sshSessionFileDownloadItem, index int) string {
|
||||
var name string
|
||||
|
||||
|
||||
+33
-4
@@ -1172,6 +1172,13 @@ export interface BinaryDownload {
|
||||
contentType: string
|
||||
}
|
||||
|
||||
export interface StreamingDownload {
|
||||
response: Response
|
||||
filename: string
|
||||
contentType: string
|
||||
contentLength: number
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -1241,6 +1248,26 @@ async function requestBinaryDownload(path: string, fallbackFilename: string, opt
|
||||
return { data, filename, contentType }
|
||||
}
|
||||
|
||||
async function requestStreamingDownload(path: string, fallbackFilename: string, options: RequestInit = {}): Promise<StreamingDownload> {
|
||||
const res: Response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
...options
|
||||
})
|
||||
let error: { error?: string }
|
||||
let filename: string
|
||||
let contentType: string
|
||||
let contentLength: number
|
||||
|
||||
if (!res.ok) {
|
||||
error = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(error.error || 'Request failed')
|
||||
}
|
||||
filename = filenameFromContentDisposition(res.headers.get('Content-Disposition'), fallbackFilename)
|
||||
contentType = res.headers.get('Content-Type') || 'application/octet-stream'
|
||||
contentLength = Number(res.headers.get('Content-Length') || '0')
|
||||
return { response: res, filename, contentType, contentLength }
|
||||
}
|
||||
|
||||
async function requestText(path: string, options: RequestInit = {}): Promise<string> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -1539,10 +1566,12 @@ export const api = {
|
||||
getSSHSessionTranscriptForSelf: (id: string, options?: RequestInit) => requestText(`/api/ssh/sessions/${id}/transcript`, options),
|
||||
getSSHSessionTranscriptAdmin: (id: string, options?: RequestInit) => requestText(`/api/admin/ssh/sessions/${id}/transcript`, options),
|
||||
disconnectSSHSessionForSelf: (id: string) => request<void>(`/api/ssh/sessions/${id}/disconnect`, { method: 'POST' }),
|
||||
uploadSSHSessionFilesForSelf: (id: string, form: FormData) =>
|
||||
requestForm<SSHSessionFileUploadResponse>(`/api/ssh/sessions/${id}/files/upload`, { method: 'POST', body: form }),
|
||||
downloadSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload) =>
|
||||
requestBinaryDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } }),
|
||||
uploadSSHSessionFilesForSelf: (id: string, form: FormData, options?: RequestInit) =>
|
||||
requestForm<SSHSessionFileUploadResponse>(`/api/ssh/sessions/${id}/files/upload`, { method: 'POST', body: form, ...(options || {}) }),
|
||||
downloadSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload, options?: RequestInit) =>
|
||||
requestBinaryDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
||||
streamSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload, options?: RequestInit) =>
|
||||
requestStreamingDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
||||
inspectSSHCertificate: (certificate: string) =>
|
||||
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
|
||||
signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import MuiDialog from '@mui/material/Dialog'
|
||||
import { DialogProps } from '@mui/material/Dialog'
|
||||
|
||||
export default function ModalDialog(props: DialogProps) {
|
||||
return (
|
||||
<MuiDialog
|
||||
{...props}
|
||||
onClose={(event, reason) => {
|
||||
if (reason === 'backdropClick') return
|
||||
props.onClose?.(event, reason)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Link from '@mui/material/Link'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Tab from '@mui/material/Tab'
|
||||
|
||||
@@ -2,7 +2,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Link from '@mui/material/Link'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Paper from '@mui/material/Paper'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import List from '@mui/material/List'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import List from '@mui/material/List'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useState } from 'react'
|
||||
import { api, BinaryDownload } from '../api'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { api, StreamingDownload } from '../api'
|
||||
import PageAlert from './PageAlert'
|
||||
|
||||
type SSHSessionFileDownloadDialogProps = {
|
||||
@@ -19,6 +19,13 @@ type SSHSessionFileDownloadDialogProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SaveFileWindow = Window & {
|
||||
showSaveFilePicker?: (options?: {
|
||||
suggestedName?: string
|
||||
types?: Array<{ description: string; accept: Record<string, string[]> }>
|
||||
}) => Promise<{ createWritable: () => Promise<WritableStream<Uint8Array>> }>
|
||||
}
|
||||
|
||||
export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownloadDialogProps) {
|
||||
const [pathsText, setPathsText] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -26,6 +33,7 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [progressText, setProgressText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
@@ -37,7 +45,11 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
}, [props.open])
|
||||
|
||||
const close = () => {
|
||||
if (busy) return
|
||||
if (busy) {
|
||||
abortRef.current?.abort()
|
||||
setProgressText('Canceling download...')
|
||||
return
|
||||
}
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -46,25 +58,54 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
.split('\n')
|
||||
.map((item: string) => item.trim())
|
||||
.filter((item: string) => item !== '')
|
||||
let downloadResult: BinaryDownload
|
||||
const controller: AbortController = new AbortController()
|
||||
let downloadResult: StreamingDownload
|
||||
let blob: Blob
|
||||
let url: string
|
||||
let link: HTMLAnchorElement
|
||||
let saveWindow: SaveFileWindow
|
||||
let fileHandle: { createWritable: () => Promise<WritableStream<Uint8Array>> } | null
|
||||
let writable: WritableStream<Uint8Array>
|
||||
let data: ArrayBuffer
|
||||
let canUseSavePicker: boolean
|
||||
|
||||
if (paths.length === 0) {
|
||||
setError('Enter at least one remote file path')
|
||||
return
|
||||
}
|
||||
saveWindow = window as SaveFileWindow
|
||||
fileHandle = null
|
||||
canUseSavePicker = Boolean(window.isSecureContext && saveWindow.showSaveFilePicker)
|
||||
if (canUseSavePicker) {
|
||||
try {
|
||||
fileHandle = await saveWindow.showSaveFilePicker?.({
|
||||
suggestedName: suggestedSSHDownloadName(paths, props.sessionID)
|
||||
}) || null
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Failed to open the browser save dialog')
|
||||
return
|
||||
}
|
||||
}
|
||||
setBusy(true)
|
||||
abortRef.current = controller
|
||||
setProgressText(`Preparing ${paths.length} remote file(s)...`)
|
||||
setError(null)
|
||||
try {
|
||||
downloadResult = await api.downloadSSHSessionFilesForSelf(props.sessionID, {
|
||||
downloadResult = await api.streamSSHSessionFilesForSelf(props.sessionID, {
|
||||
paths,
|
||||
password: props.passwordRequired ? password : undefined,
|
||||
otp_code: props.otpRequired ? otpCode : undefined
|
||||
})
|
||||
blob = new Blob([downloadResult.data], { type: downloadResult.contentType })
|
||||
}, { signal: controller.signal })
|
||||
setProgressText(downloadResult.contentLength > 0 ? `Downloading ${downloadResult.filename}...` : `Streaming ${downloadResult.filename}...`)
|
||||
if (fileHandle && downloadResult.response.body) {
|
||||
writable = await fileHandle.createWritable()
|
||||
await downloadResult.response.body.pipeTo(writable, { signal: controller.signal })
|
||||
} else {
|
||||
data = await downloadResult.response.arrayBuffer()
|
||||
blob = new Blob([data], { type: downloadResult.contentType })
|
||||
url = window.URL.createObjectURL(blob)
|
||||
link = document.createElement('a')
|
||||
link.href = url
|
||||
@@ -73,12 +114,20 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
setProgressText('Download started.')
|
||||
props.onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setProgressText('Download canceled.')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to download files')
|
||||
setProgressText('')
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -126,8 +175,26 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void download()} disabled={busy} variant="contained">Download</Button>
|
||||
<Button onClick={close} disabled={busy}>Close</Button>
|
||||
<Button onClick={close}>{busy ? 'Cancel' : 'Close'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function suggestedSSHDownloadName(paths: string[], sessionID: string): string {
|
||||
let name: string
|
||||
let slashIndex: number
|
||||
|
||||
if (paths.length === 1) {
|
||||
name = paths[0].trim()
|
||||
slashIndex = name.lastIndexOf('/')
|
||||
if (slashIndex >= 0) {
|
||||
name = name.substring(slashIndex + 1)
|
||||
}
|
||||
name = name.trim()
|
||||
if (name !== '') {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return `ssh-session-${sessionID}-files.zip`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
@@ -9,7 +9,7 @@ import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useState } from 'react'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { api, SSHSessionFileUploadItem, SSHSessionFileUploadResponse } from '../api'
|
||||
import PageAlert from './PageAlert'
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
const [progressText, setProgressText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SSHSessionFileUploadResponse | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
@@ -55,7 +56,11 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
}, [props.open])
|
||||
|
||||
const close = () => {
|
||||
if (busy) return
|
||||
if (busy) {
|
||||
abortRef.current?.abort()
|
||||
setProgressText('Canceling upload...')
|
||||
return
|
||||
}
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -69,6 +74,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
|
||||
const upload = async () => {
|
||||
const form: FormData = new FormData()
|
||||
const controller: AbortController = new AbortController()
|
||||
let response: SSHSessionFileUploadResponse
|
||||
let i: number
|
||||
|
||||
@@ -84,16 +90,28 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
form.append('files', files[i])
|
||||
}
|
||||
setBusy(true)
|
||||
abortRef.current = controller
|
||||
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
|
||||
setError(null)
|
||||
try {
|
||||
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form)
|
||||
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form, { signal: controller.signal })
|
||||
if (response.failed === 0) {
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
setResult(response)
|
||||
setProgressText(response.failed > 0 ? 'Upload completed with errors.' : 'Upload completed.')
|
||||
setProgressText('Upload completed with errors.')
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setProgressText('Upload canceled.')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to upload files')
|
||||
setProgressText('')
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +193,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void upload()} disabled={busy || files.length === 0} variant="contained">Upload</Button>
|
||||
<Button onClick={close} disabled={busy}>Close</Button>
|
||||
<Button onClick={close}>{busy ? 'Cancel' : 'Close'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from '../components/ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Link from '@mui/material/Link'
|
||||
|
||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from '../components/ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from '../components/ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from '../components/ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import '@xterm/xterm/css/xterm.css'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Dialog from '../components/ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
|
||||
Reference in New Issue
Block a user