Compare commits

...

3 Commits

43 changed files with 438 additions and 126 deletions
+241 -57
View File
@@ -3,6 +3,7 @@ package handlers
import codit_logger "codit/logger" import codit_logger "codit/logger"
import "archive/zip" import "archive/zip"
import "context"
import "database/sql" import "database/sql"
import "errors" import "errors"
import "fmt" import "fmt"
@@ -59,17 +60,24 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
var ok bool var ok bool
var store *db.Store var store *db.Store
var sessionItem models.SSHSession var sessionItem models.SSHSession
var multipartReader *multipart.Reader
var part *multipart.Part
var sshClient *ssh.Client var sshClient *ssh.Client
var sftpClient *sftp.Client var sftpClient *sftp.Client
var stopContextClose func()
var targetDir string var targetDir string
var overwrite bool var overwrite bool
var response sshSessionFileUploadResponse var response sshSessionFileUploadResponse
var files []*multipart.FileHeader
var file multipart.File
var item sshSessionFileUploadItem var item sshSessionFileUploadItem
var remotePath string 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 err error
var i int
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if !ok || user.Disabled { if !ok || user.Disabled {
@@ -77,61 +85,95 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, sshFileTransferMaxUploadBytes) r.Body = http.MaxBytesReader(w, r.Body, sshFileTransferMaxUploadBytes)
err = r.ParseMultipartForm(16 * 1024 * 1024) multipartReader, err = r.MultipartReader()
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
return return
} }
targetDir = strings.TrimSpace(r.FormValue("target_dir")) targetDir = "."
if targetDir == "" { for {
targetDir = "." if r.Context().Err() != nil {
} return
overwrite = parseSSHFileTransferBool(r.FormValue("overwrite")) }
if r.MultipartForm != nil { part, err = multipartReader.NextPart()
files = r.MultipartForm.File["files"] if err == io.EOF {
} break
if len(files) == 0 { }
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one file is required"}) if err != nil {
return WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
} return
if len(files) > sshFileTransferMaxFiles { }
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)}) fieldName = part.FormName()
return if fieldName != "files" {
} fieldValue, err = readMultipartFieldValue(part, 64 * 1024)
store = api.store(r) _ = part.Close()
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"]) if err != nil {
if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
api.writeSSHSessionFileTransferError(w, err) return
return }
} if fieldName == "target_dir" {
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, r.FormValue("password"), r.FormValue("otp_code")) targetDir = strings.TrimSpace(fieldValue)
if err != nil { if targetDir == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) targetDir = "."
return }
} } else if fieldName == "overwrite" {
defer sshClient.Close() overwrite = parseSSHFileTransferBool(fieldValue)
sftpClient, err = sftp.NewClient(sshClient) } else if fieldName == "password" {
if err != nil { password = fieldValue
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sftp subsystem unavailable: " + err.Error()}) } else if fieldName == "otp_code" {
return otpCode = fieldValue
} }
defer sftpClient.Close() continue
for i = 0; i < len(files); i++ { }
item = sshSessionFileUploadItem{Name: path.Base(files[i].Filename), Size: files[i].Size} 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()
opened = true
}
item = sshSessionFileUploadItem{Name: path.Base(part.FileName())}
if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") { if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") {
item.Error = "invalid file name" item.Error = "invalid file name"
response.Failed += 1 response.Failed += 1
response.Items = append(response.Items, item) response.Items = append(response.Items, item)
_ = part.Close()
continue continue
} }
remotePath = path.Join(targetDir, item.Name) remotePath = path.Join(targetDir, item.Name)
item.Path = remotePath item.Path = remotePath
file, err = files[i].Open() copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
if err == nil { _ = part.Close()
err = api.sftpUploadFile(sftpClient, targetDir, item.Name, file, overwrite) item.Size = copied
_ = file.Close()
}
if err != nil { if err != nil {
if r.Context().Err() != nil {
return
}
item.Error = err.Error() item.Error = err.Error()
response.Failed += 1 response.Failed += 1
} else { } else {
@@ -139,6 +181,10 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
} }
response.Items = append(response.Items, item) 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) WriteJSON(w, http.StatusOK, response)
} }
@@ -150,11 +196,13 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
var sessionItem models.SSHSession var sessionItem models.SSHSession
var sshClient *ssh.Client var sshClient *ssh.Client
var sftpClient *sftp.Client var sftpClient *sftp.Client
var stopContextClose func()
var items []sshSessionFileDownloadItem var items []sshSessionFileDownloadItem
var item sshSessionFileDownloadItem var item sshSessionFileDownloadItem
var remotePath string var remotePath string
var totalSize int64 var totalSize int64
var contentDisposition string var contentDisposition string
var archiveName string
var err error var err error
var i int 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()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
stopContextClose = closeSSHClientOnContext(r.Context(), sshClient)
defer stopContextClose()
defer sshClient.Close() defer sshClient.Close()
sftpClient, err = sftp.NewClient(sshClient) sftpClient, err = sftp.NewClient(sshClient)
if err != nil { if err != nil {
@@ -195,6 +245,9 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
} }
defer sftpClient.Close() defer sftpClient.Close()
for i = 0; i < len(req.Paths); i++ { for i = 0; i < len(req.Paths); i++ {
if r.Context().Err() != nil {
return
}
remotePath = strings.TrimSpace(req.Paths[i]) remotePath = strings.TrimSpace(req.Paths[i])
if remotePath == "" || strings.Contains(remotePath, "\x00") { if remotePath == "" || strings.Contains(remotePath, "\x00") {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"}) 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}) contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": items[0].Name})
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", contentDisposition) 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 { 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()) 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 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-Type", "application/zip")
w.Header().Set("Content-Disposition", contentDisposition) w.Header().Set("Content-Disposition", contentDisposition)
err = api.writeSSHSessionFileZip(sftpClient, items, w) err = api.writeSSHSessionFileZip(r.Context(), sftpClient, items, w)
if err != nil { if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file zip download failed session=%s err=%s", sessionItem.ID, err.Error()) 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()) 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 remotePath string
var flags int var flags int
var remoteFile *sftp.File var remoteFile *sftp.File
var copied int64
var statErr error
var err error var err error
if ctx.Err() != nil { return 0, ctx.Err() }
err = client.MkdirAll(targetDir) 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) 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 flags = os.O_WRONLY | os.O_CREATE
if overwrite { if overwrite {
flags = flags | os.O_TRUNC 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 flags = flags | os.O_EXCL
} }
remoteFile, err = client.OpenFile(remotePath, flags) remoteFile, err = client.OpenFile(remotePath, flags)
if err != nil { return err } if err != nil { return 0, err }
defer remoteFile.Close() defer remoteFile.Close()
_, err = io.Copy(remoteFile, reader) copied, err = copyWithContext(ctx, remoteFile, reader)
return err 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) { 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 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 remoteFile *sftp.File
var err error var err error
if ctx.Err() != nil { return ctx.Err() }
remoteFile, err = client.Open(remotePath) remoteFile, err = client.Open(remotePath)
if err != nil { return err } if err != nil { return err }
defer remoteFile.Close() defer remoteFile.Close()
_, err = io.Copy(writer, remoteFile) _, err = copyWithContext(ctx, writer, remoteFile)
return err 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 zipWriter *zip.Writer
var zipFile io.Writer var zipFile io.Writer
var closeErr error var closeErr error
@@ -389,9 +523,13 @@ func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFi
zipWriter = zip.NewWriter(writer) zipWriter = zip.NewWriter(writer)
for i = 0; i < len(items); i++ { for i = 0; i < len(items); i++ {
if ctx.Err() != nil {
err = ctx.Err()
break
}
zipFile, err = zipWriter.Create(sshDownloadEntryName(items[i], i)) zipFile, err = zipWriter.Create(sshDownloadEntryName(items[i], i))
if err != nil { break } 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 } if err != nil { break }
} }
closeErr = zipWriter.Close() closeErr = zipWriter.Close()
@@ -401,6 +539,52 @@ func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFi
return err 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 { func sshDownloadEntryName(item sshSessionFileDownloadItem, index int) string {
var name string var name string
+33 -4
View File
@@ -1172,6 +1172,13 @@ export interface BinaryDownload {
contentType: string contentType: string
} }
export interface StreamingDownload {
response: Response
filename: string
contentType: string
contentLength: number
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
credentials: 'include', credentials: 'include',
@@ -1241,6 +1248,26 @@ async function requestBinaryDownload(path: string, fallbackFilename: string, opt
return { data, filename, contentType } 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> { async function requestText(path: string, options: RequestInit = {}): Promise<string> {
const res = await fetch(path, { const res = await fetch(path, {
credentials: 'include', credentials: 'include',
@@ -1539,10 +1566,12 @@ export const api = {
getSSHSessionTranscriptForSelf: (id: string, options?: RequestInit) => requestText(`/api/ssh/sessions/${id}/transcript`, options), 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), 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' }), disconnectSSHSessionForSelf: (id: string) => request<void>(`/api/ssh/sessions/${id}/disconnect`, { method: 'POST' }),
uploadSSHSessionFilesForSelf: (id: string, form: FormData) => uploadSSHSessionFilesForSelf: (id: string, form: FormData, options?: RequestInit) =>
requestForm<SSHSessionFileUploadResponse>(`/api/ssh/sessions/${id}/files/upload`, { method: 'POST', body: form }), requestForm<SSHSessionFileUploadResponse>(`/api/ssh/sessions/${id}/files/upload`, { method: 'POST', body: form, ...(options || {}) }),
downloadSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload) => 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' } }), 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) => inspectSSHCertificate: (certificate: string) =>
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }), request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) => signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) =>
@@ -1,6 +1,6 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -1,6 +1,6 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
+14
View File
@@ -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 Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
+1 -1
View File
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,6 +1,6 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link' import Link from '@mui/material/Link'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Tab from '@mui/material/Tab' 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 VisibilityIcon from '@mui/icons-material/Visibility'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link' import Link from '@mui/material/Link'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -1,6 +1,6 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,6 +1,6 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import List from '@mui/material/List' import List from '@mui/material/List'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import List from '@mui/material/List' import List from '@mui/material/List'
@@ -1,6 +1,6 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
@@ -1,14 +1,14 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import LinearProgress from '@mui/material/LinearProgress' import LinearProgress from '@mui/material/LinearProgress'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' 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, BinaryDownload } from '../api' import { api, StreamingDownload } from '../api'
import PageAlert from './PageAlert' import PageAlert from './PageAlert'
type SSHSessionFileDownloadDialogProps = { type SSHSessionFileDownloadDialogProps = {
@@ -19,6 +19,13 @@ type SSHSessionFileDownloadDialogProps = {
onClose: () => void 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) { export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownloadDialogProps) {
const [pathsText, setPathsText] = useState('') const [pathsText, setPathsText] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -26,6 +33,7 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [progressText, setProgressText] = useState('') const [progressText, setProgressText] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => { useEffect(() => {
if (!props.open) return if (!props.open) return
@@ -37,7 +45,11 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
}, [props.open]) }, [props.open])
const close = () => { const close = () => {
if (busy) return if (busy) {
abortRef.current?.abort()
setProgressText('Canceling download...')
return
}
props.onClose() props.onClose()
} }
@@ -46,39 +58,76 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
.split('\n') .split('\n')
.map((item: string) => item.trim()) .map((item: string) => item.trim())
.filter((item: string) => item !== '') .filter((item: string) => item !== '')
let downloadResult: BinaryDownload const controller: AbortController = new AbortController()
let downloadResult: StreamingDownload
let blob: Blob let blob: Blob
let url: string let url: string
let link: HTMLAnchorElement 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) { if (paths.length === 0) {
setError('Enter at least one remote file path') setError('Enter at least one remote file path')
return 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) setBusy(true)
abortRef.current = controller
setProgressText(`Preparing ${paths.length} remote file(s)...`) setProgressText(`Preparing ${paths.length} remote file(s)...`)
setError(null) setError(null)
try { try {
downloadResult = await api.downloadSSHSessionFilesForSelf(props.sessionID, { downloadResult = await api.streamSSHSessionFilesForSelf(props.sessionID, {
paths, paths,
password: props.passwordRequired ? password : undefined, password: props.passwordRequired ? password : undefined,
otp_code: props.otpRequired ? otpCode : undefined otp_code: props.otpRequired ? otpCode : undefined
}) }, { signal: controller.signal })
blob = new Blob([downloadResult.data], { type: downloadResult.contentType }) setProgressText(downloadResult.contentLength > 0 ? `Downloading ${downloadResult.filename}...` : `Streaming ${downloadResult.filename}...`)
url = window.URL.createObjectURL(blob) if (fileHandle && downloadResult.response.body) {
link = document.createElement('a') writable = await fileHandle.createWritable()
link.href = url await downloadResult.response.body.pipeTo(writable, { signal: controller.signal })
link.download = downloadResult.filename } else {
document.body.appendChild(link) data = await downloadResult.response.arrayBuffer()
link.click() blob = new Blob([data], { type: downloadResult.contentType })
document.body.removeChild(link) url = window.URL.createObjectURL(blob)
window.URL.revokeObjectURL(url) link = document.createElement('a')
link.href = url
link.download = downloadResult.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
setProgressText('Download started.') setProgressText('Download started.')
props.onClose() props.onClose()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to download files') if (err instanceof Error && err.name === 'AbortError') {
setProgressText('') setProgressText('Download canceled.')
} else {
setError(err instanceof Error ? err.message : 'Failed to download files')
setProgressText('')
}
} finally { } finally {
if (abortRef.current === controller) {
abortRef.current = null
}
setBusy(false) setBusy(false)
} }
} }
@@ -126,8 +175,26 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void download()} disabled={busy} variant="contained">Download</Button> <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> </DialogActions>
</Dialog> </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 Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
@@ -9,7 +9,7 @@ import FormControlLabel from '@mui/material/FormControlLabel'
import LinearProgress from '@mui/material/LinearProgress' import LinearProgress from '@mui/material/LinearProgress'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' 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 { api, SSHSessionFileUploadItem, SSHSessionFileUploadResponse } from '../api'
import PageAlert from './PageAlert' import PageAlert from './PageAlert'
@@ -41,6 +41,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
const [progressText, setProgressText] = useState('') const [progressText, setProgressText] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SSHSessionFileUploadResponse | null>(null) const [result, setResult] = useState<SSHSessionFileUploadResponse | null>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => { useEffect(() => {
if (!props.open) return if (!props.open) return
@@ -55,7 +56,11 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
}, [props.open]) }, [props.open])
const close = () => { const close = () => {
if (busy) return if (busy) {
abortRef.current?.abort()
setProgressText('Canceling upload...')
return
}
props.onClose() props.onClose()
} }
@@ -69,6 +74,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
const upload = async () => { const upload = async () => {
const form: FormData = new FormData() const form: FormData = new FormData()
const controller: AbortController = new AbortController()
let response: SSHSessionFileUploadResponse let response: SSHSessionFileUploadResponse
let i: number let i: number
@@ -84,16 +90,28 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
form.append('files', files[i]) form.append('files', files[i])
} }
setBusy(true) setBusy(true)
abortRef.current = controller
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`) setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
setError(null) setError(null)
try { 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) setResult(response)
setProgressText(response.failed > 0 ? 'Upload completed with errors.' : 'Upload completed.') setProgressText('Upload completed with errors.')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload files') if (err instanceof Error && err.name === 'AbortError') {
setProgressText('') setProgressText('Upload canceled.')
} else {
setError(err instanceof Error ? err.message : 'Failed to upload files')
setProgressText('')
}
} finally { } finally {
if (abortRef.current === controller) {
abortRef.current = null
}
setBusy(false) setBusy(false)
} }
} }
@@ -175,7 +193,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void upload()} disabled={busy || files.length === 0} variant="contained">Upload</Button> <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> </DialogActions>
</Dialog> </Dialog>
) )
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
@@ -1,7 +1,7 @@
import DownloadIcon from '@mui/icons-material/Download' import DownloadIcon from '@mui/icons-material/Download'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
+1 -1
View File
@@ -2,7 +2,7 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Link from '@mui/material/Link' import Link from '@mui/material/Link'
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
@@ -1,7 +1,7 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
+1 -1
View File
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
+1 -1
View File
@@ -2,7 +2,7 @@ import '@xterm/xterm/css/xterm.css'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import Drawer from '@mui/material/Drawer' import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'