feat: enable changelog generation in release workflow #2
@@ -167,7 +167,7 @@ func (a *App) Delete(connId, path string) error {
|
|||||||
return exp.Delete(path)
|
return exp.Delete(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64) error {
|
func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64, verify bool) error {
|
||||||
srcExp, err := a.getExplorerForConnection(srcConnId)
|
srcExp, err := a.getExplorerForConnection(srcConnId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -185,7 +185,7 @@ func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp)
|
return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp, verify)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetTransfers() []*transfer.Transfer {
|
func (a *App) GetTransfers() []*transfer.Transfer {
|
||||||
@@ -232,7 +232,7 @@ func (a *App) PromptUploadFiles(connId, destPath string) error {
|
|||||||
remotePath = destPath + fileName
|
remotePath = destPath + fileName
|
||||||
}
|
}
|
||||||
|
|
||||||
a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size())
|
a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size(), false)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -272,7 +272,7 @@ func (a *App) PromptUploadDirectory(connId, destPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size())
|
a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size(), false)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ func (a *App) PromptDownload(connId, remotePath string) error {
|
|||||||
size = stat.Size
|
size = stat.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size)
|
return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransferItem struct {
|
type TransferItem struct {
|
||||||
@@ -314,7 +314,7 @@ type TransferItem struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem) error {
|
func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem, verify bool) error {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if !item.IsDir {
|
if !item.IsDir {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
@@ -328,18 +328,18 @@ func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []Transf
|
|||||||
remotePath = dstPath + item.Name
|
remotePath = dstPath + item.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size); err != nil {
|
if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size, verify); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Run remote walk in goroutine to not block UI
|
// Run remote walk in goroutine to not block UI
|
||||||
go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath)
|
go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath, verify)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string) {
|
func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string, verify bool) {
|
||||||
baseName := filepath.Base(srcDirPath)
|
baseName := filepath.Base(srcDirPath)
|
||||||
|
|
||||||
newDstPath := dstBasePath
|
newDstPath := dstBasePath
|
||||||
@@ -360,11 +360,11 @@ func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBaseP
|
|||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir {
|
if entry.IsDir {
|
||||||
a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath)
|
a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath, verify)
|
||||||
} else {
|
} else {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
itemDstPath := newDstPath + "/" + entry.Name
|
itemDstPath := newDstPath + "/" + entry.Name
|
||||||
a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size)
|
a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size, verify)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,15 @@
|
|||||||
<tbody id="transfer-dest-list"></tbody>
|
<tbody id="transfer-dest-list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem; flex-direction: row; margin-bottom: 1rem;">
|
||||||
|
<input type="checkbox" id="transfer-verify" style="width: auto;">
|
||||||
|
<label style="margin: 0; display: flex; flex-direction: column;">
|
||||||
|
Verify Checksum (MD5)
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-secondary); font-weight: normal;">Slower for non-S3 destinations as it requires re-reading the file over the network.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: flex-end; gap: 1rem;">
|
<div style="display: flex; justify-content: flex-end; gap: 1rem;">
|
||||||
<button class="btn" style="background: transparent; color: var(--text-primary); border-color: var(--border);" onclick="closeTransferModal()">Cancel</button>
|
<button class="btn" style="background: transparent; color: var(--text-primary); border-color: var(--border);" onclick="closeTransferModal()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="executeTransfer()">Transfer Here</button>
|
<button class="btn btn-primary" onclick="executeTransfer()">Transfer Here</button>
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ window.openTransferModal = () => {
|
|||||||
|
|
||||||
window.closeTransferModal = () => {
|
window.closeTransferModal = () => {
|
||||||
document.getElementById('transfer-modal').style.display = 'none';
|
document.getElementById('transfer-modal').style.display = 'none';
|
||||||
|
document.getElementById('transfer-verify').checked = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.loadTransferDestRoot = () => {
|
window.loadTransferDestRoot = () => {
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ type Explorer interface {
|
|||||||
Rename(src, dst string) error
|
Rename(src, dst string) error
|
||||||
ReadFile(path string) (io.ReadCloser, error)
|
ReadFile(path string) (io.ReadCloser, error)
|
||||||
WriteFile(path string, r io.Reader, size int64) error
|
WriteFile(path string, r io.Reader, size int64) error
|
||||||
|
Checksum(path string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package ftp
|
package ftp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -177,3 +179,16 @@ func (e *FTPExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
|||||||
func (e *FTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
func (e *FTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||||
return e.client.Stor(path, r)
|
return e.client.Stor(path, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *FTPExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package local
|
package local
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -82,6 +84,19 @@ func (e *LocalExplorer) Rename(src, dst string) error {
|
|||||||
return os.Rename(src, dst)
|
return os.Rename(src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *LocalExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||||
return os.Open(path)
|
return os.Open(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
package nfs
|
package nfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -125,3 +127,16 @@ func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
|||||||
_, err = io.Copy(f, r)
|
_, err = io.Copy(f, r)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *NFSExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ func (e *NFSExplorer) Delete(path string) error { return nil }
|
|||||||
func (e *NFSExplorer) Rename(src, dst string) error { return nil }
|
func (e *NFSExplorer) Rename(src, dst string) error { return nil }
|
||||||
func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) { return nil, nil }
|
func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) { return nil, nil }
|
||||||
func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { return nil }
|
func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { return nil }
|
||||||
|
func (e *NFSExplorer) Checksum(path string) (string, error) { return "", nil }
|
||||||
|
|||||||
@@ -307,3 +307,28 @@ func (e *S3Explorer) WriteFile(path string, r io.Reader, size int64) error {
|
|||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *S3Explorer) Checksum(path string) (string, error) {
|
||||||
|
bucket, subpath, _, err := e.getBucketAndPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if subpath == "" {
|
||||||
|
return "", fmt.Errorf("cannot calculate checksum of bucket")
|
||||||
|
}
|
||||||
|
|
||||||
|
input := &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(bucket),
|
||||||
|
Key: aws.String(subpath),
|
||||||
|
}
|
||||||
|
head, err := e.client.HeadObject(context.TODO(), input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if head.ETag != nil {
|
||||||
|
return strings.Trim(*head.ETag, "\""), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no etag found")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sftp
|
package sftp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -162,3 +164,16 @@ func (e *SFTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
|||||||
_, err = io.Copy(f, r)
|
_, err = io.Copy(f, r)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *SFTPExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package smb
|
package smb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -243,3 +245,16 @@ func (e *SMBExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
|||||||
_, err = io.Copy(f, r)
|
_, err = io.Copy(f, r)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *SMBExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package webdav
|
package webdav
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -102,3 +104,16 @@ func (e *WebDAVExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
|||||||
func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||||
return e.client.WriteStream(path, r, 0644)
|
return e.client.WriteStream(path, r, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *WebDAVExplorer) Checksum(path string) (string, error) {
|
||||||
|
r, err := e.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
h := md5.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package transfer
|
package transfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ type Transfer struct {
|
|||||||
ETA int `json:"eta_seconds"`
|
ETA int `json:"eta_seconds"`
|
||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Verify bool `json:"verify"`
|
||||||
|
|
||||||
srcExp explorer.Explorer
|
srcExp explorer.Explorer
|
||||||
dstExp explorer.Explorer
|
dstExp explorer.Explorer
|
||||||
@@ -81,12 +86,36 @@ func (m *Manager) doTransfer(t *Transfer) error {
|
|||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
tr := io.TeeReader(r, hash)
|
||||||
|
|
||||||
pr := &progressReader{
|
pr := &progressReader{
|
||||||
r: r,
|
r: tr,
|
||||||
t: t,
|
t: t,
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal)
|
if err := t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Verify {
|
||||||
|
expectedHash := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
t.Status = StatusActive // Still active during verification
|
||||||
|
|
||||||
|
actualHash, err := t.dstExp.Checksum(t.Destination)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verification failed: could not calculate destination checksum: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedHash != actualHash {
|
||||||
|
// S3 Multi-part ETags end with -N, we can't easily verify them with local md5
|
||||||
|
if !strings.Contains(actualHash, "-") {
|
||||||
|
return fmt.Errorf("verification failed: checksum mismatch (expected %s, got %s)", expectedHash, actualHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type progressReader struct {
|
type progressReader struct {
|
||||||
@@ -109,7 +138,7 @@ func (pr *progressReader) Read(p []byte) (int, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer) error {
|
func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer, verify bool) error {
|
||||||
if srcExp == nil || dstExp == nil {
|
if srcExp == nil || dstExp == nil {
|
||||||
return errors.New("invalid explorers")
|
return errors.New("invalid explorers")
|
||||||
}
|
}
|
||||||
@@ -121,6 +150,7 @@ func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int6
|
|||||||
Filename: filename,
|
Filename: filename,
|
||||||
BytesTotal: size,
|
BytesTotal: size,
|
||||||
Status: StatusQueued,
|
Status: StatusQueued,
|
||||||
|
Verify: verify,
|
||||||
srcExp: srcExp,
|
srcExp: srcExp,
|
||||||
dstExp: dstExp,
|
dstExp: dstExp,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user