Files
JianFeeeee 882288f67f fix(webui): send DELETE when removing keys and adapters
Deleting a gateway key from the admin UI did nothing and reported
"use GET /api/keys": delKey() called api() with an empty options object, so
fetch defaulted to GET and the request landed in the GET branch of
handleKeysAPI. delAdapter() had the identical bug and reported
"adapter code not exposed; edit in UI".

This is the third instance of the same mistake — ab20f1b fixed delSource and
delTemplate, missing these two — so it is now pinned by tests instead of by
review:

  * TestUIAPICallsDeclareMethod walks every api() call in the embedded
    index.html and fails if one passes an options object without a method
    (an AbortSignal-only read is allowed, being a deliberate GET).
  * TestUIDeleteHelpersUseDelete / TestUIMutatingHelpersUseWriteMethods pin the
    verb of each removal and write helper by name.
  * TestKeyDeleteRoundTrip covers create -> DELETE -> gone -> second DELETE is a
    clean 404, and TestCannotDeleteOwnKey keeps the lockout guard.

The 404 bodies for GET /api/keys/{key} and GET /api/adapters/{name} now name the
verb to use ("DELETE /api/keys/{key} to remove"), because that message is what a
mis-methoded client actually shows its user; "use GET /api/keys" read as though
the caller had done nothing wrong.

delSource's indentation, broken by ab20f1b, is also straightened out.
2026-08-30 09:07:05 +08:00

191 lines
5.9 KiB
Go

package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"llmsproxy/internal/config"
)
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
// GET /api/keys/me (own key for any role), POST /api/keys (admin: create),
// PUT /api/keys/{key} (admin: update), DELETE /api/keys/{key} (admin: remove).
func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/keys")
path = strings.Trim(path, "/")
role := reqRole(r.Context())
if path == "me" {
g.handleKeyMe(w, r)
return
}
if role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
if path != "" {
// A GET on a specific key is almost always a client that meant to
// DELETE or PUT it but let fetch default to GET. Name the verbs
// instead of only pointing back at the collection endpoint.
writeError(w, http.StatusNotFound, "not_found",
"no such endpoint; use GET /api/keys to list, DELETE /api/keys/{key} to remove, PUT /api/keys/{key} to update")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": g.core.ListKeys()})
case http.MethodPost:
var body struct {
Name string `json:"name"`
Role string `json:"role"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if body.Role == "" {
body.Role = "user"
}
if body.Role != "admin" && body.Role != "user" {
writeError(w, http.StatusBadRequest, "invalid_request", "role must be admin or user")
return
}
rec, err := g.core.CreateKey(body.Name, body.Role, body.Models, body.Note)
if err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "key": rec})
case http.MethodPut, http.MethodPatch:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "key required")
return
}
var body struct {
Name string `json:"name"`
Role string `json:"role"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
rec, err := g.core.UpdateKey(path, body.Name, body.Role, body.Models, body.Note)
if err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "key": rec})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "key required")
return
}
if path == reqKey(r.Context()) {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot delete the key you are logged in with")
return
}
ok, err := g.core.DeleteKey(path)
if err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "not_found", "key not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// handleKeyMe returns the authenticated key's own record (users see only
// themselves; admins can use this as a convenience too).
func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
rec, ok := g.core.FindKey(reqKey(r.Context()))
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
return
}
if !rec.Seed {
for _, s := range g.core.GatewayKeys() {
if s == rec.Key {
rec.Seed = true
break
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec})
}
// allowedModels returns the model scope for the request's key; nil means
// unrestricted (admin keys and user keys without an explicit scope).
func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
if reqRole(ctx) == "admin" {
return nil
}
rec, ok := g.core.FindKey(reqKey(ctx))
if !ok {
return nil
}
return rec.Models
}
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
// current rules; PUT /api/auto replaces them (admin only).
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, map[string]interface{}{
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
"states": g.core.AutoSlotStates(),
})
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodPut, http.MethodPost:
var body struct {
Rules []config.ModelScope `json:"rules"`
ImageRules []config.ModelScope `json:"image_rules"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if body.Rules != nil {
if err := g.core.SaveAutoRules(body.Rules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
if body.ImageRules != nil {
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}