Compare commits

..

3 Commits

43 changed files with 438 additions and 126 deletions
+223 -39
View File
@@ -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
View File
@@ -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'
+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 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 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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 -1
View File
@@ -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'