evcc-io/server/http_site_handler.go
2026-08-24 21:26:45 +02:00

583 lines
15 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"os"
"strconv"
"strings"
"text/template"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/encode"
"github.com/evcc-io/evcc/util/jq"
"github.com/evcc-io/evcc/util/logstash"
"github.com/gorilla/mux"
"github.com/itchyny/gojq"
"golang.org/x/text/language"
)
var ignoreState = []string{"releaseNotes"} // excessive size
// limits for the unauthenticated jq parameter of the state endpoint
const (
maxJqQueryLen = 512 // maximum length of the jq query
maxJqDuration = time.Second // maximum jq evaluation time
maxJqResultBytes = 1 << 20 // maximum size of the encoded jq result
)
// getPreferredLanguage returns the preferred language as two letter code
func getPreferredLanguage(header string) string {
languages, _, err := language.ParseAcceptLanguage(header)
if err != nil || len(languages) == 0 {
return "en"
}
base, _ := languages[0].Base()
return base.String()
}
// globalsJsHandler serves version and ui customization as window.evcc globals
func globalsJsHandler(custom Customization) http.HandlerFunc {
globals := struct {
Version string `json:"version"`
CustomCss bool `json:"customCss"`
CustomLogo bool `json:"customLogo"`
Brand string `json:"customBrand"`
Website string `json:"customWebsite"`
Email string `json:"customEmail"`
Phone string `json:"customPhone"`
Theme string `json:"customTheme"`
}{
Version: util.Version,
CustomCss: custom.Css != "",
CustomLogo: custom.LogoLight != "",
Brand: custom.Brand,
Website: custom.Website,
Email: custom.Email,
Phone: custom.Phone,
Theme: custom.Theme,
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=UTF-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
if _, err := w.Write([]byte("window.evcc = ")); err != nil {
return
}
if err := json.NewEncoder(w).Encode(globals); err != nil {
log.ERROR.Println("httpd: failed to render globals:", err.Error())
}
}
}
func indexHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
indexTemplate, err := fs.ReadFile(assets.Web, "index.html")
if err != nil {
log.FATAL.Print("httpd: failed to load embedded template:", err.Error())
log.FATAL.Print("Make sure templates are included using the `release` build tag or use `make build`")
w.WriteHeader(http.StatusNotFound)
return
}
t, err := template.New("evcc").Delims("[[", "]]").Parse(string(indexTemplate))
if err != nil {
log.FATAL.Fatal("httpd: failed to create main page template:", err.Error())
}
defaultLang := getPreferredLanguage(r.Header.Get("Accept-Language"))
if err := t.Execute(w, map[string]any{
"Version": util.Version,
"DefaultLang": defaultLang,
}); err != nil {
log.ERROR.Println("httpd: failed to render main page:", err.Error())
}
}
}
// jsonHandler is a middleware that decorates responses with JSON and CORS headers
func jsonHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
h.ServeHTTP(w, r)
})
}
func jsonWrite(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// jsonWriteLimited writes data as json, failing if the encoded result exceeds limit bytes.
// Encoding into a buffer keeps oversized results from reaching the client at all.
func jsonWriteLimited(w http.ResponseWriter, data any, limit int) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(data); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
if buf.Len() > limit {
jsonError(w, http.StatusBadRequest, errors.New("result too large"))
return
}
w.Header().Set("Content-Type", "application/json")
buf.WriteTo(w)
}
func jsonError(w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
jsonWrite(w, util.ErrorAsJson(err))
}
func handler[T any](conv func(string) (T, error), set func(T) error, get func() T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
val, err := conv(vars["value"])
if err == nil {
err = set(val)
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonWrite(w, get())
}
}
// ptrHandler updates pointer api
func ptrHandler[T any](conv func(string) (T, error), set func(*T) error, get func() *T) http.HandlerFunc {
return handler(func(s string) (*T, error) {
var val *T
v, err := conv(s)
if err == nil {
val = &v
} else if s == "" {
err = nil
}
return val, err
}, set, get)
}
// floatHandler updates float-param api
func floatHandler(set func(float64) error, get func() float64) http.HandlerFunc {
return handler(parseFloat, set, get)
}
// floatPtrHandler updates float-pointer api
func floatPtrHandler(set func(*float64) error, get func() *float64) http.HandlerFunc {
return ptrHandler(parseFloat, set, get)
}
// intHandler updates int-param api
func intHandler(set func(int) error, get func() int) http.HandlerFunc {
return handler(strconv.Atoi, set, get)
}
// boolHandler updates bool-param api
func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
return handler(strconv.ParseBool, set, get)
}
// stringHandler updates string-param api
func stringHandler(set func(string) error, get func() string) http.HandlerFunc {
return handler(func(s string) (string, error) { return s, nil }, set, get)
}
// durationHandler updates duration-param api
func durationHandler(set func(time.Duration) error, get func() time.Duration) http.HandlerFunc {
return handler(util.ParseDuration, set, get)
}
// getHandler returns api results
func getHandler[T any](get func() T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, get())
}
}
// callHandler invokes an api function without result
func callHandler(fun func()) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fun()
jsonWrite(w, nil)
}
}
// updateSmartCostLimit sets the smart cost limit globally
func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var val *float64
if r.Method != http.MethodDelete {
f, err := parseFloat(vars["value"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
val = &f
}
for _, lp := range site.ActiveLoadpoints() {
setLimit(lp, val)
}
jsonWrite(w, val)
}
}
// updateBatteryMode sets the external battery mode
func updateBatteryMode(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var val api.BatteryMode
if r.Method != http.MethodDelete {
s, err := api.BatteryModeString(vars["value"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
val = s
}
site.SetBatteryModeExternal(val)
jsonWrite(w, site.GetBatteryModeExternal())
}
}
// stateHandler returns the combined state
func stateHandler(cache *util.ParamCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res := cache.State(encode.NewEncoder(encode.WithDuration()))
for _, k := range ignoreState {
delete(res, k)
}
if q := r.URL.Query().Get("jq"); q != "" {
q = strings.TrimPrefix(q, ".result")
if len(q) > maxJqQueryLen {
jsonError(w, http.StatusBadRequest, errors.New("jq: query too long"))
return
}
query, err := gojq.Parse(q)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
b, err := json.Marshal(res)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
// the query is attacker-controlled, so bound evaluation time and result size
ctx, cancel := context.WithTimeout(r.Context(), maxJqDuration)
defer cancel()
res, err := jq.QueryContext(ctx, query, b)
if err != nil {
status := http.StatusBadRequest
if ctx.Err() != nil {
status = http.StatusServiceUnavailable
}
jsonError(w, status, err)
return
}
jsonWriteLimited(w, res, maxJqResultBytes)
return
}
jsonWrite(w, res)
}
}
// tariffHandler returns the configured tariff
func tariffHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
val := vars["tariff"]
tariff, err := api.TariffUsageString(val)
if err != nil {
jsonError(w, http.StatusNotFound, err)
return
}
t := site.GetTariff(tariff)
if t == nil {
jsonError(w, http.StatusNotFound, errors.New("tariff not available"))
return
}
rates, err := t.Rates()
if err != nil {
jsonError(w, http.StatusNotFound, err)
return
}
res := struct {
Rates api.Rates `json:"rates"`
}{
Rates: rates,
}
jsonWrite(w, res)
}
}
// socketHandler attaches websocket handler to uri
func socketHandler(hub *SocketHub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hub.ServeWebsocket(w, r)
}
}
func logAreasHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, logstash.Areas())
}
func clearCacheHandler(w http.ResponseWriter, r *http.Request) {
util.ResetCached()
jsonWrite(w, "OK")
}
func logHandler(w http.ResponseWriter, r *http.Request) {
a := r.URL.Query()["area"]
l := logstash.LogLevelToThreshold(r.URL.Query().Get("level"))
var count int
if v := r.URL.Query().Get("count"); v != "" {
count, _ = strconv.Atoi(v)
}
log := logstash.All(a, l, count)
if r.URL.Query().Get("format") == "txt" {
filename := "evcc-" + time.Now().Format("20060102-150405") + `-` + strings.ToLower(l.String()) + ".log"
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
for _, s := range log {
if _, err := w.Write([]byte(s)); err != nil {
return
}
}
return
}
jsonWrite(w, log)
}
func getBackup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := settings.Persist(); err != nil {
http.Error(w, "Synching DB failed", http.StatusInternalServerError)
return
}
filename := "evcc-backup-" + time.Now().Format("2006-01-02--15-04") + ".db"
tmpFile, err := os.CreateTemp("", "evcc-backup-*.db")
if err != nil {
http.Error(w, "Creating backup failed", http.StatusInternalServerError)
return
}
tmpName := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpName)
if err := db.Backup(r.Context(), tmpName); err != nil {
http.Error(w, "Backup failed", http.StatusInternalServerError)
return
}
f, err := os.Open(tmpName)
if err != nil {
http.Error(w, "Opening backup failed", http.StatusInternalServerError)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
if _, err := io.Copy(w, f); err != nil {
http.Error(w, "Streaming backup failed", http.StatusInternalServerError)
return
}
}
}
// createLocalDatabaseBackup creates a local backup in case of catastrophic error in reset or restore
func createLocalDatabaseBackup(ctx context.Context) error {
return db.Backup(ctx, db.FilePath()+".bak")
}
func restoreDatabase(shutdown func()) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// cap upload size to bound disk usage
r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
return
}
var tmpName string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, "Upload failed", http.StatusBadRequest)
return
}
switch part.FormName() {
case "file":
tmpFile, err := os.CreateTemp("", "evcc-restore-*.db")
if err != nil {
part.Close()
http.Error(w, "Failed to create temp file", http.StatusInternalServerError)
return
}
tmpName = tmpFile.Name()
defer os.Remove(tmpName)
_, copyErr := io.Copy(tmpFile, part)
closeErr := tmpFile.Close()
part.Close()
if copyErr != nil || closeErr != nil {
http.Error(w, "Upload failed", http.StatusBadRequest)
return
}
default:
part.Close()
}
}
if tmpName == "" {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
settings.Persist()
// create local backup before overwriting
if err := createLocalDatabaseBackup(r.Context()); err != nil {
http.Error(w, "Backup failed", http.StatusInternalServerError)
return
}
if err := db.Restore(r.Context(), tmpName); err != nil {
http.Error(w, "Restore failed", http.StatusInternalServerError)
return
}
// close DB so the WAL is checkpointed into the main file before shutdown
// hooks (e.g. settings.Persist) can overwrite the restored content
if err := db.Close(); err != nil {
http.Error(w, "DB close failed", http.StatusInternalServerError)
return
}
shutdown()
w.WriteHeader(http.StatusNoContent)
}
}
func resetDatabase(shutdown func()) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Sessions bool `json:"sessions"`
Settings bool `json:"settings"`
Remote bool `json:"remote"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
settings.Persist()
if err := createLocalDatabaseBackup(r.Context()); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
if req.Sessions {
query := db.Instance.Exec("DELETE FROM sessions")
if query.Error != nil {
jsonError(w, http.StatusInternalServerError, query.Error)
return
}
}
if req.Settings {
tables := []string{"settings", "configs", "caches", "meters"}
for _, table := range tables {
if err := db.Instance.Exec("DELETE FROM " + table).Error; err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
}
}
if req.Remote {
for _, key := range []string{keys.Remote, keys.RemoteClients, keys.RemoteLastSeen} {
if err := settings.Delete(key); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
}
}
// close db connection to avoid on-shutdown writes
if err := db.Close(); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
shutdown()
w.WriteHeader(http.StatusNoContent)
}
}