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>
520 lines
19 KiB
Go
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"
|
|
}
|