This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new ec6cc51  feat(tray): switch an agent's provider from the tray menu
ec6cc51 is described below

commit ec6cc518afb279be853778aad9c3a94207135b3b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 00:52:03 2026 +0800

    feat(tray): switch an agent's provider from the tray menu
---
 controllers/agent.go  |  38 +++++++-----
 controllers/tray.go   | 142 ++++++++++++++++++++++++++++++++++++++++++++
 controllers/util.go   |  15 +++++
 desktop/api.go        | 117 ++++++++++++++++++++++++++++++++++++
 desktop/providers.go  | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++
 desktop/tray.go       |   5 ++
 main.go               |   6 ++
 routers/router.go     |   5 ++
 service/localtoken.go |  87 +++++++++++++++++++++++++++
 9 files changed, 561 insertions(+), 14 deletions(-)

diff --git a/controllers/agent.go b/controllers/agent.go
index cfd9e9c..0b0dc79 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -16,6 +16,7 @@ package controllers
 
 import (
        "encoding/json"
+       "errors"
        "net"
        "path/filepath"
        "sort"
@@ -167,33 +168,42 @@ func (c *ApiController) UpdateAgentRouting() {
                return
        }
 
-       if err := checkAgentProtocol(form.AgentId, form.Mode, 
append([]string{form.Provider}, form.Fallbacks...)); err != nil {
+       if err := saveAgentRouting(form.AgentId, form.Provider, form.Fallbacks, 
form.Mode); err != nil {
                c.ResponseError(err.Error())
                return
        }
+       c.ResponseOk(form.Provider)
+}
 
-       if err := object.SetAgentRouting(form.AgentId, form.Provider, 
form.Fallbacks, form.Mode); err != nil {
-               c.ResponseError(err.Error())
-               return
+// saveAgentRouting stores one agent's routing and writes it into the
+// configuration of every installation Gateway can write. Both the routing form
+// and the tray's one-click switch end here, so an agent reached from either
+// place is left in the same state.
+func saveAgentRouting(agentId string, providerId string, fallbacks []string, 
mode string) error {
+       if err := checkAgentProtocol(agentId, mode, 
append([]string{providerId}, fallbacks...)); err != nil {
+               return err
+       }
+
+       if err := object.SetAgentRouting(agentId, providerId, fallbacks, mode); 
err != nil {
+               return err
        }
 
        // An agent that runs in a sandbox is given this host's own address, so 
the
        // management port has to answer there before that endpoint is written.
        if err := service.SyncLanAccess(); err != nil {
-               c.ResponseError("the routing was saved, but the agent cannot 
reach Gateway from its sandbox: " + err.Error())
-               return
+               return errors.New("the routing was saved, but the agent cannot 
reach Gateway from its sandbox: " + err.Error())
        }
 
-       if form.Provider == "" {
-               if failure := restoreAgentProvider(form.AgentId); failure != "" 
{
-                       c.ResponseError("the routing was cleared, but the agent 
configuration was not restored: " + failure)
-                       return
+       if providerId == "" {
+               if failure := restoreAgentProvider(agentId); failure != "" {
+                       return errors.New("the routing was cleared, but the 
agent configuration was not restored: " + failure)
                }
-       } else if failure := reapplyAgentProvider(form.AgentId); failure != "" {
-               c.ResponseError("the routing was saved, but the agent 
configuration was not rewritten: " + failure)
-               return
+               return nil
        }
-       c.ResponseOk(form.Provider)
+       if failure := reapplyAgentProvider(agentId); failure != "" {
+               return errors.New("the routing was saved, but the agent 
configuration was not rewritten: " + failure)
+       }
+       return nil
 }
 
 // PatchAgent enables monitoring for one discovered installation.
diff --git a/controllers/tray.go b/controllers/tray.go
new file mode 100644
index 0000000..b2925c6
--- /dev/null
+++ b/controllers/tray.go
@@ -0,0 +1,142 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "encoding/json"
+       "sort"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/object"
+)
+
+// trayAgent is one agent the tray offers a provider menu for. Installations 
are
+// discovered per path while the routing is stored per agent id, so the menu is
+// one entry per id however many copies are installed.
+type trayAgent struct {
+       AgentId  string `json:"agentId"`
+       Name     string `json:"name"`
+       Provider string `json:"provider"`
+}
+
+// trayProvider is one provider a tray menu entry can be switched to.
+type trayProvider struct {
+       Id       string `json:"id"`
+       Name     string `json:"name"`
+       Disabled bool   `json:"disabled"`
+}
+
+// GetTrayMenu is everything the desktop tray draws: the agents installed here,
+// the provider each is bound to, and the providers they can be switched to. It
+// is one call because the menu is rebuilt as a whole.
+func (c *ApiController) GetTrayMenu() {
+       if c.RequireLocalAdmin() {
+               return
+       }
+
+       installations, err := agent.Scan(false)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       routings, err := object.GetAgents()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       agents := []*trayAgent{}
+       seen := map[string]bool{}
+       for _, installation := range installations {
+               if seen[installation.AgentId] {
+                       continue
+               }
+               seen[installation.AgentId] = true
+
+               item := &trayAgent{
+                       AgentId: installation.AgentId,
+                       Name:    installation.Name,
+               }
+               if stored, ok := routings[installation.AgentId]; ok {
+                       item.Provider = stored.Provider
+               }
+               agents = append(agents, item)
+       }
+       sort.Slice(agents, func(i, j int) bool { return agents[i].Name < 
agents[j].Name })
+
+       stored, err := object.GetProviders("")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       providers := []*trayProvider{}
+       for _, provider := range stored {
+               name := provider.DisplayName
+               if name == "" {
+                       name = provider.Name
+               }
+               providers = append(providers, &trayProvider{
+                       Id:       provider.GetId(),
+                       Name:     name,
+                       Disabled: provider.Status == "disabled",
+               })
+       }
+       sort.Slice(providers, func(i, j int) bool { return providers[i].Name < 
providers[j].Name })
+
+       c.ResponseOk(map[string]interface{}{"agents": agents, "providers": 
providers})
+}
+
+// SetAgentProvider binds one agent to one provider, leaving the rest of its
+// routing alone. It is the tray's one click: the fallbacks and the mode chosen
+// on the pages survive a switch made from the menu.
+func (c *ApiController) SetAgentProvider() {
+       if c.RequireLocalAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId  string `json:"agentId"`
+               Provider string `json:"provider"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if !agent.IsKnownAgentId(form.AgentId) {
+               c.ResponseError("unknown agent: " + form.AgentId)
+               return
+       }
+
+       fallbacks := []string{}
+       mode := object.ModeGateway
+       routing, err := object.GetAgent(form.AgentId)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if routing != nil {
+               fallbacks = routing.Fallbacks
+               if routing.Mode != "" {
+                       mode = routing.Mode
+               }
+       }
+
+       if err := saveAgentRouting(form.AgentId, form.Provider, fallbacks, 
mode); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(form.Provider)
+}
diff --git a/controllers/util.go b/controllers/util.go
index 8971eea..2c2b930 100644
--- a/controllers/util.go
+++ b/controllers/util.go
@@ -14,6 +14,10 @@
 
 package controllers
 
+import (
+       "github.com/apache/casbin-gateway/service"
+)
+
 type Response struct {
        Status string      `json:"status"`
        Msg    string      `json:"msg"`
@@ -65,3 +69,14 @@ func (c *ApiController) RequireAdmin() bool {
 
        return false
 }
+
+// RequireLocalAdmin admits the signed-in admin, and a program on this machine
+// that holds the local token. The tray is the second one: it offers the same
+// switch the pages do, from a process that never signed in.
+func (c *ApiController) RequireLocalAdmin() bool {
+       if service.IsLocalRequest(c.Ctx.Request) {
+               return false
+       }
+
+       return c.RequireAdmin()
+}
diff --git a/desktop/api.go b/desktop/api.go
new file mode 100644
index 0000000..d1a20b9
--- /dev/null
+++ b/desktop/api.go
@@ -0,0 +1,117 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/http"
+       "os"
+       "path/filepath"
+       "strings"
+       "time"
+)
+
+// localTokenPath mirrors service.LocalTokenPath, resolved against the same
+// directory the server runs in. It is what the tray sends instead of a 
session:
+// the server writes it where only this account can read it.
+const localTokenPath = "tmp/local-token"
+
+const localTokenHeader = "X-Casbin-Gateway-Local-Token"
+
+const apiTimeout = 10 * time.Second
+
+// trayMenu is what the server says the provider submenus should contain.
+type trayMenu struct {
+       Agents []struct {
+               AgentId  string `json:"agentId"`
+               Name     string `json:"name"`
+               Provider string `json:"provider"`
+       } `json:"agents"`
+       Providers []struct {
+               Id       string `json:"id"`
+               Name     string `json:"name"`
+               Disabled bool   `json:"disabled"`
+       } `json:"providers"`
+}
+
+// serverUrl is the server's own address. It is not gatewayUrl(): that one may
+// be pointed at the frontend dev server, which has no API of its own.
+func serverUrl() string {
+       return fmt.Sprintf("http://127.0.0.1:%d";, httpPort())
+}
+
+func fetchTrayMenu() (*trayMenu, error) {
+       menu := &trayMenu{}
+       if err := callApi(http.MethodGet, "/api/get-tray-menu", nil, menu); err 
!= nil {
+               return nil, err
+       }
+       return menu, nil
+}
+
+func setAgentProvider(agentId string, providerId string) error {
+       body, err := json.Marshal(map[string]string{"agentId": agentId, 
"provider": providerId})
+       if err != nil {
+               return err
+       }
+       return callApi(http.MethodPost, "/api/set-agent-provider", body, nil)
+}
+
+// callApi unwraps the {status, msg, data} envelope every Gateway API answers
+// with, so a rejected call is an error here rather than an empty menu.
+func callApi(method string, path string, body []byte, out interface{}) error {
+       token, err := os.ReadFile(filepath.Join(gatewayHome(), 
filepath.FromSlash(localTokenPath)))
+       if err != nil {
+               return fmt.Errorf("the Gateway server has not left a local 
token: %v", err)
+       }
+
+       var payload *bytes.Reader
+       if body == nil {
+               payload = bytes.NewReader(nil)
+       } else {
+               payload = bytes.NewReader(body)
+       }
+       request, err := http.NewRequest(method, serverUrl()+path, payload)
+       if err != nil {
+               return err
+       }
+       request.Header.Set(localTokenHeader, strings.TrimSpace(string(token)))
+       request.Header.Set("Content-Type", "application/json")
+
+       client := &http.Client{Timeout: apiTimeout}
+       response, err := client.Do(request)
+       if err != nil {
+               return err
+       }
+       defer response.Body.Close()
+
+       envelope := struct {
+               Status string          `json:"status"`
+               Msg    string          `json:"msg"`
+               Data   json.RawMessage `json:"data"`
+       }{}
+       if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
+               return fmt.Errorf("%s answered %s: %v", path, response.Status, 
err)
+       }
+       if envelope.Status != "ok" {
+               return errors.New(envelope.Msg)
+       }
+       if out == nil {
+               return nil
+       }
+       return json.Unmarshal(envelope.Data, out)
+}
diff --git a/desktop/providers.go b/desktop/providers.go
new file mode 100644
index 0000000..e46e209
--- /dev/null
+++ b/desktop/providers.go
@@ -0,0 +1,160 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+       "fmt"
+       "os"
+       "strings"
+       "time"
+
+       "fyne.io/systray"
+)
+
+// menuInterval is how often the provider submenus are compared against the
+// server. A switch made on the pages reaches the menu within one.
+const menuInterval = 10 * time.Second
+
+// builtinTitle is the entry that unbinds an agent, which puts back the 
provider
+// settings it had before Gateway first switched it.
+const builtinTitle = "Built-in model"
+
+// providerMenu is the tray's provider switcher: one submenu per agent 
installed
+// here, each listing the providers that agent can be pointed at. The submenus
+// are redrawn only when what they should show changes, since redrawing one
+// closes it under the pointer.
+type providerMenu struct {
+       root  *systray.MenuItem
+       drawn string
+       // agents holds only the submenu of each agent: removing one removes the
+       // provider items under it, and removing those again is what Windows
+       // answers with "invalid menu handle".
+       agents []*systray.MenuItem
+       // redrawNow carries a switch this menu just made, so the check mark 
moves
+       // without waiting out the interval.
+       redrawNow chan struct{}
+}
+
+func newProviderMenu(root *systray.MenuItem) *providerMenu {
+       return &providerMenu{root: root, redrawNow: make(chan struct{}, 1)}
+}
+
+func (menu *providerMenu) watch() {
+       for {
+               menu.draw()
+               select {
+               case <-menu.redrawNow:
+               case <-time.After(menuInterval):
+               }
+       }
+}
+
+func (menu *providerMenu) draw() {
+       fetched, err := fetchTrayMenu()
+       if err != nil {
+               menu.clear("unavailable")
+               return
+       }
+       if len(fetched.Agents) == 0 {
+               menu.clear("no agent is installed")
+               return
+       }
+       if len(fetched.Providers) == 0 {
+               menu.clear("no provider is configured")
+               return
+       }
+
+       fingerprint := fingerprintOf(fetched)
+       if fingerprint == menu.drawn {
+               return
+       }
+       menu.remove()
+
+       menu.root.SetTitle("Switch Provider")
+       menu.root.Enable()
+       for _, entry := range fetched.Agents {
+               submenu := menu.root.AddSubMenuItem(entry.Name, "Choose where 
"+entry.Name+" sends its requests")
+               menu.agents = append(menu.agents, submenu)
+
+               menu.add(submenu, entry.AgentId, "", builtinTitle, "Unbind 
"+entry.Name+" and put its own settings back", entry.Provider == "")
+               for _, provider := range fetched.Providers {
+                       title := provider.Name
+                       if provider.Disabled {
+                               title += " (disabled)"
+                       }
+                       menu.add(submenu, entry.AgentId, provider.Id, title, 
"Send "+entry.Name+" to "+provider.Id, provider.Id == entry.Provider)
+               }
+       }
+       menu.drawn = fingerprint
+}
+
+func (menu *providerMenu) add(submenu *systray.MenuItem, agentId string, 
providerId string, title string, tooltip string, checked bool) {
+       item := submenu.AddSubMenuItemCheckbox(title, tooltip, checked)
+
+       // Remove() closes the channel, so the goroutine of an item the next 
redraw
+       // drops ends with it.
+       go func() {
+               for range item.ClickedCh {
+                       if err := setAgentProvider(agentId, providerId); err != 
nil {
+                               fmt.Fprintln(os.Stderr, 
"casbin-gateway-desktop:", err)
+                       }
+                       // Either way the menu is redrawn from what the server 
now says, so
+                       // a switch that failed leaves the check mark where it 
was.
+                       menu.requestRedraw()
+               }
+       }()
+}
+
+// clear leaves the switcher greyed out, saying why there is nothing under it.
+// The reason is what the menu is drawn from while it is empty, so a Gateway
+// that stays down is not redrawn every poll.
+func (menu *providerMenu) clear(reason string) {
+       title := "Switch Provider (" + reason + ")"
+       if menu.drawn == title {
+               return
+       }
+
+       menu.remove()
+       menu.root.SetTitle(title)
+       menu.root.Disable()
+       menu.drawn = title
+}
+
+func (menu *providerMenu) remove() {
+       for _, item := range menu.agents {
+               item.Remove()
+       }
+       menu.agents = nil
+}
+
+func (menu *providerMenu) requestRedraw() {
+       select {
+       case menu.redrawNow <- struct{}{}:
+       default:
+       }
+}
+
+// fingerprintOf is what the submenus are drawn from, so an unchanged menu is
+// left standing.
+func fingerprintOf(fetched *trayMenu) string {
+       parts := []string{}
+       for _, entry := range fetched.Agents {
+               parts = append(parts, 
entry.AgentId+"\x00"+entry.Name+"\x00"+entry.Provider)
+       }
+       for _, provider := range fetched.Providers {
+               parts = append(parts, 
provider.Id+"\x00"+provider.Name+"\x00"+fmt.Sprint(provider.Disabled))
+       }
+       return strings.Join(parts, "\x01")
+}
diff --git a/desktop/tray.go b/desktop/tray.go
index 1133cf8..7d16d74 100644
--- a/desktop/tray.go
+++ b/desktop/tray.go
@@ -62,6 +62,10 @@ func onTrayReady() {
        mOpen := systray.AddMenuItem("Open Casbin Gateway", "Show the 
management window")
        mBrowser := systray.AddMenuItem("Open in Browser", "Show the management 
UI in the default browser")
        systray.AddSeparator()
+       mProviders := systray.AddMenuItem("Switch Provider (unavailable)", 
"Point an agent at another provider")
+       mProviders.Disable()
+       providers := newProviderMenu(mProviders)
+       systray.AddSeparator()
        mStatus := systray.AddMenuItem("Starting...", "")
        mStatus.Disable()
        systray.AddSeparator()
@@ -86,6 +90,7 @@ func onTrayReady() {
                ownsServer.Unlock()
 
                go watchStatus(mStatus, port)
+               go providers.watch()
                showWindow()
        }()
 
diff --git a/main.go b/main.go
index 26bd251..32aee7c 100644
--- a/main.go
+++ b/main.go
@@ -74,6 +74,12 @@ func main() {
        // this is the only process that can now give it back.
        agentlink.Restore()
 
+       // The tray runs as a process of its own, so the credential it presents 
in
+       // place of a session has to be on disk before it asks for anything.
+       if err := service.IssueLocalToken(); err != nil {
+               beego.Error("the desktop tray cannot be given a local token:", 
err)
+       }
+
        object.InitFlag()
        object.InitAdapter()
        object.CreateTables()
diff --git a/routers/router.go b/routers/router.go
index 651aecd..f2b0ee8 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -81,6 +81,11 @@ func initAPI() {
        beego.Router("/api/apply-agent-provider", &controllers.ApiController{}, 
"POST:ApplyAgentProvider")
        beego.Router("/api/restore-agent-provider", 
&controllers.ApiController{}, "POST:RestoreAgentProvider")
        beego.Router("/api/copy-agent-config", &controllers.ApiController{}, 
"POST:CopyAgentConfig")
+
+       // The desktop tray, which holds the local token instead of a session.
+       beego.Router("/api/get-tray-menu", &controllers.ApiController{}, 
"GET:GetTrayMenu")
+       beego.Router("/api/set-agent-provider", &controllers.ApiController{}, 
"POST:SetAgentProvider")
+
        beego.Router("/api/get-skill-sources", &controllers.ApiController{}, 
"GET:GetSkillSources")
        beego.Router("/api/add-skill-source", &controllers.ApiController{}, 
"POST:AddSkillSource")
        beego.Router("/api/upload-skill-source", &controllers.ApiController{}, 
"POST:UploadSkillSource")
diff --git a/service/localtoken.go b/service/localtoken.go
new file mode 100644
index 0000000..3723c8c
--- /dev/null
+++ b/service/localtoken.go
@@ -0,0 +1,87 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package service
+
+import (
+       "crypto/rand"
+       "crypto/subtle"
+       "encoding/base64"
+       "net/http"
+       "os"
+       "path/filepath"
+       "sync"
+
+       "github.com/apache/casbin-gateway/util"
+)
+
+// LocalTokenHeader carries the credential a program on this machine presents 
in
+// place of a session. The tray is one: it draws its menu from the same API the
+// pages use, but it is not a browser and has no session to send.
+const LocalTokenHeader = "X-Casbin-Gateway-Local-Token"
+
+// LocalTokenPath is relative to the working directory, which is where Gateway
+// keeps the rest of its state, so a tray started from the same directory finds
+// it without being told.
+const LocalTokenPath = "./tmp/local-token"
+
+var localToken struct {
+       sync.RWMutex
+       value string
+}
+
+// IssueLocalToken writes a fresh token where only this account can read it. It
+// is new on every start, so one left behind by a Gateway that is gone grants
+// nothing.
+func IssueLocalToken() error {
+       buffer := make([]byte, 32)
+       if _, err := rand.Read(buffer); err != nil {
+               return err
+       }
+       token := base64.RawURLEncoding.EncodeToString(buffer)
+
+       if err := os.MkdirAll(filepath.Dir(LocalTokenPath), 0o700); err != nil {
+               return err
+       }
+       if err := os.WriteFile(LocalTokenPath, []byte(token), 0o600); err != 
nil {
+               return err
+       }
+       // WriteFile only applies the mode to a file it creates, and this one
+       // outlives the run that created it.
+       if err := os.Chmod(LocalTokenPath, 0o600); err != nil {
+               return err
+       }
+
+       localToken.Lock()
+       localToken.value = token
+       localToken.Unlock()
+       return nil
+}
+
+// IsLocalRequest reports whether a request came from a program on this machine
+// holding that token. Loopback alone would not do: a page in the operator's
+// browser reaches loopback too, and cannot read the file.
+func IsLocalRequest(r *http.Request) bool {
+       if r == nil || !util.IsLoopbackRequest(r) {
+               return false
+       }
+
+       localToken.RLock()
+       expected := localToken.value
+       localToken.RUnlock()
+       if expected == "" {
+               return false
+       }
+       return 
subtle.ConstantTimeCompare([]byte(r.Header.Get(LocalTokenHeader)), 
[]byte(expected)) == 1
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to