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
241 lines
8.4 KiB
Go
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."})
|
|
}
|