572 lines
15 KiB
Go
572 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/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"`
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|