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) } }