Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbe48dec2b | |||
| 9d1e552aeb | |||
| ad144b874e | |||
| b14462d9a3 |
@@ -29,6 +29,7 @@ import "codit/internal/models"
|
||||
const sshFileTransferMaxUploadBytes int64 = 5 * 1024 * 1024 * 1024
|
||||
const sshFileTransferMaxDownloadBytes int64 = 10 * 1024 * 1024 * 1024
|
||||
const sshFileTransferMaxFiles int = 256
|
||||
const sshFileTransferCopyBufferSize int = 1024 * 1024
|
||||
|
||||
type sshSessionFileDownloadRequest struct {
|
||||
Paths []string `json:"paths"`
|
||||
@@ -36,6 +37,17 @@ type sshSessionFileDownloadRequest struct {
|
||||
OTPCode string `json:"otp_code"`
|
||||
}
|
||||
|
||||
type sshSessionFileCopyRequest struct {
|
||||
TargetSessionID string `json:"target_session_id"`
|
||||
Paths []string `json:"paths"`
|
||||
TargetDir string `json:"target_dir"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
SourcePassword string `json:"source_password"`
|
||||
SourceOTPCode string `json:"source_otp_code"`
|
||||
TargetPassword string `json:"target_password"`
|
||||
TargetOTPCode string `json:"target_otp_code"`
|
||||
}
|
||||
|
||||
type sshSessionFileUploadItem struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
@@ -55,6 +67,43 @@ type sshSessionFileDownloadItem struct {
|
||||
Size int64
|
||||
}
|
||||
|
||||
type sshSessionFileCopyItem struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
TargetPath string `json:"target_path"`
|
||||
Size int64 `json:"size"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type sshSessionFileCopyResponse struct {
|
||||
Items []sshSessionFileCopyItem `json:"items"`
|
||||
Copied int `json:"copied"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type sshFileTransferContextReader struct {
|
||||
Context context.Context
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
type sshFileTransferContextWriter struct {
|
||||
Context context.Context
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
func (r *sshFileTransferContextReader) Read(p []byte) (int, error) {
|
||||
if r.Context.Err() != nil {
|
||||
return 0, r.Context.Err()
|
||||
}
|
||||
return r.Reader.Read(p)
|
||||
}
|
||||
|
||||
func (w *sshFileTransferContextWriter) Write(p []byte) (int, error) {
|
||||
if w.Context.Err() != nil {
|
||||
return 0, w.Context.Err()
|
||||
}
|
||||
return w.Writer.Write(p)
|
||||
}
|
||||
|
||||
func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
@@ -74,6 +123,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
var fieldValue string
|
||||
var password string
|
||||
var otpCode string
|
||||
var seenUploadPaths map[string]bool
|
||||
var uploadCount int
|
||||
var opened bool
|
||||
var copied int64
|
||||
@@ -91,6 +141,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
targetDir = "."
|
||||
seenUploadPaths = make(map[string]bool)
|
||||
for {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
@@ -125,6 +176,20 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
continue
|
||||
}
|
||||
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)
|
||||
if seenUploadPaths[remotePath] {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
seenUploadPaths[remotePath] = true
|
||||
uploadCount += 1
|
||||
if uploadCount > sshFileTransferMaxFiles {
|
||||
_ = part.Close()
|
||||
@@ -157,15 +222,6 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
||||
defer sftpClient.Close()
|
||||
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
|
||||
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
|
||||
_ = part.Close()
|
||||
@@ -200,6 +256,8 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
var items []sshSessionFileDownloadItem
|
||||
var item sshSessionFileDownloadItem
|
||||
var remotePath string
|
||||
var seenPaths map[string]bool
|
||||
var pathCount int
|
||||
var totalSize int64
|
||||
var contentDisposition string
|
||||
var archiveName string
|
||||
@@ -220,10 +278,7 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) > sshFileTransferMaxFiles {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
}
|
||||
seenPaths = make(map[string]bool)
|
||||
store = api.store(r)
|
||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
@@ -253,6 +308,15 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"})
|
||||
return
|
||||
}
|
||||
if seenPaths[remotePath] {
|
||||
continue
|
||||
}
|
||||
seenPaths[remotePath] = true
|
||||
pathCount += 1
|
||||
if pathCount > sshFileTransferMaxFiles {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
}
|
||||
item, err = api.sftpDownloadMetadata(sftpClient, remotePath)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("%s: %s", remotePath, err.Error())})
|
||||
@@ -286,6 +350,187 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var store *db.Store
|
||||
var req sshSessionFileCopyRequest
|
||||
var sourceSession models.SSHSession
|
||||
var targetSession models.SSHSession
|
||||
var sourceSSHClient *ssh.Client
|
||||
var targetSSHClient *ssh.Client
|
||||
var sourceSFTPClient *sftp.Client
|
||||
var targetSFTPClient *sftp.Client
|
||||
var stopSourceClose func()
|
||||
var stopTargetClose func()
|
||||
var response sshSessionFileCopyResponse
|
||||
var item sshSessionFileCopyItem
|
||||
var metadata sshSessionFileDownloadItem
|
||||
var remotePath string
|
||||
var targetDir string
|
||||
var seenPaths map[string]bool
|
||||
var pathCount int
|
||||
var totalSize int64
|
||||
var startedAt time.Time
|
||||
var duration time.Duration
|
||||
var err error
|
||||
var i int
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || user.Disabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TargetSessionID) == "" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target_session_id is required"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TargetSessionID) == strings.TrimSpace(params["id"]) {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target session must be different from source session"})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) == 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
||||
return
|
||||
}
|
||||
targetDir = strings.TrimSpace(req.TargetDir)
|
||||
if targetDir == "" {
|
||||
targetDir = "."
|
||||
}
|
||||
if strings.Contains(targetDir, "\x00") {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid target directory"})
|
||||
return
|
||||
}
|
||||
seenPaths = make(map[string]bool)
|
||||
store = api.store(r)
|
||||
sourceSession, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
return
|
||||
}
|
||||
targetSession, err = api.getSSHSessionFileTransferItem(store, user, req.TargetSessionID)
|
||||
if err != nil {
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
return
|
||||
}
|
||||
sourceSSHClient, err = api.openSSHSessionFileTransferClient(store, user, sourceSession, req.SourcePassword, req.SourceOTPCode)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "source session: " + err.Error()})
|
||||
return
|
||||
}
|
||||
stopSourceClose = closeSSHClientOnContext(r.Context(), sourceSSHClient)
|
||||
defer stopSourceClose()
|
||||
defer sourceSSHClient.Close()
|
||||
targetSSHClient, err = api.openSSHSessionFileTransferClient(store, user, targetSession, req.TargetPassword, req.TargetOTPCode)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target session: " + err.Error()})
|
||||
return
|
||||
}
|
||||
stopTargetClose = closeSSHClientOnContext(r.Context(), targetSSHClient)
|
||||
defer stopTargetClose()
|
||||
defer targetSSHClient.Close()
|
||||
sourceSFTPClient, err = sftp.NewClient(sourceSSHClient)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "source sftp subsystem unavailable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer sourceSFTPClient.Close()
|
||||
targetSFTPClient, err = sftp.NewClient(targetSSHClient)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target sftp subsystem unavailable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer targetSFTPClient.Close()
|
||||
for i = 0; i < len(req.Paths); i++ {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
remotePath = strings.TrimSpace(req.Paths[i])
|
||||
item = sshSessionFileCopyItem{SourcePath: remotePath}
|
||||
if remotePath == "" || strings.Contains(remotePath, "\x00") {
|
||||
item.Error = "invalid source path"
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
if seenPaths[remotePath] {
|
||||
continue
|
||||
}
|
||||
seenPaths[remotePath] = true
|
||||
pathCount += 1
|
||||
if pathCount > sshFileTransferMaxFiles {
|
||||
item.Error = fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
break
|
||||
}
|
||||
metadata, err = api.sftpDownloadMetadata(sourceSFTPClient, remotePath)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
totalSize += metadata.Size
|
||||
if totalSize > sshFileTransferMaxDownloadBytes {
|
||||
item.Error = "copy size exceeds limit"
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
break
|
||||
}
|
||||
item.TargetPath = path.Join(targetDir, metadata.Name)
|
||||
startedAt = time.Now()
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||
"ssh file copy start source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d",
|
||||
sourceSession.ID,
|
||||
sourceSession.ServerName,
|
||||
targetSession.ID,
|
||||
targetSession.ServerName,
|
||||
metadata.RemotePath,
|
||||
item.TargetPath,
|
||||
metadata.Size)
|
||||
item.Size, err = api.sftpCopyFile(r.Context(), sourceSFTPClient, targetSFTPClient, metadata.RemotePath, targetDir, metadata.Name, req.Overwrite)
|
||||
duration = time.Since(startedAt)
|
||||
if err != nil {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
|
||||
"ssh file copy failed source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d dur_ms=%d err=%s",
|
||||
sourceSession.ID,
|
||||
sourceSession.ServerName,
|
||||
targetSession.ID,
|
||||
targetSession.ServerName,
|
||||
metadata.RemotePath,
|
||||
item.TargetPath,
|
||||
item.Size,
|
||||
duration.Milliseconds(),
|
||||
err.Error())
|
||||
item.Error = err.Error()
|
||||
response.Failed += 1
|
||||
} else {
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||
"ssh file copy done source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d dur_ms=%d",
|
||||
sourceSession.ID,
|
||||
sourceSession.ServerName,
|
||||
targetSession.ID,
|
||||
targetSession.ServerName,
|
||||
metadata.RemotePath,
|
||||
item.TargetPath,
|
||||
item.Size,
|
||||
duration.Milliseconds())
|
||||
response.Copied += 1
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (api *API) getSSHSessionFileTransferItem(store *db.Store, user models.User, sessionID string) (models.SSHSession, error) {
|
||||
var item models.SSHSession
|
||||
var err error
|
||||
@@ -514,6 +759,19 @@ func (api *API) sftpDownloadFile(ctx context.Context, client *sftp.Client, remot
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *API) sftpCopyFile(ctx context.Context, sourceClient *sftp.Client, targetClient *sftp.Client, sourcePath string, targetDir string, name string, overwrite bool) (int64, error) {
|
||||
var sourceFile *sftp.File
|
||||
var copied int64
|
||||
var err error
|
||||
|
||||
if ctx.Err() != nil { return 0, ctx.Err() }
|
||||
sourceFile, err = sourceClient.Open(sourcePath)
|
||||
if err != nil { return 0, err }
|
||||
defer sourceFile.Close()
|
||||
copied, err = api.sftpUploadFile(ctx, targetClient, targetDir, name, sourceFile, overwrite)
|
||||
return copied, err
|
||||
}
|
||||
|
||||
func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
||||
var zipWriter *zip.Writer
|
||||
var zipFile io.Writer
|
||||
@@ -540,37 +798,27 @@ func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client,
|
||||
}
|
||||
|
||||
func copyWithContext(ctx context.Context, writer io.Writer, reader io.Reader) (int64, error) {
|
||||
var contextWriter *sshFileTransferContextWriter
|
||||
var contextReader *sshFileTransferContextReader
|
||||
var buffer []byte
|
||||
var written int64
|
||||
var nr int
|
||||
var nw int
|
||||
var er error
|
||||
var ew error
|
||||
var writerTo io.WriterTo
|
||||
var readerFrom io.ReaderFrom
|
||||
|
||||
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
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
contextWriter = &sshFileTransferContextWriter{Context: ctx, Writer: writer}
|
||||
contextReader = &sshFileTransferContextReader{Context: ctx, Reader: reader}
|
||||
writerTo, _ = reader.(io.WriterTo)
|
||||
if writerTo != nil {
|
||||
return writerTo.WriteTo(contextWriter)
|
||||
}
|
||||
readerFrom, _ = writer.(io.ReaderFrom)
|
||||
if readerFrom != nil {
|
||||
return readerFrom.ReadFrom(contextReader)
|
||||
}
|
||||
buffer = make([]byte, sshFileTransferCopyBufferSize)
|
||||
return io.CopyBuffer(contextWriter, contextReader, buffer)
|
||||
}
|
||||
|
||||
func readMultipartFieldValue(part *multipart.Part, maxBytes int64) (string, error) {
|
||||
|
||||
@@ -793,6 +793,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/disconnect", api.DisconnectSSHSessionForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/files/upload", api.UploadSSHSessionFilesForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/files/download", api.DownloadSSHSessionFilesForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/files/copy-to", api.CopySSHSessionFilesForSelf)
|
||||
router.Handle("POST", "/api/ssh/cert/inspect", api.InspectSSHCertificate)
|
||||
router.Handle("POST", "/api/ssh/user-cas/:id/sign", api.SignSSHUserKeyForSelf)
|
||||
router.Handle("GET", "/api/ssh/issuances", api.ListSSHUserCAIssuancesForSelf)
|
||||
|
||||
@@ -5970,6 +5970,39 @@ paths:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/api/ssh/sessions/{id}/files/copy-to:
|
||||
post:
|
||||
tags:
|
||||
- SSH Self Service
|
||||
summary: Copy Files Between SSH Sessions
|
||||
operationId: CopySSHSessionFilesForSelf
|
||||
x-codit-handler: CopySSHSessionFilesForSelf
|
||||
description: Copies files from one connected SSH session to another connected SSH session over SFTP. File data is relayed by the backend and does not pass through the browser.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SSHSessionFileCopyRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SSHSessionFileCopyResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/api/ssh/cert/inspect:
|
||||
post:
|
||||
tags:
|
||||
@@ -10278,6 +10311,55 @@ components:
|
||||
otp_code:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SSHSessionFileCopyRequest:
|
||||
type: object
|
||||
properties:
|
||||
target_session_id:
|
||||
type: string
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
target_dir:
|
||||
type: string
|
||||
overwrite:
|
||||
type: boolean
|
||||
source_password:
|
||||
type: string
|
||||
format: password
|
||||
source_otp_code:
|
||||
type: string
|
||||
target_password:
|
||||
type: string
|
||||
format: password
|
||||
target_otp_code:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
SSHSessionFileCopyItem:
|
||||
type: object
|
||||
properties:
|
||||
source_path:
|
||||
type: string
|
||||
target_path:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
error:
|
||||
type: string
|
||||
additionalProperties: true
|
||||
SSHSessionFileCopyResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SSHSessionFileCopyItem'
|
||||
copied:
|
||||
type: integer
|
||||
failed:
|
||||
type: integer
|
||||
additionalProperties: true
|
||||
SSHSessionFileUploadItem:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1166,6 +1166,30 @@ export interface SSHSessionFileDownloadPayload {
|
||||
otp_code?: string
|
||||
}
|
||||
|
||||
export interface SSHSessionFileCopyPayload {
|
||||
target_session_id: string
|
||||
paths: string[]
|
||||
target_dir: string
|
||||
overwrite: boolean
|
||||
source_password?: string
|
||||
source_otp_code?: string
|
||||
target_password?: string
|
||||
target_otp_code?: string
|
||||
}
|
||||
|
||||
export interface SSHSessionFileCopyItem {
|
||||
source_path: string
|
||||
target_path: string
|
||||
size: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SSHSessionFileCopyResponse {
|
||||
items: SSHSessionFileCopyItem[]
|
||||
copied: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface BinaryDownload {
|
||||
data: ArrayBuffer
|
||||
filename: string
|
||||
@@ -1572,6 +1596,8 @@ export const api = {
|
||||
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 || {}) }),
|
||||
copySSHSessionFilesForSelf: (id: string, payload: SSHSessionFileCopyPayload, options?: RequestInit) =>
|
||||
request<SSHSessionFileCopyResponse>(`/api/ssh/sessions/${id}/files/copy-to`, { 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) =>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { api, SSHSession, SSHSessionFileCopyItem, SSHSessionFileCopyResponse } from '../api'
|
||||
import PageAlert from './PageAlert'
|
||||
|
||||
type SSHSessionFileCopyDialogProps = {
|
||||
open: boolean
|
||||
sessionID: string
|
||||
sourceSession: SSHSession | null
|
||||
peerSessionIDs: string[]
|
||||
sourcePasswordRequired: boolean
|
||||
sourceOTPRequired: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function sessionOptionLabel(item: SSHSession): string {
|
||||
const serverName: string = item.server_name || item.server_id
|
||||
const target: string = `${item.remote_username}@${item.host}:${item.port}`
|
||||
|
||||
return `${serverName} (${target})`
|
||||
}
|
||||
|
||||
function needsPassword(item: SSHSession | null): boolean {
|
||||
return item?.auth_method === 'prompted_password'
|
||||
}
|
||||
|
||||
function needsOTP(item: SSHSession | null): boolean {
|
||||
return item?.second_factor_mode === 'prompted_totp'
|
||||
}
|
||||
|
||||
function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
if (left.server_id && right.server_id && left.server_id === right.server_id && left.remote_username === right.remote_username) {
|
||||
return true
|
||||
}
|
||||
return left.host === right.host && left.port === right.port && left.remote_username === right.remote_username
|
||||
}
|
||||
|
||||
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
|
||||
const [pathsText, setPathsText] = useState('')
|
||||
const [targetDir, setTargetDir] = useState('.')
|
||||
const [overwrite, setOverwrite] = useState(true)
|
||||
const [targetSessionID, setTargetSessionID] = useState('')
|
||||
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
|
||||
const [sourcePassword, setSourcePassword] = useState('')
|
||||
const [sourceOTPCode, setSourceOTPCode] = useState('')
|
||||
const [targetPassword, setTargetPassword] = useState('')
|
||||
const [targetOTPCode, setTargetOTPCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loadingTargets, setLoadingTargets] = useState(false)
|
||||
const [progressText, setProgressText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SSHSessionFileCopyResponse | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const targetSession: SSHSession | null = targetSessions.find((item: SSHSession) => item.id === targetSessionID) || null
|
||||
|
||||
useEffect(() => {
|
||||
const controller: AbortController = new AbortController()
|
||||
let mounted: boolean
|
||||
|
||||
mounted = true
|
||||
if (!props.open) return
|
||||
setPathsText('')
|
||||
setTargetDir('.')
|
||||
setOverwrite(true)
|
||||
setTargetSessionID('')
|
||||
setTargetSessions([])
|
||||
setSourcePassword('')
|
||||
setSourceOTPCode('')
|
||||
setTargetPassword('')
|
||||
setTargetOTPCode('')
|
||||
setProgressText('')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setLoadingTargets(true)
|
||||
void Promise.all(props.peerSessionIDs.filter((id: string) => id !== props.sessionID).map((id: string) => api.getSSHSessionForSelf(id, { signal: controller.signal })))
|
||||
.then((items: SSHSession[]) => {
|
||||
const connectedItems: SSHSession[] = items.filter((item: SSHSession) => item.status === 'connected' && !isSameSessionTarget(props.sourceSession, item))
|
||||
|
||||
if (!mounted) return
|
||||
setTargetSessions(connectedItems)
|
||||
if (connectedItems.length === 1) {
|
||||
setTargetSessionID(connectedItems[0].id)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mounted || controller.signal.aborted) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load target sessions')
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted && !controller.signal.aborted) setLoadingTargets(false)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [props.open, props.peerSessionIDs, props.sessionID, props.sourceSession])
|
||||
|
||||
const close = () => {
|
||||
if (busy) {
|
||||
abortRef.current?.abort()
|
||||
setProgressText('Canceling copy...')
|
||||
return
|
||||
}
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const copyFiles = async () => {
|
||||
const rawPaths: string[] = pathsText
|
||||
.split('\n')
|
||||
.map((item: string) => item.trim())
|
||||
.filter((item: string) => item !== '')
|
||||
const paths: string[] = []
|
||||
const seenPaths: Record<string, boolean> = {}
|
||||
const controller: AbortController = new AbortController()
|
||||
let response: SSHSessionFileCopyResponse
|
||||
let i: number
|
||||
|
||||
for (i = 0; i < rawPaths.length; i += 1) {
|
||||
if (seenPaths[rawPaths[i]]) {
|
||||
continue
|
||||
}
|
||||
seenPaths[rawPaths[i]] = true
|
||||
paths.push(rawPaths[i])
|
||||
}
|
||||
if (!targetSessionID) {
|
||||
setError('Select a target SSH session')
|
||||
return
|
||||
}
|
||||
if (paths.length === 0) {
|
||||
setError('Enter at least one source file path')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
abortRef.current = controller
|
||||
setProgressText(`Copying ${paths.length} file(s)...`)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
response = await api.copySSHSessionFilesForSelf(props.sessionID, {
|
||||
target_session_id: targetSessionID,
|
||||
paths,
|
||||
target_dir: targetDir.trim() || '.',
|
||||
overwrite,
|
||||
source_password: props.sourcePasswordRequired ? sourcePassword : undefined,
|
||||
source_otp_code: props.sourceOTPRequired ? sourceOTPCode : undefined,
|
||||
target_password: needsPassword(targetSession) ? targetPassword : undefined,
|
||||
target_otp_code: needsOTP(targetSession) ? targetOTPCode : undefined
|
||||
}, { signal: controller.signal })
|
||||
if (response.failed === 0) {
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
setResult(response)
|
||||
setProgressText('Copy completed with errors.')
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setProgressText('Copy canceled.')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy files')
|
||||
setProgressText('')
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={close} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Copy Files To Session</DialogTitle>
|
||||
<DialogContent sx={{ display: 'grid', gap: 2, pt: 1 }}>
|
||||
<PageAlert message={error} onClose={() => setError(null)} />
|
||||
<TextField
|
||||
label="Target session"
|
||||
select
|
||||
value={targetSessionID}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetSessionID(event.target.value)}
|
||||
helperText={targetSessions.length === 0 ? 'No other connected SSH sessions are available.' : 'Files are copied directly between SSH servers by the backend.'}
|
||||
disabled={loadingTargets || busy}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{targetSessions.map((item: SSHSession) => (
|
||||
<MenuItem key={item.id} value={item.id}>{sessionOptionLabel(item)}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Source file paths"
|
||||
value={pathsText}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setPathsText(event.target.value)}
|
||||
helperText="Enter one source file path per line. Directories are not supported."
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Target directory"
|
||||
value={targetDir}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetDir(event.target.value)}
|
||||
helperText="Files are copied into this directory on the target SSH server."
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={overwrite} onChange={(event: ReactChangeEvent<HTMLInputElement>) => setOverwrite(event.target.checked)} />}
|
||||
label="Overwrite existing target files"
|
||||
/>
|
||||
{props.sourcePasswordRequired ? (
|
||||
<TextField
|
||||
label="Source password"
|
||||
type="password"
|
||||
value={sourcePassword}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSourcePassword(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{props.sourceOTPRequired ? (
|
||||
<TextField
|
||||
label="Source OTP code"
|
||||
value={sourceOTPCode}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSourceOTPCode(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{needsPassword(targetSession) ? (
|
||||
<TextField
|
||||
label="Target password"
|
||||
type="password"
|
||||
value={targetPassword}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetPassword(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{needsOTP(targetSession) ? (
|
||||
<TextField
|
||||
label="Target OTP code"
|
||||
value={targetOTPCode}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetOTPCode(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
<PageAlert
|
||||
severity={result && result.failed > 0 ? 'warning' : 'success'}
|
||||
message={result ? `Copied ${result.copied}; failed ${result.failed}.` : null}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
{result && result.items.length > 0 ? (
|
||||
<Box sx={{ display: 'grid', maxHeight: 140, overflow: 'auto' }}>
|
||||
{result.items.map((item: SSHSessionFileCopyItem, index: number) => (
|
||||
<Typography key={`${item.source_path}-${index}`} variant="body2" color={item.error ? 'error' : 'text.secondary'}>
|
||||
{item.error ? 'Failed' : 'Copied'}: {item.source_path} → {item.target_path || '-'}{item.error ? ` - ${item.error}` : ''}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{busy || loadingTargets ? (
|
||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||
<LinearProgress />
|
||||
<Typography variant="caption" color="text.secondary">{progressText || 'Loading target sessions...'}</Typography>
|
||||
</Box>
|
||||
) : progressText ? (
|
||||
<Typography variant="caption" color="text.secondary">{progressText}</Typography>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void copyFiles()} disabled={busy || loadingTargets || !targetSessionID} variant="contained">Copy</Button>
|
||||
<Button onClick={close}>{busy ? 'Cancel' : 'Close'}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -54,10 +54,12 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const paths: string[] = pathsText
|
||||
const rawPaths: string[] = pathsText
|
||||
.split('\n')
|
||||
.map((item: string) => item.trim())
|
||||
.filter((item: string) => item !== '')
|
||||
const paths: string[] = []
|
||||
const seenPaths: Record<string, boolean> = {}
|
||||
const controller: AbortController = new AbortController()
|
||||
let downloadResult: StreamingDownload
|
||||
let blob: Blob
|
||||
@@ -68,7 +70,15 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
||||
let writable: WritableStream<Uint8Array>
|
||||
let data: ArrayBuffer
|
||||
let canUseSavePicker: boolean
|
||||
let i: number
|
||||
|
||||
for (i = 0; i < rawPaths.length; i += 1) {
|
||||
if (seenPaths[rawPaths[i]]) {
|
||||
continue
|
||||
}
|
||||
seenPaths[rawPaths[i]] = true
|
||||
paths.push(rawPaths[i])
|
||||
}
|
||||
if (paths.length === 0) {
|
||||
setError('Enter at least one remote file path')
|
||||
return
|
||||
|
||||
@@ -75,6 +75,9 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
const upload = async () => {
|
||||
const form: FormData = new FormData()
|
||||
const controller: AbortController = new AbortController()
|
||||
const uniqueFiles: File[] = []
|
||||
const seenNames: Record<string, boolean> = {}
|
||||
const nextTargetDir: string = targetDir.trim() || '.'
|
||||
let response: SSHSessionFileUploadResponse
|
||||
let i: number
|
||||
|
||||
@@ -82,16 +85,23 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
setError('Select at least one file')
|
||||
return
|
||||
}
|
||||
form.append('target_dir', targetDir.trim() || '.')
|
||||
for (i = 0; i < files.length; i += 1) {
|
||||
if (seenNames[files[i].name]) {
|
||||
continue
|
||||
}
|
||||
seenNames[files[i].name] = true
|
||||
uniqueFiles.push(files[i])
|
||||
}
|
||||
form.append('target_dir', nextTargetDir)
|
||||
form.append('overwrite', overwrite ? 'true' : 'false')
|
||||
if (props.passwordRequired) form.append('password', password)
|
||||
if (props.otpRequired) form.append('otp_code', otpCode)
|
||||
for (i = 0; i < files.length; i += 1) {
|
||||
form.append('files', files[i])
|
||||
for (i = 0; i < uniqueFiles.length; i += 1) {
|
||||
form.append('files', uniqueFiles[i])
|
||||
}
|
||||
setBusy(true)
|
||||
abortRef.current = controller
|
||||
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
|
||||
setProgressText(`Uploading ${uniqueFiles.length} file(s) to ${nextTargetDir}...`)
|
||||
setError(null)
|
||||
try {
|
||||
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form, { signal: controller.signal })
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
|
||||
import SSHSessionFileCopyDialog from '../components/SSHSessionFileCopyDialog'
|
||||
import SSHSessionFileDownloadDialog from '../components/SSHSessionFileDownloadDialog'
|
||||
import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog'
|
||||
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||
@@ -56,6 +57,7 @@ type RowDragState = {
|
||||
|
||||
type SessionPanelProps = {
|
||||
sessionID: string
|
||||
openSessionIDs: string[]
|
||||
layoutToken: string
|
||||
registerStream: (sessionID: string, handlers: SessionStreamHandlers) => void
|
||||
unregisterStream: (sessionID: string) => void
|
||||
@@ -151,7 +153,7 @@ function isSSHSessionStreamActive(status: string): boolean {
|
||||
}
|
||||
|
||||
function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const { sessionID, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props
|
||||
const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props
|
||||
const [session, setSession] = useState<SSHSession | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -165,6 +167,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const [actionMenuAnchor, setActionMenuAnchor] = useState<HTMLElement | null>(null)
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false)
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
@@ -179,6 +182,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const canSessionAction = Boolean(session) && !actionPending
|
||||
const canDownloadBuffer = Boolean(terminalRef.current)
|
||||
const canTransferFiles = status === 'connected' && Boolean(session) && !actionPending
|
||||
const canCopyFiles = canTransferFiles && openSessionIDs.some((id: string) => id !== sessionID)
|
||||
const needsPasswordPrompt = Boolean(session && requiresPromptedPassword(session))
|
||||
const needsOTPPrompt = Boolean(session && requiresPromptedTOTP(session))
|
||||
|
||||
@@ -569,6 +573,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
setDownloadDialogOpen(false)
|
||||
}
|
||||
|
||||
const openCopyDialog = () => {
|
||||
handleCloseActionMenu()
|
||||
setCopyDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeCopyDialog = () => {
|
||||
setCopyDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleClosePanel = async () => {
|
||||
if (!canReconnect) {
|
||||
await handleDisconnect()
|
||||
@@ -635,6 +648,9 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
<MenuItem onClick={openDownloadDialog} disabled={!canTransferFiles}>
|
||||
Download Files
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openCopyDialog} disabled={!canCopyFiles}>
|
||||
Copy To Session
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseActionMenu()
|
||||
@@ -698,6 +714,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
otpRequired={needsOTPPrompt}
|
||||
onClose={closeDownloadDialog}
|
||||
/>
|
||||
<SSHSessionFileCopyDialog
|
||||
open={copyDialogOpen}
|
||||
sessionID={sessionID}
|
||||
sourceSession={session}
|
||||
peerSessionIDs={openSessionIDs}
|
||||
sourcePasswordRequired={needsPasswordPrompt}
|
||||
sourceOTPRequired={needsOTPPrompt}
|
||||
onClose={closeCopyDialog}
|
||||
/>
|
||||
|
||||
</TintedPanel>
|
||||
)
|
||||
@@ -1664,6 +1689,7 @@ export default function SSHSessionPage() {
|
||||
>
|
||||
<SessionTerminalPanel
|
||||
sessionID={id}
|
||||
openSessionIDs={openSessionIDs}
|
||||
layoutToken={layoutToken}
|
||||
registerStream={registerStream}
|
||||
unregisterStream={unregisterStream}
|
||||
@@ -1752,6 +1778,7 @@ export default function SSHSessionPage() {
|
||||
>
|
||||
<SessionTerminalPanel
|
||||
sessionID={id}
|
||||
openSessionIDs={openSessionIDs}
|
||||
layoutToken={layoutToken}
|
||||
registerStream={registerStream}
|
||||
unregisterStream={unregisterStream}
|
||||
|
||||
Reference in New Issue
Block a user