Files
mg4/server/handoff_http.go
T
nilsandClaude Opus 5.5 bae56bec2f Server 2.6.1: code field upper-cases and inserts the dash
The phone pages' code field gets one inline script, allowed by its
SHA-256 hash in the CSP; a test checks the served script against the
header. Image pinned by digest in k8s/server.yaml (not applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 08:19:00 +02:00

241 lines
8.4 KiB
Go

package main
import (
"errors"
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// HTTP side of entering settings on the phone (handoff.go).
//
// POST /v1/settings/start car, with its token: open a request {installationId} → codes and URL
// POST /v1/settings/poll car, with its token: collect {deviceCode} → 202 / 200 settings / 410
// GET /settings?code=… phone: the form
// POST /settings phone: send it (needs the approval password)
//
// Switched on together with pairing, by APPROVAL_PASSWORD: the phone page needs it.
func (a *API) handoffRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/settings/start", a.ingest(a.settingsStart))
mux.HandleFunc("POST /v1/settings/poll", a.ingest(a.settingsPoll))
mux.HandleFunc("GET /settings", a.settingsPage)
mux.HandleFunc("POST /settings", a.settingsSubmit)
}
func (a *API) handoffEnabled() bool { return a.Handoff != nil && a.Pairing.Enabled() }
var errHandoffOff = errors.New("entering settings on the phone is not enabled")
func (a *API) settingsStart(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
if !a.handoffEnabled() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()})
return nil
}
o, err := decodeObject(body)
if err != nil {
return err
}
id, err := validUUID(o["installationId"], "installationId")
if err != nil {
return err
}
if !c.mayUse(id) {
return errOtherInstallation
}
req, err := a.Handoff.Start(id)
if err != nil {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
return nil
}
base := a.publicURL(r) + "/settings"
writeJSON(w, http.StatusCreated, map[string]any{
"deviceCode": req.deviceCode,
"userCode": req.userCode,
"verificationUri": base,
"verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode),
"expiresIn": int(pairingTTL / time.Second),
"interval": int(pairingInterval / time.Second),
})
return nil
}
func (a *API) settingsPoll(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
if !a.handoffEnabled() {
writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()})
return nil
}
o, err := decodeObject(body)
if err != nil {
return err
}
code, _ := o["deviceCode"].(string)
settings, err := a.Handoff.Poll(code, c.mayUse)
switch {
case err != nil:
writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"})
case settings == nil:
writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"})
default:
// The answer carries credentials: never cached anywhere on the way.
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, settings)
}
return nil
}
// ---------- Phone page ----------
type settingsPageData struct {
Code string
Price string
Usable string
Error string
Done bool
Message string
}
var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Car settings — Nils' MG4</title>
<style>` + pageStyle + `.hint{font-size:13px;color:#8E9597;margin:4px 0 0}</style></head><body><main>
<h1>Car settings</h1>
{{if .Done}}
<p class="ok">{{.Message}}</p>
{{else}}
<p>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.</p>
<form method="post" action="/settings" autocomplete="off">
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
{{else}}<label for="code">Code shown on the car</label>
` + codeInput + `{{end}}
<label for="abrpApiKey">ABRP API key</label>
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
<label for="abrpToken">ABRP user token</label>
<input id="abrpToken" name="abrpToken" type="password" autocomplete="off" spellcheck="false">
<p class="hint">The car needs both to send live data to ABRP.</p>
<label for="price">Charging price (€/kWh)</label>
<input id="price" name="price" inputmode="decimal" value="{{.Price}}">
<label for="usable">Usable battery capacity (kWh)</label>
<input id="usable" name="usable" inputmode="decimal" value="{{.Usable}}">
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
<label for="password">Approval password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="row"><button class="approve">Send to the car</button></div>
</form>
{{end}}
</main></body></html>`))
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", pageCSP)
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."})
}