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>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,368 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package localstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "localstorage" {
|
||||
t.Fatalf("name = %q, want localstorage", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryDrivesLocal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesLocal)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("localstorage"); !ok {
|
||||
t.Fatal("localstorage not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfinement verifies that traversal, absolute-looking, and
|
||||
// backslash paths all stay under the base directory.
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{basePath: base}
|
||||
absBase, _ := filepath.Abs(base)
|
||||
|
||||
contained := []string{"a/b.txt", "/etc/passwd", "../../../etc/passwd", "a\\b", "./x", ""}
|
||||
for _, in := range contained {
|
||||
got, err := p.resolve(in)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve(%q) errored: %v", in, err)
|
||||
}
|
||||
if got != absBase && !strings.HasPrefix(got, absBase+string(os.PathSeparator)) {
|
||||
t.Errorf("resolve(%q) = %q escaped base %q", in, got, absBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNoBase(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if _, err := p.resolve("x"); err == nil {
|
||||
t.Fatal("expected error when base path unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckMissing(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Base path unset -> down.
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("unset base: status = %q, want down", h.Status)
|
||||
}
|
||||
// Nonexistent path without createMissing -> down.
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": filepath.Join(t.TempDir(), "nope")})
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("missing base: status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckCreateMissing(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "created")
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": dir, "createMissing": "true"})
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusOK {
|
||||
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("base path was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTrip exercises upload -> list -> download -> delete end to end.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "createMissing": "true"})
|
||||
|
||||
payload := []byte("hello pilotvault")
|
||||
up, _ := json.Marshal(writeParams{Path: "sub/dir/file.txt", ContentBase64: base64.StdEncoding.EncodeToString(payload)})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
dl, _ := json.Marshal(pathParams{Path: "sub/dir/file.txt"})
|
||||
raw, err := p.Invoke(context.Background(), "download", dl)
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
if decoded, _ := base64.StdEncoding.DecodeString(got.ContentBase64); string(decoded) != string(payload) {
|
||||
t.Fatalf("download content = %q, want %q", decoded, payload)
|
||||
}
|
||||
|
||||
if _, err := p.Invoke(context.Background(), "delete", dl); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "sub", "dir", "file.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("file still present after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyRejectsWrites(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "readOnly": "true"})
|
||||
|
||||
up, _ := json.Marshal(writeParams{Path: "x.txt", ContentBase64: ""})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err == nil {
|
||||
t.Error("upload should be rejected in read-only mode")
|
||||
}
|
||||
del, _ := json.Marshal(pathParams{Path: "x.txt"})
|
||||
if _, err := p.Invoke(context.Background(), "delete", del); err == nil {
|
||||
t.Error("delete should be rejected in read-only mode")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user