The project has no public installs, so there is nothing to migrate from. MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and the legacy path threaded through the Server all go. What is left is one store, PocketBase, and a plugins package that touches no filesystem at all. That was the last thing keeping api_data alive, so the volume goes too. All four compose files now declare exactly one volume, pb_data, and the standalone API Server compose declares none - it talks to an external PocketBase and has nothing of its own to keep. Backing up the stack is backing up one path again. Both images get simpler for it. The API Server image loses VOLUME /data and the su-exec entrypoint that existed only to fix a mounted volume's ownership, so it goes back to a plain USER app; its working directory is now /app and holds nothing. The AIO image loses its second volume and chowns only /pb/pb_data. One consequence worth stating plainly, because it is a small regression rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web App screens write .env in the working directory, which is now ephemeral. In the multi-container stack that changes nothing: compose sets all five of those keys as container environment, and loadDotEnv only applies a key that is not already set, so the file could never win a restart there anyway. In the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are not in that container's environment - so a service account fixed from the panel now lasts only until the container is recreated. Both READMEs say so. Moving those two screens into the app_settings singleton would close it properly; the PocketBase URL and credentials cannot follow, since they are how the database is reached in the first place. go build, go vet and go test ./... pass; the compose files parse and each resolves to a single pb_data volume. Not verified: no Docker CLI here, so neither image was built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
18 KiB
Go
495 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// Integrations exposes the Toyota Connected Europe plugin to end users under a
|
|
// three-layer cascade so that everyone can run it under their own MyToyota
|
|
// account, while a superadmin (and, in an organization, an org admin) can impose
|
|
// settings from above.
|
|
//
|
|
// Resolution is a cascade: the top layer wins, and a lower layer only fills a
|
|
// field the layers above left blank.
|
|
//
|
|
// - global (L1): pluginSettings on the app_settings singleton, set in the API
|
|
// Server panel by a superadmin. This is the top of the cascade for everyone.
|
|
// - org (L2): pluginSettings.toyota on the caller's organization record,
|
|
// editable by an org admin. Present only for users who belong to an org.
|
|
// - user (L3): pluginSettings.toyota on the caller's own user record.
|
|
//
|
|
// So the effective cascade is:
|
|
// - org user: global → org → user
|
|
// - org-less user: global → user
|
|
//
|
|
// The MyToyota username + password resolve together as a *pair* from the highest
|
|
// layer that supplies a username, so credential halves are never mixed across
|
|
// layers. The brand resolves on its own. Enablement is strictly per-user (L3),
|
|
// gated by the global master switch (the plugin being enabled in the panel) and,
|
|
// for org users, by the org gate.
|
|
//
|
|
// Secrets (and inherited usernames) are never returned to a lower-privileged
|
|
// client: the effective config is resolved server-side and only masked values
|
|
// leave the API. The live probe runs server-side against the resolved config.
|
|
|
|
const (
|
|
toyotaPlugin = "toyota"
|
|
toyotaSecretMask = "••••••••"
|
|
)
|
|
|
|
// toyotaConfig is one layer's Toyota settings.
|
|
type toyotaConfig struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Brand string `json:"brand"`
|
|
}
|
|
|
|
// toyotaStored is what we persist per user/org under pluginSettings.toyota.
|
|
type toyotaStored struct {
|
|
Config toyotaConfig `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. Only meaningful on the org record; ignored on user records.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// toyotaSettingsDoc is the pluginSettings JSON shape (only toyota today).
|
|
type toyotaSettingsDoc struct {
|
|
Toyota toyotaStored `json:"toyota"`
|
|
}
|
|
|
|
// toyotaFieldView is one field's resolved state for the UI.
|
|
type toyotaFieldView struct {
|
|
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
|
|
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
|
|
Source string `json:"source"` // global | org | user | unset
|
|
Locked bool `json:"locked"` // set above the caller's editable layer
|
|
}
|
|
|
|
// toyotaResolution is the fully-resolved Toyota state for one caller. It captures
|
|
// the effective cascade plus both editable layers (personal + organization), so
|
|
// an org admin can manage each independently.
|
|
type toyotaResolution struct {
|
|
eff toyotaConfig // effective (unmasked) — used only server-side (probes)
|
|
userOwn toyotaConfig // caller's personal (L3) values (unmasked)
|
|
orgOwn toyotaConfig // organization (L2) values (unmasked)
|
|
source map[string]string // field -> layer name (global|org|user|unset)
|
|
isSuper bool // superadmin: manages the global layer in the panel
|
|
canOrg bool // caller may edit the organization layer (org admin)
|
|
available bool // global master switch (plugin enabled in the panel)
|
|
orgEnabled bool // org gate (default true; gates the org's users)
|
|
enabled bool // caller's personal enable flag
|
|
}
|
|
|
|
// toyotaLayerRank orders the cascade layers; a higher number is lower priority.
|
|
var toyotaLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
|
|
|
// resolveToyota computes the cascade for a caller. userRaw is the caller's
|
|
// pluginSettings blob (read from their user record).
|
|
func (s *Server) resolveToyota(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) toyotaResolution {
|
|
g, masterEnabled, _ := s.plugins.RawConfig(toyotaPlugin)
|
|
gc := toyotaConfig{Username: g["username"], Password: g["password"], Brand: g["brand"]}
|
|
|
|
var oStored toyotaStored
|
|
if who.OrgID != "" {
|
|
oStored, _ = s.orgToyota(ctx, who.OrgID)
|
|
}
|
|
oc := oStored.Config
|
|
|
|
var uStored toyotaStored
|
|
if len(userRaw) > 0 {
|
|
var d toyotaSettingsDoc
|
|
_ = json.Unmarshal(userRaw, &d)
|
|
uStored = d.Toyota
|
|
}
|
|
uc := uStored.Config
|
|
|
|
res := toyotaResolution{
|
|
source: map[string]string{},
|
|
userOwn: uc,
|
|
orgOwn: oc,
|
|
isSuper: who.isSuperadmin(),
|
|
// An org admin may edit the organization layer in addition to their own
|
|
// personal layer. Requires the service account (org writes go through it).
|
|
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.pb.Configured(),
|
|
available: masterEnabled,
|
|
orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
|
|
enabled: uStored.Enabled,
|
|
}
|
|
|
|
// Ordered layers, top (highest priority) first.
|
|
type layer struct {
|
|
name string
|
|
c toyotaConfig
|
|
}
|
|
layers := []layer{{"global", gc}}
|
|
if who.OrgID != "" {
|
|
layers = append(layers, layer{"org", oc})
|
|
}
|
|
layers = append(layers, layer{"user", uc})
|
|
|
|
// Brand resolves independently: the highest layer that sets it wins.
|
|
res.source["brand"] = "unset"
|
|
for _, l := range layers {
|
|
if v := strings.TrimSpace(l.c.Brand); v != "" {
|
|
res.eff.Brand, res.source["brand"] = v, l.name
|
|
break
|
|
}
|
|
}
|
|
|
|
// Credentials resolve as a pair from the highest layer with a username, so
|
|
// the username and password never come from different layers.
|
|
credSrc := "unset"
|
|
for _, l := range layers {
|
|
if u := strings.TrimSpace(l.c.Username); u != "" {
|
|
res.eff.Username, res.eff.Password, credSrc = u, l.c.Password, l.name
|
|
break
|
|
}
|
|
}
|
|
res.source["username"] = credSrc
|
|
res.source["password"] = credSrc
|
|
return res
|
|
}
|
|
|
|
// toyotaLockedFor reports whether a field whose value comes from source is locked
|
|
// for a caller whose editable layer is editable (i.e. the value is set above them).
|
|
func toyotaLockedFor(source, editable string) bool {
|
|
if editable == "none" {
|
|
return true // superadmin edits the global layer in the panel, not here
|
|
}
|
|
sr, ok := toyotaLayerRank[source]
|
|
if !ok {
|
|
return false // unset — the caller may be the first to set it
|
|
}
|
|
return sr < toyotaLayerRank[editable]
|
|
}
|
|
|
|
// toyotaMaskPresent returns the secret mask when v is non-empty, else "".
|
|
func toyotaMaskPresent(v string) string {
|
|
if strings.TrimSpace(v) != "" {
|
|
return toyotaSecretMask
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// orgToyota reads an organization's stored Toyota 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) orgToyota(ctx context.Context, orgID string) (toyotaStored, json.RawMessage) {
|
|
if orgID == "" || !s.pb.Configured() {
|
|
return toyotaStored{}, nil
|
|
}
|
|
data, status, err := s.pb.Raw(ctx, http.MethodGet,
|
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
|
if err != nil || status != http.StatusOK {
|
|
return toyotaStored{}, nil
|
|
}
|
|
var rec struct {
|
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
|
}
|
|
_ = json.Unmarshal(data, &rec)
|
|
var doc toyotaSettingsDoc
|
|
if len(rec.PluginSettings) > 0 {
|
|
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
|
}
|
|
return doc.Toyota, rec.PluginSettings
|
|
}
|
|
|
|
// userPluginSettings reads a user's raw pluginSettings blob via the service
|
|
// account. Best effort: nil on any miss.
|
|
func (s *Server) userPluginSettings(ctx context.Context, id string) json.RawMessage {
|
|
if id == "" || !s.pb.Configured() {
|
|
return nil
|
|
}
|
|
var rec struct {
|
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
|
}
|
|
if err := s.pb.GetOne(ctx, s.usersCollection(), id, &rec); err != nil {
|
|
return nil
|
|
}
|
|
return rec.PluginSettings
|
|
}
|
|
|
|
// mergeToyota applies a mutation to the toyota entry of a pluginSettings blob,
|
|
// preserving any other plugin keys, and returns the new blob.
|
|
func mergeToyota(existing json.RawMessage, apply func(*toyotaStored)) 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 ts toyotaStored
|
|
if raw, ok := doc["toyota"]; ok {
|
|
_ = json.Unmarshal(raw, &ts)
|
|
}
|
|
apply(&ts)
|
|
b, _ := json.Marshal(ts)
|
|
doc["toyota"] = b
|
|
out, _ := json.Marshal(doc)
|
|
return out
|
|
}
|
|
|
|
// toyotaScopeView builds the masked field set for one editable scope. editable is
|
|
// the layer the caller edits in this scope ("user" | "org" | "none"); a field is
|
|
// locked when its effective value is set above that layer.
|
|
func (s *Server) toyotaScopeView(res toyotaResolution, editable string) map[string]any {
|
|
own := res.userOwn
|
|
if editable == "org" {
|
|
own = res.orgOwn
|
|
}
|
|
field := func(key, eff, ownv string, secret bool) toyotaFieldView {
|
|
src := res.source[key]
|
|
locked := toyotaLockedFor(src, editable)
|
|
fv := toyotaFieldView{Source: src, Locked: locked}
|
|
switch {
|
|
case secret:
|
|
// Never expose a secret; show only presence.
|
|
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
|
|
case key == "username" && locked:
|
|
// Inherited username — hide the concrete value from a lower layer.
|
|
fv.Effective, fv.Own = toyotaMaskPresent(eff), toyotaMaskPresent(ownv)
|
|
default:
|
|
fv.Effective, fv.Own = eff, ownv
|
|
}
|
|
return fv
|
|
}
|
|
return map[string]any{
|
|
"editableLayer": editable,
|
|
"fields": map[string]toyotaFieldView{
|
|
"username": field("username", res.eff.Username, own.Username, false),
|
|
"password": field("password", res.eff.Password, own.Password, true),
|
|
"brand": field("brand", res.eff.Brand, own.Brand, false),
|
|
},
|
|
}
|
|
}
|
|
|
|
// toyotaView builds the masked, client-safe response body from a resolution. It
|
|
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
|
|
// with its own locked-field state — so the UI can present the two independently.
|
|
func (s *Server) toyotaView(who *callerIdentity, res toyotaResolution) 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,
|
|
}
|
|
if res.isSuper {
|
|
// Superadmin manages the global layer in the panel; here it is read-only.
|
|
out["editableLayer"] = "none"
|
|
out["scopes"] = map[string]any{"user": s.toyotaScopeView(res, "none")}
|
|
return out
|
|
}
|
|
scopes := map[string]any{"user": s.toyotaScopeView(res, "user")}
|
|
if res.canOrg {
|
|
scopes["org"] = s.toyotaScopeView(res, "org")
|
|
}
|
|
out["scopes"] = scopes
|
|
return out
|
|
}
|
|
|
|
// GET /api/integrations/toyota — resolved Toyota view for the caller.
|
|
func (s *Server) handleGetToyota(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
res := s.resolveToyota(r.Context(), who, userRaw)
|
|
writeJSON(w, http.StatusOK, s.toyotaView(who, res))
|
|
}
|
|
|
|
// PUT /api/integrations/toyota — save the caller's editable layer. Body:
|
|
// {enabled?: bool, scope?: "user"|"org", config?: {username, password, brand}}.
|
|
// Fields locked above the caller are ignored; a password left at the mask is
|
|
// preserved.
|
|
func (s *Server) handlePutToyota(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
if !s.pb.Configured() {
|
|
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
|
|
return
|
|
}
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
|
|
Config map[string]string `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
res := s.resolveToyota(r.Context(), who, userRaw)
|
|
|
|
// Resolve which layer this write targets. Everyone edits their own personal
|
|
// (user) layer by default; an org admin may target the organization layer by
|
|
// asking for scope "org". Superadmins are read-only here (they manage global
|
|
// in the panel) and may only toggle their personal enable flag.
|
|
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"
|
|
}
|
|
|
|
// Build the new target-layer config from its current own values, overlaying
|
|
// only fields the caller is allowed to change in this scope.
|
|
newOwn := res.userOwn
|
|
if editable == "org" {
|
|
newOwn = res.orgOwn
|
|
}
|
|
applyField := func(key string, set func(*toyotaConfig, string)) {
|
|
v, ok := body.Config[key]
|
|
if !ok || toyotaLockedFor(res.source[key], editable) {
|
|
return
|
|
}
|
|
if key == "password" && v == toyotaSecretMask {
|
|
return // keep current secret
|
|
}
|
|
set(&newOwn, strings.TrimSpace(v))
|
|
}
|
|
applyField("username", func(c *toyotaConfig, v string) { c.Username = v })
|
|
applyField("password", func(c *toyotaConfig, v string) { c.Password = v })
|
|
applyField("brand", func(c *toyotaConfig, v string) { c.Brand = v })
|
|
|
|
// Persist the organization layer (admins) via the service account.
|
|
if editable == "org" {
|
|
_, orgRaw := s.orgToyota(r.Context(), who.OrgID)
|
|
newDoc := mergeToyota(orgRaw, func(ts *toyotaStored) {
|
|
ts.Config = newOwn
|
|
// In the org scope the enable flag is the org master switch, stored
|
|
// inverted (disabled) so absent means enabled.
|
|
if body.Enabled != nil {
|
|
ts.Disabled = !*body.Enabled
|
|
}
|
|
})
|
|
_, st, err := s.pb.Raw(r.Context(), http.MethodPatch,
|
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(who.OrgID),
|
|
map[string]json.RawMessage{"pluginSettings": newDoc})
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if st != http.StatusOK {
|
|
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Persist the user record: the personal enable flag lives here (user/
|
|
// superadmin scope — in the org scope it targets the org gate instead), and
|
|
// so does the personal config layer when this write targets the user scope.
|
|
personalEnable := body.Enabled != nil && editable != "org"
|
|
if personalEnable || editable == "user" {
|
|
newDoc := mergeToyota(userRaw, func(ts *toyotaStored) {
|
|
if personalEnable {
|
|
ts.Enabled = *body.Enabled
|
|
}
|
|
if editable == "user" {
|
|
ts.Config = newOwn
|
|
}
|
|
})
|
|
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
|
|
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Re-resolve and return the fresh view.
|
|
fresh := s.userPluginSettings(r.Context(), who.ID)
|
|
res2 := s.resolveToyota(r.Context(), who, fresh)
|
|
writeJSON(w, http.StatusOK, s.toyotaView(who, res2))
|
|
}
|
|
|
|
// POST /api/integrations/toyota/health — live probe using the caller's resolved
|
|
// config. Never returns secrets.
|
|
func (s *Server) handleToyotaHealth(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
res := s.resolveToyota(r.Context(), who, userRaw)
|
|
|
|
down := func(detail string) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": detail}})
|
|
}
|
|
switch {
|
|
case !res.available:
|
|
down("The Toyota integration is disabled by the administrator")
|
|
return
|
|
case !res.orgEnabled:
|
|
down("The Toyota integration is disabled for your organization")
|
|
return
|
|
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
|
down("Enter your MyToyota email and password to connect")
|
|
return
|
|
}
|
|
|
|
cfg := map[string]string{
|
|
"username": res.eff.Username,
|
|
"password": res.eff.Password,
|
|
"brand": res.eff.Brand,
|
|
}
|
|
h, err := s.plugins.HealthCheckWith(r.Context(), toyotaPlugin, cfg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
|
}
|
|
|
|
// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles as the
|
|
// upstream returned them, fetched server-side under their resolved credentials.
|
|
// Gated by the same switches as the settings view (global master, org gate,
|
|
// personal opt-in, credentials present); when any gate is off it returns 200 with
|
|
// an empty list plus a reason, so the UI can degrade quietly rather than error.
|
|
//
|
|
// The gate and its wording live in toyotaSource (vehicleproviders_toyota.go), so
|
|
// this endpoint and the generic vehicle-provider endpoints cannot drift apart.
|
|
// This one stays because it relays the raw payload; /api/vehicle-providers/
|
|
// toyota/vehicles returns the normalized, importable shape.
|
|
func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
cfg, ok, detail := toyotaSource{}.gate(r.Context(), s, who, userRaw)
|
|
if !ok {
|
|
writeJSON(w, http.StatusOK, map[string]any{"vehicles": []any{}, "unavailable": true, "detail": detail})
|
|
return
|
|
}
|
|
|
|
raw, err := s.plugins.InvokeWith(r.Context(), toyotaPlugin, cfg, "vehicles", nil)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
// Relay the upstream JSON verbatim under "vehicles".
|
|
writeJSON(w, http.StatusOK, map[string]any{"vehicles": json.RawMessage(raw)})
|
|
}
|