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>
369 lines
12 KiB
Go
369 lines
12 KiB
Go
// Package localstorage is a built-in plugin that exposes a directory on the host
|
|
// machine's own filesystem as a storage "drive", behind the same capability
|
|
// surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer
|
|
// connector. Where filetransfer dials FTP/SFTP, this one just calls the os
|
|
// package — there is no network, no auth, and nothing to dial.
|
|
//
|
|
// Every caller-supplied path is confined under the configured base path: paths
|
|
// are treated as relative to the base and cleaned so that ".." or a leading
|
|
// separator can never escape the storage root. This is the one piece of extra
|
|
// care a local-filesystem connector needs that a remote one gets from the remote
|
|
// server's own chroot/permissions.
|
|
package localstorage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"pilotvault/apiserver/internal/plugins"
|
|
)
|
|
|
|
// maxReadBytes caps a download so a huge file can't exhaust memory; the health
|
|
// probe and Invoke both honour it (mirrors filetransfer).
|
|
const maxReadBytes = 32 << 20 // 32 MiB
|
|
|
|
func init() {
|
|
plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} })
|
|
}
|
|
|
|
// Plugin is the local-filesystem connector. Fields are guarded by mu because
|
|
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
|
type Plugin struct {
|
|
mu sync.Mutex
|
|
basePath string
|
|
createMissing bool // create the base path (and upload/mkdir parents) if absent
|
|
readOnly bool // reject upload/delete/mkdir when true
|
|
}
|
|
|
|
func (p *Plugin) Descriptor() plugins.Descriptor {
|
|
return plugins.Descriptor{
|
|
Name: "localstorage",
|
|
Provider: "Local Filesystem",
|
|
Version: "1.0.0",
|
|
Kind: plugins.KindBuiltin,
|
|
Category: plugins.CategoryDrivesLocal,
|
|
AuthType: plugins.AuthNone,
|
|
Capabilities: []plugins.Capability{
|
|
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"},
|
|
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"},
|
|
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"},
|
|
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"},
|
|
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"},
|
|
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"},
|
|
},
|
|
ConfigFields: []plugins.ConfigField{
|
|
// No field is Required: like the other drive plugins, this can be enabled
|
|
// as a master switch with an empty config; a missing base path is reported
|
|
// gracefully by the health probe rather than blocking the switch.
|
|
{Key: "basePath", Label: "Base path", Type: "text",
|
|
Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`},
|
|
{Key: "createMissing", Label: "Create base path", Type: "select", Default: "false",
|
|
Options: []plugins.SelectOption{
|
|
{Value: "false", Label: "Require the directory to already exist"},
|
|
{Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"},
|
|
},
|
|
Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."},
|
|
{Key: "readOnly", Label: "Access mode", Type: "select", Default: "false",
|
|
Options: []plugins.SelectOption{
|
|
{Value: "false", Label: "Read-write"},
|
|
{Value: "true", Label: "Read-only — reject upload, delete and mkdir"},
|
|
},
|
|
Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.basePath = strings.TrimSpace(config["basePath"])
|
|
p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true")
|
|
p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true")
|
|
return nil
|
|
}
|
|
|
|
// resolve confines a caller-supplied path under the base path. The path is always
|
|
// treated as relative to the base; a leading separator or ".." segments are
|
|
// neutralized by cleaning against a virtual root, so the result can never escape.
|
|
func (p *Plugin) resolve(rel string) (string, error) {
|
|
base := strings.TrimSpace(p.basePath)
|
|
if base == "" {
|
|
return "", errors.New("no base path configured")
|
|
}
|
|
absBase, err := filepath.Abs(base)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Clean against a virtual root so "..", ".", and leading separators collapse to
|
|
// a path that stays at or below "/", then strip the root and join under base.
|
|
virtual := filepath.ToSlash(strings.TrimSpace(rel))
|
|
cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/"))
|
|
sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/"))
|
|
joined := filepath.Join(absBase, sub)
|
|
|
|
// Belt-and-braces containment check after joining.
|
|
if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) {
|
|
return "", fmt.Errorf("path %q escapes the base directory", rel)
|
|
}
|
|
return joined, nil
|
|
}
|
|
|
|
// HealthCheck verifies the base path exists, is a directory, is readable, and
|
|
// (unless read-only) is writable. Missing-but-creatable resolves to OK.
|
|
func (p *Plugin) HealthCheck(_ context.Context) plugins.Health {
|
|
start := time.Now()
|
|
|
|
p.mu.Lock()
|
|
base, create, readOnly := p.basePath, p.createMissing, p.readOnly
|
|
p.mu.Unlock()
|
|
|
|
if strings.TrimSpace(base) == "" {
|
|
return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"}
|
|
}
|
|
absBase, err := filepath.Abs(base)
|
|
if err != nil {
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()}
|
|
}
|
|
|
|
info, err := os.Stat(absBase)
|
|
if err != nil {
|
|
if os.IsNotExist(err) && create {
|
|
if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil {
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
|
Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)}
|
|
}
|
|
info, err = os.Stat(absBase)
|
|
}
|
|
if err != nil {
|
|
detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err)
|
|
if os.IsNotExist(err) {
|
|
detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase)
|
|
}
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail}
|
|
}
|
|
}
|
|
if !info.IsDir() {
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
|
Detail: fmt.Sprintf("base path %q is not a directory", absBase)}
|
|
}
|
|
|
|
entries, err := os.ReadDir(absBase)
|
|
if err != nil {
|
|
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start),
|
|
Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)}
|
|
}
|
|
|
|
detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries)))
|
|
status := plugins.StatusOK
|
|
if readOnly {
|
|
detail += " · read-only"
|
|
} else if werr := probeWritable(absBase); werr != nil {
|
|
status = plugins.StatusDegraded
|
|
detail += fmt.Sprintf(" · not writable: %v", werr)
|
|
} else {
|
|
detail += " · read-write"
|
|
}
|
|
return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail}
|
|
}
|
|
|
|
// Invoke runs one capability against the local filesystem.
|
|
func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
|
p.mu.Lock()
|
|
create, readOnly := p.createMissing, p.readOnly
|
|
p.mu.Unlock()
|
|
|
|
switch action {
|
|
case "list":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]fileInfo, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, dirEntryToInfo(e))
|
|
}
|
|
return json.Marshal(map[string]any{"path": target, "entries": out})
|
|
|
|
case "stat":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fi, err := os.Stat(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(statToInfo(fi))
|
|
|
|
case "download":
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := readCapped(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"path": target,
|
|
"size": len(data),
|
|
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
|
})
|
|
|
|
case "upload":
|
|
if readOnly {
|
|
return nil, errReadOnly
|
|
}
|
|
var in writeParams
|
|
if err := json.Unmarshal(params, &in); err != nil {
|
|
return nil, fmt.Errorf("invalid params: %w", err)
|
|
}
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
|
}
|
|
if create {
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := os.WriteFile(target, data, 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true})
|
|
|
|
case "delete":
|
|
if readOnly {
|
|
return nil, errReadOnly
|
|
}
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Refuse to delete the base path itself.
|
|
absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath))
|
|
if target == absBase {
|
|
return nil, errors.New("refusing to delete the base directory")
|
|
}
|
|
if err := os.Remove(target); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": target, "ok": true})
|
|
|
|
case "mkdir":
|
|
if readOnly {
|
|
return nil, errReadOnly
|
|
}
|
|
var in pathParams
|
|
_ = json.Unmarshal(params, &in)
|
|
target, err := p.resolve(in.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(map[string]any{"path": target, "ok": true})
|
|
|
|
default:
|
|
return nil, errors.New("unknown action: " + action)
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
|
|
|
var errReadOnly = errors.New("plugin is configured read-only")
|
|
|
|
// pathParams / writeParams are the Invoke request shapes (mirrors filetransfer).
|
|
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"`
|
|
}
|
|
|
|
func statToInfo(fi os.FileInfo) fileInfo {
|
|
return fileInfo{
|
|
Name: fi.Name(),
|
|
Size: fi.Size(),
|
|
IsDir: fi.IsDir(),
|
|
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
// dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single
|
|
// entry (e.g. a broken symlink) by reporting name/isDir without size/modtime.
|
|
func dirEntryToInfo(e os.DirEntry) fileInfo {
|
|
fi, err := e.Info()
|
|
if err != nil {
|
|
return fileInfo{Name: e.Name(), IsDir: e.IsDir()}
|
|
}
|
|
return statToInfo(fi)
|
|
}
|
|
|
|
// readCapped reads a file up to maxReadBytes.
|
|
func readCapped(path string) ([]byte, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
|
}
|
|
|
|
// probeWritable confirms the directory accepts a write by creating and removing a
|
|
// short-lived temp file.
|
|
func probeWritable(dir string) error {
|
|
f, err := os.CreateTemp(dir, ".pilotvault-health-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := f.Name()
|
|
_ = f.Close()
|
|
return os.Remove(name)
|
|
}
|
|
|
|
func ms(start time.Time) int64 { return time.Since(start).Milliseconds() }
|
|
|
|
func plural(n int) string {
|
|
if n == 1 {
|
|
return "y"
|
|
}
|
|
return "ies"
|
|
}
|