UI: add custom CSS support (#21263)
This commit is contained in:
parent
7330fdff63
commit
92ef228af9
12 changed files with 96 additions and 14 deletions
|
|
@ -30,6 +30,7 @@
|
|||
version: "[[.Version]]",
|
||||
configured: "[[.Configured]]",
|
||||
commit: "[[.Commit]]",
|
||||
customCss: [[.CustomCss]],
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -75,3 +75,10 @@ window.app = app.mount("#app");
|
|||
|
||||
watchThemeChanges();
|
||||
appDetection();
|
||||
|
||||
if (window.evcc.customCss) {
|
||||
const link = document.createElement("link");
|
||||
link.href = `./custom.css`;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<footer class="footer">
|
||||
<footer class="footer" data-testid="footer">
|
||||
<div class="container py-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<Version v-bind="version" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<div class="d-flex flex-column site safe-area-inset">
|
||||
<div class="container px-4 top-area">
|
||||
<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center my-3 my-md-4"
|
||||
data-testid="header"
|
||||
>
|
||||
<h1 class="d-block my-0">
|
||||
<span v-if="!isInitialSetup">
|
||||
{{ siteTitle || "evcc" }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<header class="d-flex justify-content-between align-items-center py-3 py-md-4">
|
||||
<header
|
||||
class="d-flex justify-content-between align-items-center py-3 py-md-4"
|
||||
data-testid="header"
|
||||
>
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<router-link class="evcc-default-text" to="/" data-testid="home-link">
|
||||
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const (
|
|||
flagDisableAuth = "disable-auth"
|
||||
flagDisableAuthDescription = "Disable authentication (dangerous)"
|
||||
|
||||
flagCustomCss = "custom-css"
|
||||
flagCustomCssDescription = "Additional user-defined CSS file for custom styling. No compatibility guarantees."
|
||||
|
||||
flagBatteryMode = "battery-mode"
|
||||
flagBatteryModeDescription = "Set battery mode (normal, hold, charge)"
|
||||
flagBatteryModeWait = "battery-mode-wait"
|
||||
|
|
|
|||
17
cmd/root.go
17
cmd/root.go
|
|
@ -36,13 +36,13 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
log = util.NewLogger("main")
|
||||
cfgFile string
|
||||
cfgDatabase string
|
||||
|
||||
ignoreEmpty = "" // ignore empty keys
|
||||
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
|
||||
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
|
||||
log = util.NewLogger("main")
|
||||
cfgFile string
|
||||
cfgDatabase string
|
||||
customCssFile string
|
||||
ignoreEmpty = "" // ignore empty keys
|
||||
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
|
||||
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
|
||||
|
||||
viper *vpr.Viper
|
||||
|
||||
|
|
@ -70,6 +70,7 @@ func init() {
|
|||
rootCmd.PersistentFlags().Bool(flagIgnoreDatabase, false, flagIgnoreDatabaseDescription)
|
||||
rootCmd.PersistentFlags().String(flagTemplate, "", flagTemplateDescription)
|
||||
rootCmd.PersistentFlags().String(flagTemplateType, "", flagTemplateTypeDescription)
|
||||
rootCmd.PersistentFlags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription)
|
||||
|
||||
// config file options
|
||||
rootCmd.PersistentFlags().StringP("log", "l", "info", "Log level (fatal, error, warn, info, debug, trace)")
|
||||
|
|
@ -159,7 +160,7 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
|
||||
// create web server
|
||||
socketHub := server.NewSocketHub()
|
||||
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub)
|
||||
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile)
|
||||
|
||||
// metrics
|
||||
if viper.GetBool("metrics") {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package server
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
eapi "github.com/evcc-io/evcc/api"
|
||||
|
|
@ -40,7 +41,7 @@ type HTTPd struct {
|
|||
}
|
||||
|
||||
// NewHTTPd creates HTTP server with configured routes for loadpoint
|
||||
func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
|
||||
func NewHTTPd(addr string, hub *SocketHub, customCssFile string) *HTTPd {
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
log := util.NewLogger("httpd")
|
||||
|
|
@ -72,10 +73,25 @@ func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
|
|||
})
|
||||
})
|
||||
|
||||
static.HandleFunc("/", indexHandler())
|
||||
if customCssFile != "" {
|
||||
log.WARN.Printf("❗ using custom CSS: %s", customCssFile)
|
||||
if _, err := os.Stat(customCssFile); os.IsNotExist(err) {
|
||||
log.FATAL.Fatalf("custom CSS file does not exist: %s", customCssFile)
|
||||
}
|
||||
static.HandleFunc("/custom.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
// disable caching
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
http.ServeFile(w, r, customCssFile)
|
||||
})
|
||||
}
|
||||
|
||||
static.HandleFunc("/", indexHandler(customCssFile != ""))
|
||||
for _, dir := range []string{"assets", "meta"} {
|
||||
static.PathPrefix("/" + dir).Handler(http.FileServer(http.FS(assets.Web)))
|
||||
}
|
||||
|
||||
static.PathPrefix("/i18n").Handler(http.StripPrefix("/i18n", http.FileServer(http.FS(assets.I18n))))
|
||||
|
||||
srv := &HTTPd{
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func getPreferredLanguage(header string) string {
|
|||
return base.String()
|
||||
}
|
||||
|
||||
func indexHandler() http.HandlerFunc {
|
||||
func indexHandler(customCss bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
|
||||
|
|
@ -60,6 +60,7 @@ func indexHandler() http.HandlerFunc {
|
|||
"Version": util.Version,
|
||||
"Commit": util.Commit,
|
||||
"DefaultLang": defaultLang,
|
||||
"CustomCss": customCss,
|
||||
}); err != nil {
|
||||
log.ERROR.Println("httpd: failed to render main page:", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,3 +81,15 @@ test.describe("loadpoint settings", async () => {
|
|||
await expect(page.getByText("~ 1.4 kW")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("network requests", async () => {
|
||||
test("no failed requests", async ({ page }) => {
|
||||
const failedRequests = [];
|
||||
page.on("requestfailed", (request) => failedRequests.push(request.url()));
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
expect(failedRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
12
tests/custom-css.css
Normal file
12
tests/custom-css.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
:root {
|
||||
--evcc-dark-green: rebeccapurple;
|
||||
--evcc-yellow: hotpink;
|
||||
}
|
||||
|
||||
[data-testid="header"],
|
||||
[data-testid="footer"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* test against markup injection */
|
||||
</style><script>alert("hello");</script><style>
|
||||
23
tests/custom-css.spec.js
Normal file
23
tests/custom-css.spec.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start("basics.evcc.yaml", null, ["--disable-auth", "--custom-css", "tests/custom-css.css"]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
test.describe("custom-css", async () => {
|
||||
test("header and footer are hidden", async ({ page }) => {
|
||||
page.on("dialog", () => {
|
||||
throw new Error("XSS: inline script detected");
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("header")).toBeHidden();
|
||||
await expect(page.getByTestId("footer")).toBeHidden();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue