+Car settings
+{{if .Done}}
+{{.Message}}
+{{else}}
+Fill in what you want to change; empty fields leave the car's value as it is. The car picks the values up within a few seconds.
+
+{{end}}
+`))
+
+func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) {
+ h := w.Header()
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ if err := settingsTemplate.Execute(w, data); err != nil {
+ slog.Warn("settings page not rendered", "error", err)
+ }
+}
+
+func (a *API) settingsPage(w http.ResponseWriter, r *http.Request) {
+ if !a.handoffEnabled() {
+ http.NotFound(w, r)
+ return
+ }
+ data := settingsPageData{}
+ if raw := r.URL.Query().Get("code"); raw != "" {
+ if a.Handoff.Open(raw) {
+ data.Code = normalizeUserCode(raw)
+ } else {
+ data.Error = "This code is unknown or has expired. Start again on the car."
+ }
+ }
+ renderSettingsPage(w, http.StatusOK, data)
+}
+
+// phoneSettingsFromForm checks what the phone sent. Credentials are never put back into the
+// page; the numbers are, so a typo is fixed without typing them again.
+func phoneSettingsFromForm(form url.Values) (PhoneSettings, error) {
+ var s PhoneSettings
+ for _, f := range []struct {
+ name string
+ dst *string
+ }{{"abrpApiKey", &s.AbrpAPIKey}, {"abrpToken", &s.AbrpToken}} {
+ v := strings.TrimSpace(form.Get(f.name))
+ if v == "" {
+ continue
+ }
+ if len(v) < 8 || len(v) > 200 || strings.ContainsAny(v, " \t\r\n") {
+ return PhoneSettings{}, errors.New("the ABRP API key or user token does not look right")
+ }
+ *f.dst = v
+ }
+ for _, f := range []struct {
+ name string
+ min, max float64
+ dst **float64
+ label string
+ }{{"price", 0, 100, &s.PricePerKwh, "charging price"}, {"usable", 10, 150, &s.UsableKwh, "usable capacity"}} {
+ v := strings.ReplaceAll(strings.TrimSpace(form.Get(f.name)), ",", ".")
+ if v == "" {
+ continue
+ }
+ n, err := strconv.ParseFloat(v, 64)
+ if err != nil || n < f.min || n > f.max {
+ return PhoneSettings{}, errors.New("the " + f.label + " is not a plausible number")
+ }
+ *f.dst = &n
+ }
+ if s.empty() {
+ return PhoneSettings{}, errors.New("nothing filled in")
+ }
+ return s, nil
+}
+
+func (a *API) settingsSubmit(w http.ResponseWriter, r *http.Request) {
+ if !a.handoffEnabled() {
+ http.NotFound(w, r)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ renderSettingsPage(w, http.StatusBadRequest, settingsPageData{Error: "The form could not be read."})
+ return
+ }
+ code := normalizeUserCode(r.PostForm.Get("code"))
+ back := settingsPageData{Code: code, Price: r.PostForm.Get("price"), Usable: r.PostForm.Get("usable")}
+ switch err := a.Pairing.CheckAdmin(r.PostForm.Get("password"), clientAddress(r)); {
+ case errors.Is(err, errTooManyFailed):
+ back.Error = err.Error() + "."
+ renderSettingsPage(w, http.StatusTooManyRequests, back)
+ return
+ case err != nil:
+ back.Error = "Wrong password."
+ renderSettingsPage(w, http.StatusUnauthorized, back)
+ return
+ }
+ settings, err := phoneSettingsFromForm(r.PostForm)
+ if err != nil {
+ back.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
+ renderSettingsPage(w, http.StatusBadRequest, back)
+ return
+ }
+ switch err := a.Handoff.Fill(code, settings); {
+ case errors.Is(err, errAlreadyFilled):
+ renderSettingsPage(w, http.StatusConflict, settingsPageData{Error: "These settings were already sent. Start again on the car to send more."})
+ return
+ case err != nil:
+ renderSettingsPage(w, http.StatusNotFound, settingsPageData{Error: "This code is unknown or has expired. Start again on the car."})
+ return
+ }
+ slog.Info("settings sent from a phone")
+ renderSettingsPage(w, http.StatusOK, settingsPageData{Done: true,
+ Message: "Sent. The car picks the settings up within a few seconds — you can close this page."})
+}
diff --git a/server/handoff_test.go b/server/handoff_test.go
new file mode 100644
index 0000000..cb94947
--- /dev/null
+++ b/server/handoff_test.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func startHandoff(t *testing.T, h http.Handler, token string) (deviceCode, userCode string) {
+ t.Helper()
+ rec := do(t, h, "POST", "/v1/settings/start", token, `{"installationId":"`+testInstallation+`"}`)
+ if rec.Code != 201 {
+ t.Fatalf("start: %d %s", rec.Code, rec.Body)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got["verificationUriComplete"] != "https://car.example.com/settings?code="+got["userCode"].(string) {
+ t.Fatalf("uri %v", got["verificationUriComplete"])
+ }
+ return got["deviceCode"].(string), got["userCode"].(string)
+}
+
+func sendSettings(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestSettingsTravelFromThePhoneToTheCarOnce(t *testing.T) {
+ api, _ := newTestAPI()
+ h := api.Routes()
+ if rec := do(t, h, "POST", "/v1/settings/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 401 {
+ t.Fatalf("start without a token: %d", rec.Code)
+ }
+ deviceCode, userCode := startHandoff(t, h, testToken)
+ poll := func() *httptest.ResponseRecorder {
+ return do(t, h, "POST", "/v1/settings/poll", testToken, `{"deviceCode":"`+deviceCode+`"}`)
+ }
+ if rec := poll(); rec.Code != 202 {
+ t.Fatalf("before the phone: %d", rec.Code)
+ }
+ page := do(t, h, "GET", "/settings?code="+userCode, "", "")
+ if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) {
+ t.Fatalf("page %d", page.Code)
+ }
+
+ form := url.Values{"code": {userCode}, "abrpApiKey": {"api-key-1234"}, "abrpToken": {"user-token-5678"},
+ "price": {"0,39"}, "password": {"wrong"}}
+ if rec := sendSettings(t, h, form); rec.Code != 401 || strings.Contains(rec.Body.String(), "user-token-5678") {
+ t.Fatalf("wrong password: %d (credentials must not be echoed)", rec.Code)
+ }
+ form.Set("password", testApprovalPassword)
+ if rec := sendSettings(t, h, form); rec.Code != 200 {
+ t.Fatalf("send: %d %s", rec.Code, rec.Body)
+ }
+ if rec := sendSettings(t, h, form); rec.Code != 409 {
+ t.Fatalf("sent twice: %d", rec.Code)
+ }
+
+ rec := poll()
+ var got PhoneSettings
+ if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &got) != nil {
+ t.Fatalf("collect: %d %s", rec.Code, rec.Body)
+ }
+ if got.AbrpAPIKey != "api-key-1234" || got.AbrpToken != "user-token-5678" || got.PricePerKwh == nil ||
+ *got.PricePerKwh != 0.39 || got.UsableKwh != nil {
+ t.Fatalf("settings %+v", got)
+ }
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("credentials cacheable")
+ }
+ if rec := poll(); rec.Code != 410 {
+ t.Fatalf("collected twice: %d", rec.Code)
+ }
+}
+
+func TestSettingsAreOnlyForTheCarThatAsked(t *testing.T) {
+ api, _ := newTestAPI()
+ h := api.Routes()
+ deviceCode, userCode := startHandoff(t, h, testToken)
+ _ = api.Handoff.Fill(userCode, PhoneSettings{AbrpToken: "user-token-5678"})
+ // A car paired as another installation cannot open a request for this one, nor collect it.
+ other := caller{installationID: "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c"}
+ if s, err := api.Handoff.Poll(deviceCode, other.mayUse); s != nil || err == nil {
+ t.Fatal("another car collected the settings")
+ }
+ if s, err := api.Handoff.Poll(deviceCode, caller{}.mayUse); s == nil || err != nil {
+ t.Fatalf("the car itself could not collect: %v", err)
+ }
+}
+
+func TestPhoneSettingsAreChecked(t *testing.T) {
+ for name, form := range map[string]url.Values{
+ "nothing": {},
+ "short token": {"abrpToken": {"abc"}},
+ "spaced key": {"abrpApiKey": {"has a space here"}},
+ "price absurd": {"price": {"250"}},
+ "usable too low": {"usable": {"3"}},
+ "not a number": {"price": {"cheap"}},
+ } {
+ if _, err := phoneSettingsFromForm(form); err == nil {
+ t.Errorf("%s accepted", name)
+ }
+ }
+ s, err := phoneSettingsFromForm(url.Values{"usable": {" 50,8 "}})
+ if err != nil || s.UsableKwh == nil || *s.UsableKwh != 50.8 || s.AbrpToken != "" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
+
+func TestHandoffRequestsExpireAndAreReplaced(t *testing.T) {
+ h := NewHandoff()
+ first, _ := h.Start(testInstallation)
+ second, _ := h.Start(testInstallation)
+ if _, err := h.Poll(first.deviceCode, caller{}.mayUse); err == nil {
+ t.Fatal("the replaced request is still open")
+ }
+ h.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) }
+ if err := h.Fill(second.userCode, PhoneSettings{AbrpToken: "user-token-5678"}); err == nil {
+ t.Fatal("an expired request took settings")
+ }
+}
+
+func TestPhoneSettingsNeedPairingSwitchedOn(t *testing.T) {
+ api, _ := newTestAPI()
+ api.Pairing = NewPairing("", nil)
+ h := api.Routes()
+ if rec := do(t, h, "POST", "/v1/settings/start", testToken, `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 {
+ t.Fatalf("start: %d", rec.Code)
+ }
+ if rec := do(t, h, "GET", "/settings", "", ""); rec.Code != 404 {
+ t.Fatalf("page: %d", rec.Code)
+ }
+}
diff --git a/server/main.go b/server/main.go
index 1d99853..be65222 100644
--- a/server/main.go
+++ b/server/main.go
@@ -87,7 +87,8 @@ func main() {
os.Exit(1)
}
pairing := NewPairing(cfg.approvalPass, devices)
- api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL}
+ api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
+ PublicURL: cfg.publicURL}
srv := &http.Server{
Addr: cfg.listenAddr,
diff --git a/server/pairing_http.go b/server/pairing_http.go
index 2456969..bece390 100644
--- a/server/pairing_http.go
+++ b/server/pairing_http.go
@@ -147,10 +147,8 @@ type pageData struct {
Done bool
}
-var pairTemplate = template.Must(template.New("pair").Parse(`
-