package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" ) // This file exposes the "openweather" plugin's settings to end users through the // same three-layer cascade OpenSky uses (superadmin/global → organization → // user); see integrations.go for the shared helpers (lockedFor, layerRank, // maskPresent, callerFromRecord) and integrations_webdav.go for the endpoint shape. // // - global (L1): the plugin's config in plugins.json, set in the API Server panel. // - org (L2): pluginSettings.openweather on the caller's organization record. // - user (L3): pluginSettings.openweather on the caller's own user record. // // Unlike WebDAV (a connection group), every OpenWeather field resolves // *independently* — top layer wins, a blank field falls through. There is a // single secret (the API key) with no paired half, so no field group is needed. // // The API key is never returned to a lower-privileged client: the effective // config is resolved server-side and only masked values leave the API. Live // probes run server-side against the resolved config. const openWeatherPlugin = "openweather" // owFields are the plugin's ConfigField keys, in display order. Kept in sync with // the plugin descriptor so the cascade covers every setting. var owFields = []string{"apiKey", "units", "lat", "lon", "lang", "callsPerMinute"} // owSecretKeys are masked in every view and preserved on save when left at the mask. var owSecretKeys = map[string]bool{"apiKey": true} // owConfig is one layer's openweather settings. Values are strings to match the // plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init). type owConfig struct { APIKey string `json:"apiKey"` Units string `json:"units"` Lat string `json:"lat"` Lon string `json:"lon"` Lang string `json:"lang"` CallsPerMinute string `json:"callsPerMinute"` } // owStored is what we persist per user/org under pluginSettings.openweather. type owStored struct { Config owConfig `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"` } // owSettingsDoc is the openweather slice of the shared pluginSettings JSON. type owSettingsDoc struct { OpenWeather owStored `json:"openweather"` } // owResolution is the fully-resolved openweather state for one caller. type owResolution struct { eff owConfig // effective (unmasked) — used only server-side (probes) userOwn owConfig // caller's personal (L3) values (unmasked) orgOwn owConfig // 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 master switch (default true; gates the org's users) enabled bool // caller's personal enable flag } // owConfigFromMap builds an owConfig from a flat string map (global plugin config). func owConfigFromMap(m map[string]string) owConfig { return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"], CallsPerMinute: m["callsPerMinute"]} } // owGet returns a config field by the plugin's key name. func owGet(c owConfig, key string) string { switch key { case "apiKey": return c.APIKey case "units": return c.Units case "lat": return c.Lat case "lon": return c.Lon case "lang": return c.Lang case "callsPerMinute": return c.CallsPerMinute } return "" } // owSet writes a config field by the plugin's key name. func owSet(c *owConfig, key, v string) { switch key { case "apiKey": c.APIKey = v case "units": c.Units = v case "lat": c.Lat = v case "lon": c.Lon = v case "lang": c.Lang = v case "callsPerMinute": c.CallsPerMinute = v } } // resolveOpenWeather computes the cascade for a caller. userRaw is the caller's // pluginSettings blob (from their auth-refresh record). func (s *Server) resolveOpenWeather(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) owResolution { g, masterEnabled, _ := s.plugins.RawConfig(openWeatherPlugin) gc := owConfigFromMap(g) var oStored owStored if who.OrgID != "" { oStored, _ = s.orgOpenWeather(ctx, who.OrgID) } oc := oStored.Config var uStored owStored if len(userRaw) > 0 { var d owSettingsDoc _ = json.Unmarshal(userRaw, &d) uStored = d.OpenWeather } uc := uStored.Config res := owResolution{ source: map[string]string{}, userOwn: uc, orgOwn: oc, 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, } // Ordered layers, top (highest priority) first. type layer struct { name string c owConfig } layers := []layer{{"global", gc}} if who.OrgID != "" { layers = append(layers, layer{"org", oc}) } layers = append(layers, layer{"user", uc}) // Every field cascades independently: top wins, blanks fall through. for _, key := range owFields { src := "unset" for _, l := range layers { if v := strings.TrimSpace(owGet(l.c, key)); v != "" { owSet(&res.eff, key, v) src = l.name break } } res.source[key] = src } return res } // orgOpenWeather reads an organization's stored openweather 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) orgOpenWeather(ctx context.Context, orgID string) (owStored, json.RawMessage) { if orgID == "" || !s.admin.configured() { return owStored{}, 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 owStored{}, nil } var rec struct { PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.Unmarshal(data, &rec) var doc owSettingsDoc if len(rec.PluginSettings) > 0 { _ = json.Unmarshal(rec.PluginSettings, &doc) } return doc.OpenWeather, rec.PluginSettings } // mergeOpenWeather applies a mutation to the openweather entry of a pluginSettings // blob, preserving any other plugin keys (e.g. opensky, webdav), and returns the // new blob. func mergeOpenWeather(existing json.RawMessage, apply func(*owStored)) 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 ow owStored if raw, ok := doc["openweather"]; ok { _ = json.Unmarshal(raw, &ow) } apply(&ow) b, _ := json.Marshal(ow) doc["openweather"] = b out, _ := json.Marshal(doc) return out } // GET /api/integrations/openweather — resolved view for the caller. func (s *Server) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) { who, userRaw, ok := s.integrationCaller(w, r) if !ok { return } res := s.resolveOpenWeather(r.Context(), who, userRaw) writeJSON(w, http.StatusOK, s.openWeatherView(who, res)) } // owScopeView builds the masked field set for one editable scope. editable is the // layer the caller edits ("user" | "org" | "none"); a field is locked when its // effective value is set above that layer. func (s *Server) owScopeView(res owResolution, editable string) map[string]any { own := res.userOwn if editable == "org" { own = res.orgOwn } fields := map[string]osFieldView{} for _, key := range owFields { src := res.source[key] fv := osFieldView{Source: src, Locked: lockedFor(src, editable)} if owSecretKeys[key] { fv.Effective, fv.Own = maskPresent(owGet(res.eff, key)), maskPresent(owGet(own, key)) } else { fv.Effective, fv.Own = owGet(res.eff, key), owGet(own, key) } fields[key] = fv } return map[string]any{"editableLayer": editable, "fields": fields} } // openWeatherView builds the masked, client-safe response body. It exposes a // "user" scope for everyone plus, for org admins, an "org" scope. func (s *Server) openWeatherView(who *callerIdentity, res owResolution) 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 { out["editableLayer"] = "none" out["scopes"] = map[string]any{"user": s.owScopeView(res, "none")} return out } scopes := map[string]any{"user": s.owScopeView(res, "user")} if res.canOrg { scopes["org"] = s.owScopeView(res, "org") } out["scopes"] = scopes return out } // PUT /api/integrations/openweather — save the caller's editable layer. Body: // {enabled?, scope?, config?}. Fields locked above the caller are ignored; the // API key left at the mask is preserved. func (s *Server) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") var body struct { Enabled *bool `json:"enabled"` 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.resolveOpenWeather(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 the fields the caller may change in this scope onto its own values. newOwn := res.userOwn if editable == "org" { newOwn = res.orgOwn } for _, key := range owFields { v, present := body.Config[key] if !present || lockedFor(res.source[key], editable) { continue } if owSecretKeys[key] && v == openSkySecretMask { continue // keep current secret } owSet(&newOwn, key, strings.TrimSpace(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.orgOpenWeather(r.Context(), who.OrgID) newDoc := mergeOpenWeather(orgRaw, func(ow *owStored) { ow.Config = newOwn if body.Enabled != nil { ow.Disabled = !*body.Enabled // org master switch, stored inverted } }) _, 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 lives here (user/superadmin // scope), and so does the personal config layer when this write targets user. personalEnable := body.Enabled != nil && editable != "org" if personalEnable || editable == "user" { newDoc := mergeOpenWeather(userRaw, func(ow *owStored) { if personalEnable { ow.Enabled = *body.Enabled } if editable == "user" { ow.Config = newOwn } }) 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.resolveOpenWeather(r.Context(), who, fresh.Record["pluginSettings"]) writeJSON(w, http.StatusOK, s.openWeatherView(who, res2)) } // POST /api/integrations/openweather/health — live probe using the caller's // resolved config. Never returns the API key. func (s *Server) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) { who, userRaw, ok := s.integrationCaller(w, r) if !ok { return } if !who.isSuperadmin() { if _, _, ok := s.plugins.RawConfig(openWeatherPlugin); !ok { writeError(w, http.StatusNotFound, "unknown plugin") return } } res := s.resolveOpenWeather(r.Context(), who, userRaw) if !res.available { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ "status": "down", "detail": "OpenWeather is disabled by the administrator"}}) return } if !res.orgEnabled { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ "status": "down", "detail": "OpenWeather is disabled for your organization"}}) return } if strings.TrimSpace(res.eff.APIKey) == "" { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{ "status": "down", "detail": "No API key configured — add one to connect"}}) return } cfg := map[string]string{} for _, k := range owFields { cfg[k] = owGet(res.eff, k) } h, err := s.plugins.HealthCheckWith(r.Context(), openWeatherPlugin, cfg) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"health": h}) } // owWeather is the trimmed current-conditions shape the Overview weather card needs, // flattened out of OpenWeather's richer /data/2.5/weather payload. type owWeather struct { Location string `json:"location"` Country string `json:"country"` Temp *float64 `json:"temp"` FeelsLike *float64 `json:"feelsLike"` Description string `json:"description"` Icon string `json:"icon"` // OpenWeather icon code, e.g. "01d" Humidity *int `json:"humidity"` WindSpeed *float64 `json:"windSpeed"` WindDeg *int `json:"windDeg"` Clouds *int `json:"clouds"` Dt int64 `json:"dt"` // observation time (unix seconds) } // validLatLon reports whether lat/lon are well-formed geographic coordinates. func validLatLon(lat, lon string) bool { la, e1 := strconv.ParseFloat(lat, 64) lo, e2 := strconv.ParseFloat(lon, 64) return e1 == nil && e2 == nil && la >= -90 && la <= 90 && lo >= -180 && lo <= 180 } // GET /api/integrations/openweather/current — current conditions for the caller's // resolved location (or a supplied ?lat=&lon= point), for the Overview weather card. // Runs server-side against the resolved cascade config (never returns the API key). // Gated by the same switches as the settings view: global master, org gate, and the // caller's personal opt-in. When any gate is off (or no key resolves) it returns 200 // with {unavailable:true, detail} so the card can degrade quietly rather than error. func (s *Server) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) { who, userRaw, ok := s.integrationCaller(w, r) if !ok { return } res := s.resolveOpenWeather(r.Context(), who, userRaw) units := res.eff.Units if units == "" { units = "metric" // matches the plugin's runtime fallback } unavailable := func(detail string) { writeJSON(w, http.StatusOK, map[string]any{"unavailable": true, "detail": detail, "units": units}) } switch { case !res.available: unavailable("OpenWeather is disabled by the administrator") return case !res.orgEnabled: unavailable("OpenWeather is disabled for your organization") return case !res.enabled: unavailable("Enable OpenWeather in Settings → Integrations to show weather") return case strings.TrimSpace(res.eff.APIKey) == "": unavailable("No API key configured for OpenWeather") return } // Optional point override (drone/device/browser location the Overview resolves). // Malformed input is ignored so the plugin falls back to the configured default. var payload json.RawMessage lat := strings.TrimSpace(r.URL.Query().Get("lat")) lon := strings.TrimSpace(r.URL.Query().Get("lon")) if validLatLon(lat, lon) { payload, _ = json.Marshal(map[string]string{"lat": lat, "lon": lon}) } cfg := map[string]string{} for _, k := range owFields { cfg[k] = owGet(res.eff, k) } raw, err := s.plugins.InvokeWith(r.Context(), openWeatherPlugin, cfg, "weather.current", payload) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } // Flatten OpenWeather's /data/2.5/weather response into the card model. var owResp struct { Weather []struct { Description string `json:"description"` Icon string `json:"icon"` } `json:"weather"` Main struct { Temp *float64 `json:"temp"` FeelsLike *float64 `json:"feels_like"` Humidity *int `json:"humidity"` } `json:"main"` Wind struct { Speed *float64 `json:"speed"` Deg *int `json:"deg"` } `json:"wind"` Clouds struct { All *int `json:"all"` } `json:"clouds"` Dt int64 `json:"dt"` Name string `json:"name"` Sys struct { Country string `json:"country"` } `json:"sys"` } if err := json.Unmarshal(raw, &owResp); err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenWeather response"}) return } weather := owWeather{ Location: owResp.Name, Country: owResp.Sys.Country, Temp: owResp.Main.Temp, FeelsLike: owResp.Main.FeelsLike, Humidity: owResp.Main.Humidity, WindSpeed: owResp.Wind.Speed, WindDeg: owResp.Wind.Deg, Clouds: owResp.Clouds.All, Dt: owResp.Dt, } if len(owResp.Weather) > 0 { weather.Description = owResp.Weather[0].Description weather.Icon = owResp.Weather[0].Icon } writeJSON(w, http.StatusOK, map[string]any{"weather": weather, "units": units}) }