From 2c1fc4b5004fa6ec268a1f109420bc9f5624e83b Mon Sep 17 00:00:00 2001 From: "Martin W. Kirst" Date: Mon, 10 Jul 2023 12:46:35 +0200 Subject: [PATCH] support custom .JS and .CSS files for custom banner messages (white-labeling) (#1781) This PR introduces two new server configuration options, for providing a custom .JS and .CSS file. These can be used to show custom banner messages, add environment-dependent signals, or simply a corporate logo. ### Motivation (what problem I try to solve) I'm operating Woodpecker in multiple k8s clusters for different environments. When having multiple browser tabs open, I prefer strong indicators for each environment. E.g. a red "PROD" banner, or just a blue "QA" banner. Also, we sometimes need to have the chance for maintenance, and instead of broadcasting emails, I prefer a banner message, stating something like: "Heads-up: there's a planned downtime, next Friday, blabla...". Also, I like to have the firm's logo visible, which makes Woodpecker look more like an integral part of our platform. ### Implementation notes * Two new config options are introduced ```WOODPECKER_CUSTOM_CSS_FILE``` and ```WOODPECKER_CUSTOM_JS_FILE``` * I've piggy-bagged the existing handler for assets, as it seemed to me a minimally invasive approach * the option along with an example is documented * a simple unit test for the Gin-handler ensures some regression safety * no extra dependencies are introduced ### Visual example The documented example will look like this. ![Screenshot 2023-05-27 at 17 00 44](https://github.com/woodpecker-ci/woodpecker/assets/1189394/8940392e-463c-4651-a1eb-f017cd3cd64d) ### Areas of uncertainty This is my first contribution to Woodpecker and I tried my best to align with your conventions. That said, I found myself uncertain about these things and would be glad about getting feedback. * The handler tests are somewhat different than the other ones because I wanted to keep them simple - I hope that still matches your coding guidelines * caching the page sometimes will let the browser not recognize changes and a user must reload. I'm not fully into the details of how caching is implemented and neither can judge if it's a real problem. Another pair of eyes would be good. --- cmd/server/flags.go | 10 +++ cmd/server/server.go | 2 + .../30-administration/10-server-config.md | 59 ++++++++++++++++ server/config.go | 2 + server/web/web.go | 23 +++++- server/web/web_test.go | 70 +++++++++++++++++++ web/index.html | 2 + 7 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 server/web/web_test.go diff --git a/cmd/server/flags.go b/cmd/server/flags.go index ae3c7b9c5..454936a08 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -77,6 +77,16 @@ var flags = []cli.Flag{ Name: "server-key", Usage: "server ssl key path", }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_CUSTOM_CSS_FILE"}, + Name: "custom-css-file", + Usage: "file path for the server to serve a custom .CSS file, used for customizing the UI", + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_CUSTOM_JS_FILE"}, + Name: "custom-js-file", + Usage: "file path for the server to serve a custom .JS file, used for customizing the UI", + }, &cli.StringFlag{ EnvVars: []string{"WOODPECKER_LETS_ENCRYPT_EMAIL"}, Name: "lets-encrypt-email", diff --git a/cmd/server/server.go b/cmd/server/server.go index f69acea4d..1822a3433 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -358,6 +358,8 @@ func setupEvilGlobals(c *cli.Context, v store.Store, f forge.Forge) { server.Config.Server.StatusContextFormat = c.String("status-context-format") server.Config.Server.SessionExpires = c.Duration("session-expires") server.Config.Server.RootURL = strings.TrimSuffix(c.String("root-url"), "/") + server.Config.Server.CustomCSSFile = strings.TrimSpace(c.String("custom-css-file")) + server.Config.Server.CustomJsFile = strings.TrimSpace(c.String("custom-js-file")) server.Config.Pipeline.Networks = c.StringSlice("network") server.Config.Pipeline.Volumes = c.StringSlice("volume") server.Config.Pipeline.Privileged = c.StringSlice("escalate") diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index 40586a95c..1505d1406 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -139,6 +139,47 @@ or generate a random one like this: `openssl rand -hex 32 | docker secret create woodpecker-agent-secret -` +## Custom Javascript and CSS Styling (a.k.a. white-labeling) + +Woodpecker supports custom styling of the Web UI by providing custom JS and CSS files. +These files must be present in the server's filesystem. +They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. +The configuration variables are independent of each other, which means it can be just one file present, or both. + +```text +WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css +WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.js +``` + +The examples below show how to place a banner message in the top navigation bar of Woodpecker. + +##### woodpecker.css +```css +.banner-message { + position: absolute; + width: 280px; + height: 40px; + margin-left: 240px; + margin-top: 5px; + padding-top: 5px; + font-weight: bold; + background: red no-repeat; + text-align: center; +} +``` + +##### woodpecker.js + +```javascript +// place/copy a minified version of jQuery or ZeptoJS here ... +!function(){"use strict";function e(){};/*...*/}(); + +$().ready(function(){ + $(".app nav img").first().htmlAfter("") +}); +``` + + ## All server configuration options The following list describes all available server configuration options. @@ -196,6 +237,24 @@ Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` +### `WOODPECKER_CUSTOM_CSS_FILE` +> Default: empty + +File path for the server to serve a custom .CSS file, used for customizing the UI. +Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). +The file must be UTF-8 encoded, to ensure all special characters are preserved. + +Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` + +### `WOODPECKER_CUSTOM_JS_FILE` +> Default: empty + +File path for the server to serve a custom .JS file, used for customizing the UI. +Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). +The file must be UTF-8 encoded, to ensure all special characters are preserved. + +Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` + ### `WOODPECKER_LETS_ENCRYPT` > Default: `false` diff --git a/server/config.go b/server/config.go index 0e52e988b..b3efd444a 100644 --- a/server/config.go +++ b/server/config.go @@ -68,6 +68,8 @@ var Config = struct { StatusContextFormat string SessionExpires time.Duration RootURL string + CustomCSSFile string + CustomJsFile string Migrations struct { AllowLong bool } diff --git a/server/web/web.go b/server/web/web.go index 50af4d242..31452c847 100644 --- a/server/web/web.go +++ b/server/web/web.go @@ -15,6 +15,7 @@ package web import ( + "bytes" "crypto/md5" "fmt" "net/http" @@ -63,13 +64,33 @@ func New() (*gin.Engine, error) { h := http.FileServer(&prefixFS{httpFS, rootPath}) e.GET(rootPath+"/favicon.svg", redirect(server.Config.Server.RootURL+"/favicons/favicon-light-default.svg", http.StatusPermanentRedirect)) e.GET(rootPath+"/favicons/*filepath", gin.WrapH(h)) - e.GET(rootPath+"/assets/*filepath", gin.WrapH(h)) + e.GET(rootPath+"/assets/*filepath", gin.WrapH(handleCustomFilesAndAssets(h))) e.NoRoute(handleIndex) return e, nil } +func handleCustomFilesAndAssets(assetHandler http.Handler) http.HandlerFunc { + serveFileOrEmptyContent := func(w http.ResponseWriter, r *http.Request, localFileName string) { + if len(localFileName) > 0 { + http.ServeFile(w, r, localFileName) + } else { + // prefer zero content over sending a 404 Not Found + http.ServeContent(w, r, localFileName, time.Now(), bytes.NewReader([]byte{})) + } + } + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.RequestURI, "/assets/custom.js") { + serveFileOrEmptyContent(w, r, server.Config.Server.CustomJsFile) + } else if strings.HasSuffix(r.RequestURI, "/assets/custom.css") { + serveFileOrEmptyContent(w, r, server.Config.Server.CustomCSSFile) + } else { + assetHandler.ServeHTTP(w, r) + } + } +} + // redirect return gin helper to redirect a request func redirect(location string, status ...int) func(ctx *gin.Context) { return func(ctx *gin.Context) { diff --git a/server/web/web_test.go b/server/web/web_test.go new file mode 100644 index 000000000..f4f98ebae --- /dev/null +++ b/server/web/web_test.go @@ -0,0 +1,70 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/woodpecker-ci/woodpecker/server" +) + +func Test_custom_file_returns_OK_and_empty_content(t *testing.T) { + gin.SetMode(gin.TestMode) + + customFiles := []string{ + "/assets/custom.js", + "/assets/custom.css", + } + + for _, f := range customFiles { + t.Run(f, func(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, f, nil) + request.RequestURI = f // additional required for mocking + assert.NoError(t, err) + + rr := httptest.NewRecorder() + router, _ := New() + router.ServeHTTP(rr, request) + + assert.Equal(t, 200, rr.Code) + assert.Equal(t, []byte(nil), rr.Body.Bytes()) + }) + } +} + +func Test_custom_file_return_actual_content(t *testing.T) { + gin.SetMode(gin.TestMode) + + temp, err := os.CreateTemp(os.TempDir(), "data.txt") + assert.NoError(t, err) + _, err = temp.Write([]byte("EXPECTED-DATA")) + assert.NoError(t, err) + err = temp.Close() + assert.NoError(t, err) + + server.Config.Server.CustomJsFile = temp.Name() + server.Config.Server.CustomCSSFile = temp.Name() + + customRequestedFilesToTest := []string{ + "/assets/custom.js", + "/assets/custom.css", + } + + for _, f := range customRequestedFilesToTest { + t.Run(f, func(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, f, nil) + request.RequestURI = f // additional required for mocking + assert.NoError(t, err) + + rr := httptest.NewRecorder() + router, _ := New() + router.ServeHTTP(rr, request) + + assert.Equal(t, 200, rr.Code) + assert.Equal(t, []byte("EXPECTED-DATA"), rr.Body.Bytes()) + }) + } +} diff --git a/web/index.html b/web/index.html index 66a0f1be1..ed1b99067 100644 --- a/web/index.html +++ b/web/index.html @@ -7,10 +7,12 @@ Woodpecker +
+