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: // // /orgs//shared — the organization folder (all members) // /orgs//private/— a member's private folder (opt-in) // /users/ — 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/. 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" }