feat: ✨ initialize project structure with frontend dependencies and backend protocols
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ConnectionConfig struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Protocol string `yaml:"protocol" json:"protocol"`
|
||||
Host string `yaml:"host,omitempty" json:"host,omitempty"`
|
||||
Port int `yaml:"port,omitempty" json:"port,omitempty"`
|
||||
Bucket string `yaml:"bucket,omitempty" json:"bucket,omitempty"`
|
||||
Region string `yaml:"region,omitempty" json:"region,omitempty"`
|
||||
PathStyle bool `yaml:"path_style,omitempty" json:"path_style,omitempty"`
|
||||
Username string `yaml:"username,omitempty" json:"username,omitempty"`
|
||||
KeychainKey string `yaml:"keychain_key,omitempty" json:"keychain_key,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Connections []ConnectionConfig `yaml:"connections"`
|
||||
}
|
||||
|
||||
func getConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(home, ".config", "goexplore")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "config.yaml"), nil
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
path, err := getConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Config{Connections: []ConnectionConfig{}}, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func SaveConfig(cfg *Config) error {
|
||||
path, err := getConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package explorer
|
||||
|
||||
import "io"
|
||||
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Modified string `json:"modified"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Permissions string `json:"permissions"`
|
||||
}
|
||||
|
||||
type Explorer interface {
|
||||
Connect() error
|
||||
Disconnect() error
|
||||
ListDir(path string) ([]FileEntry, error)
|
||||
Stat(path string) (FileEntry, error)
|
||||
MkDir(path string) error
|
||||
Delete(path string) error
|
||||
Rename(src, dst string) error
|
||||
ReadFile(path string) (io.ReadCloser, error)
|
||||
WriteFile(path string, r io.Reader, size int64) error
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package keychain
|
||||
|
||||
import "github.com/zalando/go-keyring"
|
||||
|
||||
const serviceName = "goexplore"
|
||||
|
||||
func SetSecret(id string, secret string) error {
|
||||
key := "goexplore-" + id
|
||||
return keyring.Set(serviceName, key, secret)
|
||||
}
|
||||
|
||||
func GetSecret(id string) (string, error) {
|
||||
key := "goexplore-" + id
|
||||
return keyring.Get(serviceName, key)
|
||||
}
|
||||
|
||||
func DeleteSecret(id string) error {
|
||||
key := "goexplore-" + id
|
||||
return keyring.Delete(serviceName, key)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type LocalExplorer struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func New() *LocalExplorer {
|
||||
basePath := "/"
|
||||
if runtime.GOOS == "windows" {
|
||||
basePath = "C:\\"
|
||||
}
|
||||
return &LocalExplorer{basePath: basePath}
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) Connect() error { return nil }
|
||||
func (e *LocalExplorer) Disconnect() error { return nil }
|
||||
|
||||
func (e *LocalExplorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = e.basePath
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []explorer.FileEntry
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, explorer.FileEntry{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.ToSlash(filepath.Join(path, entry.Name())),
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().Format(time.RFC3339),
|
||||
IsDir: entry.IsDir(),
|
||||
Permissions: info.Mode().String(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = e.basePath
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
return explorer.FileEntry{
|
||||
Name: info.Name(),
|
||||
Path: filepath.ToSlash(path),
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().Format(time.RFC3339),
|
||||
IsDir: info.IsDir(),
|
||||
Permissions: info.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) MkDir(path string) error {
|
||||
return os.Mkdir(path, 0755)
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) Delete(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) Rename(src, dst string) error {
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
func (e *LocalExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package nfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/go-nfs-client/nfs"
|
||||
"github.com/vmware/go-nfs-client/nfs/rpc"
|
||||
"goexplore/internal/config"
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type NFSExplorer struct {
|
||||
cfg *config.ConnectionConfig
|
||||
mount *nfs.Mount
|
||||
target *nfs.Target
|
||||
}
|
||||
|
||||
func New(c *config.ConnectionConfig, secret string) (*NFSExplorer, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, fmt.Errorf("NFS not supported on this platform")
|
||||
}
|
||||
return &NFSExplorer{cfg: c}, nil
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) Connect() error {
|
||||
mount, err := nfs.DialMount(e.cfg.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.mount = mount
|
||||
|
||||
auth := rpc.AuthNull
|
||||
target, err := mount.Mount(e.cfg.Bucket, auth)
|
||||
if err != nil {
|
||||
mount.Close()
|
||||
return err
|
||||
}
|
||||
e.target = target
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) Disconnect() error {
|
||||
if e.target != nil {
|
||||
e.target.Close()
|
||||
}
|
||||
if e.mount != nil {
|
||||
e.mount.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
files, err := e.target.ReadDirPlus(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []explorer.FileEntry
|
||||
for _, f := range files {
|
||||
if f.Name() == "." || f.Name() == ".." {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: filepath.ToSlash(filepath.Join(path, f.Name())),
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
f, _, err := e.target.Lookup(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
return explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: filepath.ToSlash(path),
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) MkDir(path string) error {
|
||||
_, err := e.target.Mkdir(path, 0755)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) Delete(path string) error {
|
||||
return e.target.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) Rename(src, dst string) error {
|
||||
return fmt.Errorf("rename not fully supported by go-nfs-client wrapper")
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
return e.target.Open(path)
|
||||
}
|
||||
|
||||
func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
f, err := e.target.OpenFile(path, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
appcfg "goexplore/internal/config"
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type S3Explorer struct {
|
||||
cfg *appcfg.ConnectionConfig
|
||||
secret string
|
||||
client *s3.Client
|
||||
}
|
||||
|
||||
func New(c *appcfg.ConnectionConfig, secret string) *S3Explorer {
|
||||
return &S3Explorer{cfg: c, secret: secret}
|
||||
}
|
||||
|
||||
func (e *S3Explorer) Connect() error {
|
||||
region := e.cfg.Region
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
creds := credentials.NewStaticCredentialsProvider(e.cfg.Username, e.secret, "")
|
||||
|
||||
optFns := []func(*config.LoadOptions) error{
|
||||
config.WithCredentialsProvider(creds),
|
||||
config.WithRegion(region),
|
||||
}
|
||||
|
||||
awsCfg, err := config.LoadDefaultConfig(context.TODO(), optFns...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientOpts := func(o *s3.Options) {
|
||||
if e.cfg.Host != "" {
|
||||
host := e.cfg.Host
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
host = "https://" + host
|
||||
}
|
||||
if e.cfg.Port > 0 {
|
||||
host = fmt.Sprintf("%s:%d", host, e.cfg.Port)
|
||||
}
|
||||
o.BaseEndpoint = aws.String(host)
|
||||
}
|
||||
o.UsePathStyle = e.cfg.PathStyle
|
||||
}
|
||||
|
||||
e.client = s3.NewFromConfig(awsCfg, clientOpts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) getBucketAndPath(path string) (string, string, string, error) {
|
||||
if e.cfg.Bucket != "" {
|
||||
return e.cfg.Bucket, strings.TrimPrefix(path, "/"), "", nil
|
||||
}
|
||||
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if path == "" {
|
||||
return "", "", "", fmt.Errorf("no bucket specified")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
bucket := parts[0]
|
||||
subpath := ""
|
||||
if len(parts) > 1 {
|
||||
subpath = parts[1]
|
||||
}
|
||||
return bucket, subpath, bucket, nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if e.cfg.Bucket == "" && (path == "" || path == "/") {
|
||||
result, err := e.client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []explorer.FileEntry
|
||||
for _, b := range result.Buckets {
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: *b.Name,
|
||||
Path: *b.Name,
|
||||
Modified: b.CreationDate.Format(time.RFC3339),
|
||||
IsDir: true,
|
||||
Permissions: "bucket",
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
bucket, subpath, bucketName, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix := subpath
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
input := &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
Delimiter: aws.String("/"),
|
||||
}
|
||||
|
||||
result, err := e.client.ListObjectsV2(context.TODO(), input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []explorer.FileEntry
|
||||
for _, cp := range result.CommonPrefixes {
|
||||
name := strings.TrimSuffix(*cp.Prefix, "/")
|
||||
name = filepath.Base(name)
|
||||
|
||||
fullPath := *cp.Prefix
|
||||
if e.cfg.Bucket == "" {
|
||||
fullPath = bucketName + "/" + *cp.Prefix
|
||||
}
|
||||
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
IsDir: true,
|
||||
Permissions: "dir",
|
||||
})
|
||||
}
|
||||
|
||||
for _, obj := range result.Contents {
|
||||
if *obj.Key == prefix {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(*obj.Key)
|
||||
|
||||
fullPath := *obj.Key
|
||||
if e.cfg.Bucket == "" {
|
||||
fullPath = bucketName + "/" + *obj.Key
|
||||
}
|
||||
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
Size: *obj.Size,
|
||||
Modified: obj.LastModified.Format(time.RFC3339),
|
||||
IsDir: false,
|
||||
Permissions: "file",
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
if e.cfg.Bucket == "" && (path == "" || path == "/") {
|
||||
return explorer.FileEntry{
|
||||
Name: "/", Path: "/", IsDir: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
bucket, subpath, bucketName, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
|
||||
if subpath == "" {
|
||||
return explorer.FileEntry{
|
||||
Name: bucket, Path: bucketName, IsDir: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
input := &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(subpath),
|
||||
}
|
||||
head, err := e.client.HeadObject(context.TODO(), input)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{
|
||||
Name: filepath.Base(subpath),
|
||||
Path: path,
|
||||
IsDir: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return explorer.FileEntry{
|
||||
Name: filepath.Base(subpath),
|
||||
Path: path,
|
||||
Size: *head.ContentLength,
|
||||
Modified: head.LastModified.Format(time.RFC3339),
|
||||
IsDir: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) MkDir(path string) error {
|
||||
bucket, subpath, _, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subpath == "" {
|
||||
_, err := e.client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(subpath, "/") {
|
||||
subpath += "/"
|
||||
}
|
||||
_, err = e.client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(subpath),
|
||||
Body: strings.NewReader(""),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *S3Explorer) Delete(path string) error {
|
||||
bucket, subpath, _, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subpath == "" {
|
||||
_, err := e.client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = e.client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(subpath),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *S3Explorer) Rename(src, dst string) error {
|
||||
bucket1, subpath1, _, err := e.getBucketAndPath(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket2, subpath2, _, err := e.getBucketAndPath(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bucket1 != bucket2 {
|
||||
return fmt.Errorf("cross-bucket rename not supported")
|
||||
}
|
||||
|
||||
_, err = e.client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
||||
Bucket: aws.String(bucket2),
|
||||
CopySource: aws.String(fmt.Sprintf("%s/%s", bucket1, subpath1)),
|
||||
Key: aws.String(subpath2),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = e.client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket1),
|
||||
Key: aws.String(subpath1),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *S3Explorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
bucket, subpath, _, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := e.client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(subpath),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Body, nil
|
||||
}
|
||||
|
||||
func (e *S3Explorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
bucket, subpath, _, err := e.getBucketAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = e.client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(subpath),
|
||||
Body: r,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"goexplore/internal/config"
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type SFTPExplorer struct {
|
||||
cfg *config.ConnectionConfig
|
||||
secret string
|
||||
client *sftp.Client
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
func New(c *config.ConnectionConfig, secret string) *SFTPExplorer {
|
||||
return &SFTPExplorer{cfg: c, secret: secret}
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) Connect() error {
|
||||
port := e.cfg.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
var authMethod ssh.AuthMethod
|
||||
signer, err := ssh.ParsePrivateKey([]byte(e.secret))
|
||||
if err == nil {
|
||||
authMethod = ssh.PublicKeys(signer)
|
||||
} else {
|
||||
authMethod = ssh.Password(e.secret)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: e.cfg.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
authMethod,
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", e.cfg.Host, port), config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.conn = conn
|
||||
|
||||
client, err := sftp.NewClient(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
e.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) Disconnect() error {
|
||||
if e.client != nil {
|
||||
e.client.Close()
|
||||
}
|
||||
if e.conn != nil {
|
||||
e.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
files, err := e.client.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []explorer.FileEntry
|
||||
for _, f := range files {
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: filepath.ToSlash(filepath.Join(path, f.Name())),
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
f, err := e.client.Stat(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
return explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: filepath.ToSlash(path),
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) MkDir(path string) error {
|
||||
return e.client.MkdirAll(path)
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) removeAll(path string) error {
|
||||
fi, err := e.client.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return e.client.Remove(path)
|
||||
}
|
||||
|
||||
files, err := e.client.ReadDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if f.Name() == "." || f.Name() == ".." {
|
||||
continue
|
||||
}
|
||||
if err := e.removeAll(filepath.Join(path, f.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.client.RemoveDirectory(path)
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) Delete(path string) error {
|
||||
return e.removeAll(path)
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) Rename(src, dst string) error {
|
||||
return e.client.Rename(src, dst)
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
return e.client.Open(path)
|
||||
}
|
||||
|
||||
func (e *SFTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
f, err := e.client.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package smb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
"goexplore/internal/config"
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type SMBExplorer struct {
|
||||
cfg *config.ConnectionConfig
|
||||
secret string
|
||||
conn net.Conn
|
||||
sess *smb2.Session
|
||||
defaultShare *smb2.Share
|
||||
shares map[string]*smb2.Share
|
||||
}
|
||||
|
||||
func New(c *config.ConnectionConfig, secret string) *SMBExplorer {
|
||||
return &SMBExplorer{cfg: c, secret: secret, shares: make(map[string]*smb2.Share)}
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) Connect() error {
|
||||
port := e.cfg.Port
|
||||
if port == 0 {
|
||||
port = 445
|
||||
}
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", e.cfg.Host, port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.conn = conn
|
||||
|
||||
d := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: e.cfg.Username,
|
||||
Password: e.secret,
|
||||
},
|
||||
}
|
||||
|
||||
s, err := d.Dial(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
e.sess = s
|
||||
|
||||
if e.cfg.Bucket != "" {
|
||||
share, err := s.Mount(e.cfg.Bucket)
|
||||
if err != nil {
|
||||
s.Logoff()
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
e.defaultShare = share
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) Disconnect() error {
|
||||
if e.defaultShare != nil {
|
||||
e.defaultShare.Umount()
|
||||
}
|
||||
for _, s := range e.shares {
|
||||
s.Umount()
|
||||
}
|
||||
if e.sess != nil {
|
||||
e.sess.Logoff()
|
||||
}
|
||||
if e.conn != nil {
|
||||
e.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) getShareAndPath(path string) (*smb2.Share, string, string, error) {
|
||||
if e.defaultShare != nil {
|
||||
if path == "" || path == "/" {
|
||||
path = "."
|
||||
}
|
||||
return e.defaultShare, strings.ReplaceAll(path, "/", "\\"), "", nil
|
||||
}
|
||||
|
||||
path = strings.TrimPrefix(filepath.ToSlash(path), "/")
|
||||
if path == "" || path == "." {
|
||||
return nil, "", "", fmt.Errorf("no share specified")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
sharename := parts[0]
|
||||
subpath := "."
|
||||
if len(parts) > 1 {
|
||||
subpath = parts[1]
|
||||
}
|
||||
|
||||
if s, ok := e.shares[sharename]; ok {
|
||||
return s, strings.ReplaceAll(subpath, "/", "\\"), sharename, nil
|
||||
}
|
||||
|
||||
s, err := e.sess.Mount(sharename)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
e.shares[sharename] = s
|
||||
return s, strings.ReplaceAll(subpath, "/", "\\"), sharename, nil
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if e.defaultShare == nil && (path == "" || path == "/" || path == ".") {
|
||||
shares, err := e.sess.ListSharenames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []explorer.FileEntry
|
||||
for _, s := range shares {
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: s,
|
||||
Path: s,
|
||||
IsDir: true,
|
||||
Permissions: "share",
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
share, subpath, shareName, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := share.ReadDir(subpath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []explorer.FileEntry
|
||||
for _, f := range files {
|
||||
fullPath := ""
|
||||
if e.defaultShare != nil {
|
||||
fullPath = strings.ReplaceAll(filepath.Join(subpath, f.Name()), "\\", "/")
|
||||
} else {
|
||||
fullPath = filepath.ToSlash(filepath.Join(shareName, subpath, f.Name()))
|
||||
}
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: fullPath,
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
if e.defaultShare == nil && (path == "" || path == "/" || path == ".") {
|
||||
return explorer.FileEntry{
|
||||
Name: "/", Path: "/", IsDir: true, Permissions: "root",
|
||||
}, nil
|
||||
}
|
||||
|
||||
share, subpath, shareName, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
|
||||
f, err := share.Stat(subpath)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
|
||||
fullPath := strings.ReplaceAll(subpath, "\\", "/")
|
||||
if e.defaultShare == nil {
|
||||
fullPath = filepath.ToSlash(filepath.Join(shareName, subpath))
|
||||
}
|
||||
return explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: fullPath,
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) MkDir(path string) error {
|
||||
share, subpath, _, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return share.Mkdir(subpath, 0755)
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) Delete(path string) error {
|
||||
share, subpath, _, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return share.RemoveAll(subpath)
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) Rename(src, dst string) error {
|
||||
share1, subpath1, shareName1, err := e.getShareAndPath(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, subpath2, shareName2, err := e.getShareAndPath(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shareName1 != shareName2 {
|
||||
return fmt.Errorf("cross-share rename not supported")
|
||||
}
|
||||
return share1.Rename(subpath1, subpath2)
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
share, subpath, _, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return share.Open(subpath)
|
||||
}
|
||||
|
||||
func (e *SMBExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
share, subpath, _, err := e.getShareAndPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := share.Create(subpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/studio-b12/gowebdav"
|
||||
"goexplore/internal/config"
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type WebDAVExplorer struct {
|
||||
cfg *config.ConnectionConfig
|
||||
secret string
|
||||
client *gowebdav.Client
|
||||
}
|
||||
|
||||
func New(c *config.ConnectionConfig, secret string) *WebDAVExplorer {
|
||||
return &WebDAVExplorer{cfg: c, secret: secret}
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) Connect() error {
|
||||
host := e.cfg.Host
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
host = "http://" + host
|
||||
}
|
||||
if e.cfg.Port > 0 {
|
||||
host = fmt.Sprintf("%s:%d", host, e.cfg.Port)
|
||||
}
|
||||
if e.cfg.Bucket != "" {
|
||||
host = host + "/" + e.cfg.Bucket
|
||||
}
|
||||
|
||||
e.client = gowebdav.NewClient(host, e.cfg.Username, e.secret)
|
||||
err := e.client.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) ListDir(path string) ([]explorer.FileEntry, error) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
files, err := e.client.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []explorer.FileEntry
|
||||
for _, f := range files {
|
||||
entries = append(entries, explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: strings.TrimSuffix(path, "/") + "/" + f.Name(),
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) Stat(path string) (explorer.FileEntry, error) {
|
||||
f, err := e.client.Stat(path)
|
||||
if err != nil {
|
||||
return explorer.FileEntry{}, err
|
||||
}
|
||||
return explorer.FileEntry{
|
||||
Name: f.Name(),
|
||||
Path: path,
|
||||
Size: f.Size(),
|
||||
Modified: f.ModTime().Format(time.RFC3339),
|
||||
IsDir: f.IsDir(),
|
||||
Permissions: f.Mode().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) MkDir(path string) error {
|
||||
return e.client.MkdirAll(path, 0755)
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) Delete(path string) error {
|
||||
return e.client.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) Rename(src, dst string) error {
|
||||
return e.client.Rename(src, dst, true)
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) ReadFile(path string) (io.ReadCloser, error) {
|
||||
return e.client.ReadStream(path)
|
||||
}
|
||||
|
||||
func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error {
|
||||
return e.client.WriteStream(path, r, 0644)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"goexplore/internal/explorer"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusActive Status = "active"
|
||||
StatusComplete Status = "complete"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
type Transfer struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
Filename string `json:"filename"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesDone int64 `json:"bytes_done"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
ETA int `json:"eta_seconds"`
|
||||
Status Status `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
srcExp explorer.Explorer
|
||||
dstExp explorer.Explorer
|
||||
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
transfers map[string]*Transfer
|
||||
queue chan *Transfer
|
||||
concurrency int
|
||||
}
|
||||
|
||||
func NewManager(concurrency int) *Manager {
|
||||
m := &Manager{
|
||||
transfers: make(map[string]*Transfer),
|
||||
queue: make(chan *Transfer, 100),
|
||||
concurrency: concurrency,
|
||||
}
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go m.worker()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) worker() {
|
||||
for t := range m.queue {
|
||||
m.process(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) process(t *Transfer) {
|
||||
t.Status = StatusActive
|
||||
t.startTime = time.Now()
|
||||
err := m.doTransfer(t)
|
||||
if err != nil {
|
||||
t.Status = StatusFailed
|
||||
t.Error = err.Error()
|
||||
} else {
|
||||
t.Status = StatusComplete
|
||||
t.BytesDone = t.BytesTotal
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) doTransfer(t *Transfer) error {
|
||||
r, err := t.srcExp.ReadFile(t.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
pr := &progressReader{
|
||||
r: r,
|
||||
t: t,
|
||||
}
|
||||
|
||||
return t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal)
|
||||
}
|
||||
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
t *Transfer
|
||||
}
|
||||
|
||||
func (pr *progressReader) Read(p []byte) (int, error) {
|
||||
n, err := pr.r.Read(p)
|
||||
if n > 0 {
|
||||
pr.t.BytesDone += int64(n)
|
||||
elapsed := time.Since(pr.t.startTime).Seconds()
|
||||
if elapsed > 0 {
|
||||
pr.t.SpeedMBps = (float64(pr.t.BytesDone) / 1024 / 1024) / elapsed
|
||||
if pr.t.SpeedMBps > 0 {
|
||||
pr.t.ETA = int(float64(pr.t.BytesTotal-pr.t.BytesDone) / 1024 / 1024 / pr.t.SpeedMBps)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer) error {
|
||||
if srcExp == nil || dstExp == nil {
|
||||
return errors.New("invalid explorers")
|
||||
}
|
||||
|
||||
t := &Transfer{
|
||||
ID: id,
|
||||
Source: srcPath,
|
||||
Destination: dstPath,
|
||||
Filename: filename,
|
||||
BytesTotal: size,
|
||||
Status: StatusQueued,
|
||||
srcExp: srcExp,
|
||||
dstExp: dstExp,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.transfers[id] = t
|
||||
m.mu.Unlock()
|
||||
|
||||
m.queue <- t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetTransfers() []*Transfer {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
res := make([]*Transfer, 0, len(m.transfers))
|
||||
for _, t := range m.transfers {
|
||||
res = append(res, t)
|
||||
}
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user