feat: initialize project structure with frontend dependencies and backend protocols

This commit is contained in:
2026-06-09 11:14:49 +01:00
commit 13848fb227
43 changed files with 4836 additions and 0 deletions
+71
View File
@@ -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)
}
+24
View File
@@ -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
}
+20
View File
@@ -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)
}
+97
View File
@@ -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
}
+125
View File
@@ -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
}
+309
View File
@@ -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
}
+164
View File
@@ -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
}
+245
View File
@@ -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
}
+104
View File
@@ -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)
}
+144
View File
@@ -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
}