The add and remove buttons, and the read that checks them

Anker documents neither rfid write, so the bodies are inferred from the
field names get_device_cards answers with, and every write re-reads the
list: what the card shows is what the account holds, never what an
undocumented endpoint claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 10:50:00 +02:00
co-authored by Claude Opus 5
parent a5842201c0
commit 245870a96a
10 changed files with 588 additions and 16 deletions
@@ -584,3 +584,100 @@ func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Reques
}
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// The two card writes. They are the only calls in the Anker connector that
// change anything on the account, so both are gated exactly like the reads,
// rate-limited beside the control commands — a card is who may start a charge,
// which is the same actuator asked a slower question — and audited by serial and
// card, with the number kept out of the log line: it is the credential itself.
// Both answer with the card list as it stands after the write, so the caller
// sees what the account holds rather than what an undocumented endpoint claimed.
// ankerCardBody is what a card write is asked for.
type ankerCardBody struct {
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
}
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards — add a card to
// the charger, or rename one already on it.
func (s *Server) handleAnkerCardSave(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
var body ankerCardBody
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
if strings.TrimSpace(body.CardNumber) == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-save", sn, map[string]any{
"sn": sn, "cardNumber": body.CardNumber, "cardName": body.CardName,
})
}
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} —
// remove one card, named in full.
func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
number := strings.TrimSpace(r.PathValue("number"))
if number == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-delete", sn, map[string]any{
"sn": sn, "cardNumber": number,
})
}
// ankerCardGate is everything both writes need before they may run: a caller, a
// serial, an integration that is on and has credentials, and a rate limit. A
// gate that is off answers 409 rather than the reads' 200-with-a-reason: a write
// that did not happen is not a state to render, it is a request that failed.
func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, string, map[string]string, bool) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, "", nil, false
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "charger serial required")
return nil, "", nil, false
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
if reason := ankerGate(res, true); reason != "" {
writeError(w, http.StatusConflict, reason)
return nil, "", nil, false
}
if !s.ctlRL.allow(who.ID + "|" + sn) {
writeError(w, http.StatusTooManyRequests, "too many card changes; please slow down")
return nil, "", nil, false
}
return who, sn, map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}, true
}
// ankerCardWrite runs one card capability and relays its document. The audit
// line names the charger and how the write went, never the card number.
func (s *Server) ankerCardWrite(w http.ResponseWriter, r *http.Request, who *callerIdentity,
cfg map[string]string, action, sn string, params map[string]any) {
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, action, mustJSON(params))
if err != nil {
s.auditControl(who, sn, action, nil, "failed", err)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
s.auditControl(who, sn, action, nil, "ok", nil)
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
+4
View File
@@ -52,6 +52,8 @@
// POST /api/integrations/anker-solix/health
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -441,6 +443,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)