Files
PilotVault/API Server/internal/plugins/builtin/localstorage/localstorage.go
T
tajniak81andClaude Opus 4.8 afc6952eda Initial commit: PilotVault multi-service project
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>
2026-07-13 11:43:33 +02:00

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"
}