Files
PilotVault/API Server/internal/api/integrations_localstorage.go
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

520 lines
19 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
)
// This file exposes the "localstorage" (host local filesystem) plugin's settings
// to end users through the same three-layer cascade OpenSky and filetransfer use
// (superadmin/global → organization → user); see integrations.go for the shared
// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr).
//
// - global (L1): the plugin's config in plugins.json, set in the API Server panel
// (the storage root basePath + the master switch + a global read-only default).
// - org (L2): pluginSettings.localstorage on the caller's organization record.
// - user (L3): pluginSettings.localstorage on the caller's own user record.
//
// Unlike filetransfer, a local drive gives each tenant *isolated* folders rather
// than a freely-chosen path. Folders are derived from identity, never taken from a
// client, and laid out so that "private" is genuinely private:
//
// <root>/orgs/<orgId>/shared — the organization folder (all members)
// <root>/orgs/<orgId>/private/<userId>— a member's private folder (opt-in)
// <root>/users/<userId> — an org-less user's private folder
//
// A member on the shared folder is confined to .../shared and cannot traverse into
// anyone's .../private subtree; each private folder is confined to its own
// .../private/<userId>. So an org member can hold BOTH the shared org folder and a
// private folder nested inside the org folder, reachable only by them. The plugin
// confines every operation within the folder it is handed, so isolation is enforced
// end-to-end.
//
// A member opts into their private folder personally (user layer); an org admin may
// gate the feature for the whole organization (org layer, default allowed). The one
// other cascading tunable is readOnly (blank falls through, top wins, a set value
// locks lower layers).
const localStoragePlugin = "localstorage"
// lsConfig is one layer's editable localstorage settings. Folders are not here:
// they are computed from identity, never stored or taken from a client.
type lsConfig struct {
ReadOnly string `json:"readOnly"` // "" (inherit) | "true" | "false"
}
// lsStored is what we persist per user/org under pluginSettings.localstorage.
type lsStored struct {
Config lsConfig `json:"config"`
// Enabled is the personal per-user opt-in (user layer). Default false.
Enabled bool `json:"enabled"`
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
Disabled bool `json:"disabled,omitempty"`
// PrivateFolder is the user layer's opt-in for a private folder inside the org
// folder. Only meaningful for a caller who belongs to an organization.
PrivateFolder bool `json:"privateFolder,omitempty"`
// DisallowPrivate is the org layer's gate on private folders, stored inverted so
// that absent == allowed. Only meaningful on the org record.
DisallowPrivate bool `json:"disallowPrivate,omitempty"`
}
// lsSettingsDoc is the localstorage slice of the shared pluginSettings JSON.
type lsSettingsDoc struct {
LocalStorage lsStored `json:"localstorage"`
}
// lsMount is one isolated folder a caller can reach.
type lsMount struct {
ID string `json:"id"` // "shared" | "private" | "personal"
Label string `json:"label"` // human label for the UI
Path string `json:"path"` // absolute folder path
Kind string `json:"kind"` // "shared" | "private"
}
// lsResolution is the fully-resolved localstorage state for one caller.
type lsResolution struct {
readOnly string // effective read-only ("" | "true" | "false")
userReadOnly string // user (L3) read-only value
orgReadOnly string // organization (L2) read-only value
root string // global storage root
mounts []lsMount // isolated folders in force for this caller
source map[string]string // field -> layer name (global|org|user|unset)
isSuper bool
canOrg bool
available bool // global master switch
orgEnabled bool // org master switch (default true)
enabled bool // personal opt-in to use the plugin
isOrgUser bool // caller belongs to an organization
allowPrivate bool // org policy: private folders permitted (default true)
wantsPrivate bool // user's raw private-folder opt-in
privateOn bool // effective: org user + allowed + opted in
}
// Folder builders. Forward-slash joins (path, not filepath) since the target host
// is Linux; the plugin re-resolves against the OS filesystem and confines within.
func orgSharedFolder(root, orgID string) string {
return path.Join(root, "orgs", orgID, "shared")
}
func orgPrivateFolder(root, orgID, userID string) string {
return path.Join(root, "orgs", orgID, "private", userID)
}
func userPersonalFolder(root, userID string) string {
return path.Join(root, "users", userID)
}
// tenantMounts computes the isolated folders for a caller. An org member always
// gets the shared org folder and, when privateOn, an additional private folder
// nested inside the org folder; an org-less user gets a single private folder.
func tenantMounts(root string, who *callerIdentity, privateOn bool) []lsMount {
root = strings.TrimSpace(root)
if root == "" {
return []lsMount{}
}
if who.OrgID != "" {
mounts := []lsMount{{
ID: "shared", Label: "Organization folder", Kind: "shared",
Path: orgSharedFolder(root, who.OrgID),
}}
if privateOn && who.ID != "" {
mounts = append(mounts, lsMount{
ID: "private", Label: "Your private folder", Kind: "private",
Path: orgPrivateFolder(root, who.OrgID, who.ID),
})
}
return mounts
}
if who.ID != "" {
return []lsMount{{
ID: "personal", Label: "Your private folder", Kind: "private",
Path: userPersonalFolder(root, who.ID),
}}
}
return []lsMount{}
}
// resolveLocalStorage computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (from their auth-refresh record).
func (s *Server) resolveLocalStorage(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) lsResolution {
g, masterEnabled, _ := s.plugins.RawConfig(localStoragePlugin)
root := strings.TrimSpace(g["basePath"])
// The global panel's readOnly select defaults to a concrete "false"; treat that
// as unset so the global default does not permanently lock lower layers. Only an
// explicit global "true" freezes every folder.
globalRO := strings.TrimSpace(g["readOnly"])
if strings.EqualFold(globalRO, "false") {
globalRO = ""
}
var oStored lsStored
if who.OrgID != "" {
oStored, _ = s.orgLocalStorage(ctx, who.OrgID)
}
var uStored lsStored
if len(userRaw) > 0 {
var d lsSettingsDoc
_ = json.Unmarshal(userRaw, &d)
uStored = d.LocalStorage
}
res := lsResolution{
source: map[string]string{},
userReadOnly: uStored.Config.ReadOnly,
orgReadOnly: oStored.Config.ReadOnly,
isSuper: who.isSuperadmin(),
// An org admin may edit the organization layer in addition to their own.
// Requires the service account (org writes go through it).
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
available: masterEnabled,
orgEnabled: !oStored.Disabled,
enabled: uStored.Enabled,
root: root,
isOrgUser: who.OrgID != "",
allowPrivate: !oStored.DisallowPrivate, // default allowed
wantsPrivate: uStored.PrivateFolder,
}
// readOnly cascades top-wins, blanks fall through (same mechanism as OpenSky).
type layer struct{ name, ro string }
layers := []layer{{"global", globalRO}}
if who.OrgID != "" {
layers = append(layers, layer{"org", oStored.Config.ReadOnly})
}
layers = append(layers, layer{"user", uStored.Config.ReadOnly})
roSrc := "unset"
for _, l := range layers {
if v := strings.TrimSpace(l.ro); v != "" {
res.readOnly, roSrc = v, l.name
break
}
}
res.source["readOnly"] = roSrc
// Effective private-folder state and the isolated folders in force.
res.privateOn = res.isOrgUser && res.allowPrivate && res.wantsPrivate
res.mounts = tenantMounts(root, who, res.privateOn)
return res
}
// orgLocalStorage reads an organization's stored localstorage settings (config +
// the org gate) and its raw pluginSettings blob via the service account. Best
// effort: zero values on any miss so callers proceed as if the org layer were empty.
func (s *Server) orgLocalStorage(ctx context.Context, orgID string) (lsStored, json.RawMessage) {
if orgID == "" || !s.admin.configured() {
return lsStored{}, nil
}
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
if err != nil || status != http.StatusOK {
return lsStored{}, nil
}
var rec struct {
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(data, &rec)
var doc lsSettingsDoc
if len(rec.PluginSettings) > 0 {
_ = json.Unmarshal(rec.PluginSettings, &doc)
}
return doc.LocalStorage, rec.PluginSettings
}
// mergeLocalStorage applies a mutation to the localstorage entry of a
// pluginSettings blob, preserving any other plugin keys, and returns the new blob.
func mergeLocalStorage(existing json.RawMessage, apply func(*lsStored)) json.RawMessage {
doc := map[string]json.RawMessage{}
if len(existing) > 0 {
_ = json.Unmarshal(existing, &doc)
}
if doc == nil {
doc = map[string]json.RawMessage{} // existing was JSON null
}
var ls lsStored
if raw, ok := doc["localstorage"]; ok {
_ = json.Unmarshal(raw, &ls)
}
apply(&ls)
b, _ := json.Marshal(ls)
doc["localstorage"] = b
out, _ := json.Marshal(doc)
return out
}
// GET /api/integrations/localstorage — resolved view for the caller.
func (s *Server) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveLocalStorage(r.Context(), who, userRaw)
writeJSON(w, http.StatusOK, s.localStorageView(who, res))
}
// lsScopeView builds the field set for one editable scope. editable is the layer
// the caller edits ("user" | "org" | "none"); readOnly is locked when its effective
// value is set above that layer.
func (s *Server) lsScopeView(res lsResolution, editable string) map[string]any {
own := res.userReadOnly
if editable == "org" {
own = res.orgReadOnly
}
src := res.source["readOnly"]
field := osFieldView{
Source: src,
Locked: lockedFor(src, editable),
Effective: res.readOnly,
Own: own,
}
return map[string]any{
"editableLayer": editable,
"fields": map[string]osFieldView{"readOnly": field},
}
}
// localStorageView builds the client-safe response body. It exposes a "user" scope
// for everyone plus, for org admins, an "org" scope. The effective isolated folders
// are reported at the top level (derived, not editable).
func (s *Server) localStorageView(who *callerIdentity, res lsResolution) map[string]any {
out := map[string]any{
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
"isSuperadmin": res.isSuper,
"isOrgUser": res.isOrgUser,
"rootConfigured": strings.TrimSpace(res.root) != "",
"mounts": res.mounts,
"privateFolder": res.wantsPrivate, // the user's own opt-in
"privateEnabled": res.privateOn, // effective (may be gated off by the org)
"allowPrivate": res.allowPrivate, // org policy
}
if res.isSuper {
out["editableLayer"] = "none"
out["scopes"] = map[string]any{"user": s.lsScopeView(res, "none")}
return out
}
scopes := map[string]any{"user": s.lsScopeView(res, "user")}
if res.canOrg {
scopes["org"] = s.lsScopeView(res, "org")
}
out["scopes"] = scopes
return out
}
// PUT /api/integrations/localstorage — save the caller's editable layer. Body:
// {enabled?, privateFolder?, allowPrivate?, scope?, config?}. Folders are derived,
// so only readOnly, the enable flags, and the private-folder settings are writable.
func (s *Server) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
var body struct {
Enabled *bool `json:"enabled"`
PrivateFolder *bool `json:"privateFolder"` // user: opt into a private folder
AllowPrivate *bool `json:"allowPrivate"` // org: permit private folders
Scope string `json:"scope"`
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
res := s.resolveLocalStorage(r.Context(), who, userRaw)
// Resolve which layer this write targets.
editable := "user"
switch {
case res.isSuper:
editable = "none"
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
if !res.canOrg {
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
return
}
editable = "org"
}
// Overlay readOnly onto the target scope's own value, unless it is locked above.
newRO := res.userReadOnly
if editable == "org" {
newRO = res.orgReadOnly
}
if v, present := body.Config["readOnly"]; present && !lockedFor(res.source["readOnly"], editable) {
newRO = normalizeReadOnly(v)
}
// Persist the organization layer (admins) via the service account.
if editable == "org" {
if who.OrgID == "" {
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
return
}
if !s.admin.configured() {
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
return
}
_, orgRaw := s.orgLocalStorage(r.Context(), who.OrgID)
newDoc := mergeLocalStorage(orgRaw, func(ls *lsStored) {
ls.Config.ReadOnly = newRO
if body.Enabled != nil {
ls.Disabled = !*body.Enabled // org master switch, stored inverted
}
if body.AllowPrivate != nil {
ls.DisallowPrivate = !*body.AllowPrivate // stored inverted (absent = allowed)
}
})
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
map[string]json.RawMessage{"pluginSettings": newDoc})
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
if st != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save organization settings")
return
}
}
// Persist the user record: the personal enable flag and private-folder opt-in
// live here (user/superadmin scope), and so does the personal readOnly when this
// write targets the user scope.
personalEnable := body.Enabled != nil && editable != "org"
personalPrivate := body.PrivateFolder != nil && editable != "org"
if personalEnable || personalPrivate || editable == "user" {
newDoc := mergeLocalStorage(userRaw, func(ls *lsStored) {
if personalEnable {
ls.Enabled = *body.Enabled
}
if personalPrivate {
ls.PrivateFolder = *body.PrivateFolder
}
if editable == "user" {
ls.Config.ReadOnly = newRO
}
})
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
} else if code != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not save user settings")
return
}
}
// Re-resolve and return the fresh view.
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
if err != nil || st != http.StatusOK || fresh == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
res2 := s.resolveLocalStorage(r.Context(), who, fresh.Record["pluginSettings"])
writeJSON(w, http.StatusOK, s.localStorageView(who, res2))
}
// normalizeReadOnly coerces a submitted readOnly value to the stored vocabulary.
func normalizeReadOnly(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true":
return "true"
case "false":
return "false"
default:
return "" // inherit
}
}
// POST /api/integrations/localstorage/health — live probe against every isolated
// folder the caller holds (each auto-created), honouring the resolved read-only flag.
func (s *Server) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) {
who, userRaw, ok := s.integrationCaller(w, r)
if !ok {
return
}
if !who.isSuperadmin() {
if _, _, ok := s.plugins.RawConfig(localStoragePlugin); !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
}
res := s.resolveLocalStorage(r.Context(), who, userRaw)
if !res.available {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "Local storage is disabled by the administrator"}})
return
}
if !res.orgEnabled {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "Local storage is disabled for your organization"}})
return
}
if len(res.mounts) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
"status": "down", "detail": "No storage root configured by the administrator"}})
return
}
// Probe each folder; aggregate to the worst status for the summary badge and
// return per-folder results so the UI can annotate each mount.
perMount := make([]map[string]any, 0, len(res.mounts))
worst := "ok"
for _, m := range res.mounts {
cfg := map[string]string{
"basePath": m.Path,
"createMissing": "true", // each folder is provisioned on demand
"readOnly": res.readOnly,
}
h, err := s.plugins.HealthCheckWith(r.Context(), localStoragePlugin, cfg)
status, detail := "down", ""
if err != nil {
detail = err.Error()
} else {
status, detail = h.Status, h.Detail
}
worst = worseStatus(worst, status)
perMount = append(perMount, map[string]any{
"id": m.ID, "label": m.Label, "path": m.Path, "status": status, "detail": detail,
})
}
summary := fmt.Sprintf("%d folder%s reachable", len(res.mounts), plural2(len(res.mounts)))
if worst != "ok" {
// Surface the first non-ok detail so the badge is actionable.
for _, m := range perMount {
if m["status"] != "ok" {
summary = fmt.Sprintf("%s: %v", m["label"], m["detail"])
break
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"health": map[string]any{"status": worst, "detail": summary},
"mounts": perMount,
})
}
// worseStatus returns the more severe of two health statuses (ok < degraded < down).
func worseStatus(a, b string) string {
rank := map[string]int{"ok": 0, "degraded": 1, "down": 2}
if rank[b] > rank[a] {
return b
}
return a
}
func plural2(n int) string {
if n == 1 {
return ""
}
return "s"
}