Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
611 lines
18 KiB
Go
611 lines
18 KiB
Go
// Package filetransfer is a built-in plugin that connects to a file-transfer
|
|
// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a
|
|
// stateful third-party integration behind the plugin contract: one descriptor
|
|
// with a protocol switch, and a small protocol-agnostic `conn` abstraction that
|
|
// HealthCheck and Invoke drive without caring which wire protocol is in use.
|
|
//
|
|
// Connections are opened per operation rather than pooled: FTP/SFTP sessions are
|
|
// stateful and idle-timeout aggressively, so dialling on demand is both simpler
|
|
// and more robust than keeping a long-lived connection healthy. Init only stores
|
|
// the resolved config; nothing connects until HealthCheck or Invoke runs.
|
|
//
|
|
// - FTP : github.com/jlaffaye/ftp
|
|
// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS)
|
|
// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp
|
|
package filetransfer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jlaffaye/ftp"
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"pilotvault/apiserver/internal/plugins"
|
|
)
|
|
|
|
const (
|
|
protoSFTP = "sftp"
|
|
protoFTP = "ftp"
|
|
protoFTPS = "ftps"
|
|
|
|
dialTimeout = 12 * time.Second
|
|
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
|
// the health probe and Invoke both honour it.
|
|
maxReadBytes = 32 << 20 // 32 MiB
|
|
)
|
|
|
|
func init() {
|
|
plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} })
|
|
}
|
|
|
|
// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because
|
|
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
|
type Plugin struct {
|
|
mu sync.Mutex
|
|
protocol string
|
|
host string
|
|
port int
|
|
username string
|
|
password string
|
|
privateKey string // PEM-encoded SSH private key (sftp only)
|
|
keyPass string // passphrase for the private key
|
|
basePath string
|
|
// hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint
|
|
// ("SHA256:…"); empty means accept any host key (trust-on-first-use, no
|
|
// verification — flagged as degraded by the health probe).
|
|
hostKeyFP string
|
|
// insecureTLS skips FTPS certificate verification when true.
|
|
insecureTLS bool
|
|
}
|
|
|
|
func (p *Plugin) Descriptor() plugins.Descriptor {
|
|
return plugins.Descriptor{
|
|
Name: "filetransfer",
|
|
Provider: "FTP / SFTP",
|
|
Version: "1.0.0",
|
|
Kind: plugins.KindBuiltin,
|
|
Category: plugins.CategoryDrivesExternal,
|
|
AuthType: plugins.AuthBasic,
|
|
Capabilities: []plugins.Capability{
|
|
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
|
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
|
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
|
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
|
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"},
|
|
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
|
},
|
|
ConfigFields: []plugins.ConfigField{
|
|
// No field is Required: the plugin can be enabled as a master switch with
|
|
// an empty global config, leaving each organization or user to supply
|
|
// their own connection through the cascade (mirrors OpenSky). A missing
|
|
// host is reported gracefully by the health probe.
|
|
{Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP,
|
|
Options: []plugins.SelectOption{
|
|
{Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"},
|
|
{Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"},
|
|
{Value: protoFTP, Label: "FTP — plaintext (insecure)"},
|
|
},
|
|
Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."},
|
|
{Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"},
|
|
{Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."},
|
|
{Key: "username", Label: "Username", Type: "text"},
|
|
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
|
Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."},
|
|
{Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true,
|
|
Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."},
|
|
{Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true,
|
|
Help: "Passphrase protecting the SSH private key, if any."},
|
|
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
|
Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."},
|
|
{Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text",
|
|
Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."},
|
|
{Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false",
|
|
Options: []plugins.SelectOption{
|
|
{Value: "false", Label: "Verify certificate (recommended)"},
|
|
{Value: "true", Label: "Skip verification — accept any certificate"},
|
|
},
|
|
Help: "Only affects FTPS. Skip verification only for self-signed test servers."},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"]))
|
|
if p.protocol == "" {
|
|
p.protocol = protoSFTP
|
|
}
|
|
p.host = strings.TrimSpace(config["host"])
|
|
p.port = 0
|
|
if raw := strings.TrimSpace(config["port"]); raw != "" {
|
|
if n, err := strconv.Atoi(raw); err == nil {
|
|
p.port = n
|
|
}
|
|
}
|
|
p.username = strings.TrimSpace(config["username"])
|
|
p.password = config["password"]
|
|
p.privateKey = config["privateKey"]
|
|
p.keyPass = config["keyPassphrase"]
|
|
p.basePath = strings.TrimSpace(config["basePath"])
|
|
if p.basePath == "" {
|
|
p.basePath = "."
|
|
}
|
|
p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"])
|
|
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
|
return nil
|
|
}
|
|
|
|
// effectivePort returns the configured port or the protocol default.
|
|
func (p *Plugin) effectivePort() int {
|
|
if p.port > 0 {
|
|
return p.port
|
|
}
|
|
if p.protocol == protoSFTP {
|
|
return 22
|
|
}
|
|
return 21
|
|
}
|
|
|
|
// resolve joins a caller-supplied path against the base path. An absolute path
|
|
// is used as-is; an empty path becomes the base path itself.
|
|
func (p *Plugin) resolve(rel string) string {
|
|
rel = strings.TrimSpace(rel)
|
|
if rel == "" {
|
|
return p.basePath
|
|
}
|
|
if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." {
|
|
return rel
|
|
}
|
|
return path.Join(p.basePath, rel)
|
|
}
|
|
|
|
// HealthCheck dials, authenticates, and lists the base path, classifying the
|
|
// outcome. A missing/unverified SFTP host key downgrades OK to degraded.
|
|
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
|
start := time.Now()
|
|
|
|
p.mu.Lock()
|
|
proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP
|
|
base := p.basePath
|
|
p.mu.Unlock()
|
|
|
|
if host == "" {
|
|
return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"}
|
|
}
|
|
|
|
c, err := p.dial(ctx)
|
|
if err != nil {
|
|
lat := time.Since(start).Milliseconds()
|
|
return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()}
|
|
}
|
|
defer c.close()
|
|
|
|
entries, err := c.list(base)
|
|
lat := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat,
|
|
Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)}
|
|
}
|
|
|
|
detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base)
|
|
status := plugins.StatusOK
|
|
if proto == protoSFTP && hostKeyFP == "" {
|
|
status = plugins.StatusDegraded
|
|
detail += " · host key not verified (no fingerprint pinned)"
|
|
}
|
|
if proto == protoFTP {
|
|
detail += " · plaintext (no encryption)"
|
|
}
|
|
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
|
}
|
|
|
|
// Invoke runs one capability against a freshly-dialled connection.
|
|
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
|
c, err := p.dial(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer c.close()
|
|
|
|
switch action {
|
|
case "list":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
entries, err := c.list(p.resolve(in.Path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries})
|
|
|
|
case "stat":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
fi, err := c.stat(p.resolve(in.Path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(fi)
|
|
|
|
case "download":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
data, err := c.read(p.resolve(in.Path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"path": p.resolve(in.Path),
|
|
"size": len(data),
|
|
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
|
})
|
|
|
|
case "upload":
|
|
var in writeParams
|
|
if err := json.Unmarshal(params, &in); err != nil {
|
|
return nil, fmt.Errorf("invalid params: %w", err)
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
|
}
|
|
if err := c.write(p.resolve(in.Path), data); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true})
|
|
|
|
case "delete":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
if err := c.remove(p.resolve(in.Path)); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
|
|
|
case "mkdir":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
if err := c.mkdir(p.resolve(in.Path)); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
|
|
|
default:
|
|
return nil, errors.New("unknown action: " + action)
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
|
|
|
// pathParams / writeParams are the Invoke request shapes.
|
|
type pathParams struct {
|
|
Path string `json:"path"`
|
|
}
|
|
type writeParams struct {
|
|
Path string `json:"path"`
|
|
ContentBase64 string `json:"contentBase64"`
|
|
}
|
|
|
|
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
|
type fileInfo struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"isDir"`
|
|
ModTime string `json:"modTime,omitempty"`
|
|
}
|
|
|
|
// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the
|
|
// FTP and SFTP implementations satisfy it.
|
|
type conn interface {
|
|
list(path string) ([]fileInfo, error)
|
|
stat(path string) (fileInfo, error)
|
|
read(path string) ([]byte, error)
|
|
write(path string, data []byte) error
|
|
remove(path string) error
|
|
mkdir(path string) error
|
|
close() error
|
|
}
|
|
|
|
// dial builds an authenticated connection for the configured protocol.
|
|
func (p *Plugin) dial(ctx context.Context) (conn, error) {
|
|
p.mu.Lock()
|
|
proto := p.protocol
|
|
p.mu.Unlock()
|
|
|
|
switch proto {
|
|
case protoSFTP:
|
|
return p.dialSFTP(ctx)
|
|
case protoFTP, protoFTPS:
|
|
return p.dialFTP(ctx)
|
|
default:
|
|
return nil, errors.New("unsupported protocol: " + proto)
|
|
}
|
|
}
|
|
|
|
// classifyDialErr maps a dial/auth failure to a health status: an auth rejection
|
|
// is degraded (server reachable, credentials wrong); anything else is down.
|
|
func classifyDialErr(err error) string {
|
|
msg := strings.ToLower(err.Error())
|
|
switch {
|
|
case strings.Contains(msg, "unable to authenticate"),
|
|
strings.Contains(msg, "auth"),
|
|
strings.Contains(msg, "password"),
|
|
strings.Contains(msg, "login"),
|
|
strings.Contains(msg, "530"), // FTP: not logged in
|
|
strings.Contains(msg, "permission denied"):
|
|
return plugins.StatusDegraded
|
|
default:
|
|
return plugins.StatusDown
|
|
}
|
|
}
|
|
|
|
func plural(n int) string {
|
|
if n == 1 {
|
|
return "y"
|
|
}
|
|
return "ies"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SFTP implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type sftpConn struct {
|
|
ssh *ssh.Client
|
|
cli *sftp.Client
|
|
}
|
|
|
|
func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) {
|
|
p.mu.Lock()
|
|
host, user, pass := p.host, p.username, p.password
|
|
key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP
|
|
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
|
p.mu.Unlock()
|
|
|
|
var auth []ssh.AuthMethod
|
|
if strings.TrimSpace(key) != "" {
|
|
signer, err := parseSigner(key, keyPass)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("private key: %w", err)
|
|
}
|
|
auth = append(auth, ssh.PublicKeys(signer))
|
|
}
|
|
if pass != "" {
|
|
auth = append(auth, ssh.Password(pass))
|
|
}
|
|
if len(auth) == 0 {
|
|
return nil, errors.New("SFTP requires a password or a private key")
|
|
}
|
|
|
|
hostKeyCallback, err := hostKeyChecker(hostKeyFP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg := &ssh.ClientConfig{
|
|
User: user,
|
|
Auth: auth,
|
|
HostKeyCallback: hostKeyCallback,
|
|
Timeout: dialTimeout,
|
|
}
|
|
|
|
// ssh.Dial has no context form; dial the TCP conn with the context, then
|
|
// run the SSH handshake over it.
|
|
d := net.Dialer{Timeout: dialTimeout}
|
|
tcp, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg)
|
|
if err != nil {
|
|
_ = tcp.Close()
|
|
return nil, err
|
|
}
|
|
client := ssh.NewClient(sshConn, chans, reqs)
|
|
sc, err := sftp.NewClient(client)
|
|
if err != nil {
|
|
_ = client.Close()
|
|
return nil, err
|
|
}
|
|
return &sftpConn{ssh: client, cli: sc}, nil
|
|
}
|
|
|
|
// parseSigner parses a PEM private key, with or without a passphrase.
|
|
func parseSigner(pem, passphrase string) (ssh.Signer, error) {
|
|
if strings.TrimSpace(passphrase) != "" {
|
|
return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase))
|
|
}
|
|
return ssh.ParsePrivateKey([]byte(pem))
|
|
}
|
|
|
|
// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:…
|
|
// fingerprint, or accepts any key when the fingerprint is empty.
|
|
func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) {
|
|
if fingerprint == "" {
|
|
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned
|
|
}
|
|
want := strings.TrimSpace(fingerprint)
|
|
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
|
got := ssh.FingerprintSHA256(key)
|
|
if got != want {
|
|
return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want)
|
|
}
|
|
return nil
|
|
}, nil
|
|
}
|
|
|
|
func (c *sftpConn) list(p string) ([]fileInfo, error) {
|
|
infos, err := c.cli.ReadDir(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]fileInfo, 0, len(infos))
|
|
for _, fi := range infos {
|
|
out = append(out, fileInfo{
|
|
Name: fi.Name(),
|
|
Size: fi.Size(),
|
|
IsDir: fi.IsDir(),
|
|
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *sftpConn) stat(p string) (fileInfo, error) {
|
|
fi, err := c.cli.Stat(p)
|
|
if err != nil {
|
|
return fileInfo{}, err
|
|
}
|
|
return fileInfo{
|
|
Name: fi.Name(),
|
|
Size: fi.Size(),
|
|
IsDir: fi.IsDir(),
|
|
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
|
}, nil
|
|
}
|
|
|
|
func (c *sftpConn) read(p string) ([]byte, error) {
|
|
f, err := c.cli.Open(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
|
}
|
|
|
|
func (c *sftpConn) write(p string, data []byte) error {
|
|
f, err := c.cli.Create(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = f.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) }
|
|
func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) }
|
|
|
|
func (c *sftpConn) close() error {
|
|
err := c.cli.Close()
|
|
if c.ssh != nil {
|
|
_ = c.ssh.Close()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FTP / FTPS implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ftpConn struct {
|
|
c *ftp.ServerConn
|
|
}
|
|
|
|
func (p *Plugin) dialFTP(ctx context.Context) (conn, error) {
|
|
p.mu.Lock()
|
|
host, user, pass, proto := p.host, p.username, p.password, p.protocol
|
|
insecure := p.insecureTLS
|
|
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
|
p.mu.Unlock()
|
|
|
|
opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)}
|
|
if proto == protoFTPS {
|
|
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{
|
|
ServerName: host,
|
|
InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers
|
|
}))
|
|
}
|
|
|
|
sc, err := ftp.Dial(addr, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := sc.Login(user, pass); err != nil {
|
|
_ = sc.Quit()
|
|
return nil, err
|
|
}
|
|
return &ftpConn{c: sc}, nil
|
|
}
|
|
|
|
func (c *ftpConn) list(p string) ([]fileInfo, error) {
|
|
entries, err := c.c.List(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]fileInfo, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.Name == "." || e.Name == ".." {
|
|
continue
|
|
}
|
|
out = append(out, entryToInfo(e))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *ftpConn) stat(p string) (fileInfo, error) {
|
|
// FTP has no portable stat; MLST via GetEntry works on servers that support
|
|
// it, otherwise fall back to listing the parent and matching the name.
|
|
if e, err := c.c.GetEntry(p); err == nil && e != nil {
|
|
return entryToInfo(e), nil
|
|
}
|
|
dir, base := path.Split(strings.TrimRight(p, "/"))
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
entries, err := c.c.List(dir)
|
|
if err != nil {
|
|
return fileInfo{}, err
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name == base {
|
|
return entryToInfo(e), nil
|
|
}
|
|
}
|
|
return fileInfo{}, fmt.Errorf("not found: %s", p)
|
|
}
|
|
|
|
func (c *ftpConn) read(p string) ([]byte, error) {
|
|
resp, err := c.c.Retr(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Close()
|
|
return io.ReadAll(io.LimitReader(resp, maxReadBytes))
|
|
}
|
|
|
|
func (c *ftpConn) write(p string, data []byte) error {
|
|
return c.c.Stor(p, strings.NewReader(string(data)))
|
|
}
|
|
|
|
func (c *ftpConn) remove(p string) error { return c.c.Delete(p) }
|
|
func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) }
|
|
|
|
func (c *ftpConn) close() error { return c.c.Quit() }
|
|
|
|
// entryToInfo normalizes a jlaffaye/ftp entry.
|
|
func entryToInfo(e *ftp.Entry) fileInfo {
|
|
fi := fileInfo{
|
|
Name: e.Name,
|
|
Size: int64(e.Size),
|
|
IsDir: e.Type == ftp.EntryTypeFolder,
|
|
}
|
|
if !e.Time.IsZero() {
|
|
fi.ModTime = e.Time.UTC().Format(time.RFC3339)
|
|
}
|
|
return fi
|
|
}
|