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.
This commit is contained in:
Martin W. Kirst 2023-07-10 12:46:35 +02:00 committed by GitHub
parent f2c33a0d89
commit 2c1fc4b500
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 167 additions and 1 deletions

View file

@ -77,6 +77,16 @@ var flags = []cli.Flag{
Name: "server-key", Name: "server-key",
Usage: "server ssl key path", 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{ &cli.StringFlag{
EnvVars: []string{"WOODPECKER_LETS_ENCRYPT_EMAIL"}, EnvVars: []string{"WOODPECKER_LETS_ENCRYPT_EMAIL"},
Name: "lets-encrypt-email", Name: "lets-encrypt-email",

View file

@ -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.StatusContextFormat = c.String("status-context-format")
server.Config.Server.SessionExpires = c.Duration("session-expires") server.Config.Server.SessionExpires = c.Duration("session-expires")
server.Config.Server.RootURL = strings.TrimSuffix(c.String("root-url"), "/") 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.Networks = c.StringSlice("network")
server.Config.Pipeline.Volumes = c.StringSlice("volume") server.Config.Pipeline.Volumes = c.StringSlice("volume")
server.Config.Pipeline.Privileged = c.StringSlice("escalate") server.Config.Pipeline.Privileged = c.StringSlice("escalate")

View file

@ -139,6 +139,47 @@ or generate a random one like this:
`openssl rand -hex 32 | docker secret create woodpecker-agent-secret -` `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("<div class='banner-message'>This is a demo banner message :)</div>")
});
```
## All server configuration options ## All server configuration options
The following list describes all available 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` 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` ### `WOODPECKER_LETS_ENCRYPT`
> Default: `false` > Default: `false`

View file

@ -68,6 +68,8 @@ var Config = struct {
StatusContextFormat string StatusContextFormat string
SessionExpires time.Duration SessionExpires time.Duration
RootURL string RootURL string
CustomCSSFile string
CustomJsFile string
Migrations struct { Migrations struct {
AllowLong bool AllowLong bool
} }

View file

@ -15,6 +15,7 @@
package web package web
import ( import (
"bytes"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"net/http" "net/http"
@ -63,13 +64,33 @@ func New() (*gin.Engine, error) {
h := http.FileServer(&prefixFS{httpFS, rootPath}) 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+"/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+"/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) e.NoRoute(handleIndex)
return e, nil 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 // redirect return gin helper to redirect a request
func redirect(location string, status ...int) func(ctx *gin.Context) { func redirect(location string, status ...int) func(ctx *gin.Context) {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {

70
server/web/web_test.go Normal file
View file

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

View file

@ -7,10 +7,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#65a30d" /> <meta name="theme-color" content="#65a30d" />
<title>Woodpecker</title> <title>Woodpecker</title>
<link rel="stylesheet" href="/assets/custom.css" />
<script type="" src="/web-config.js"></script> <script type="" src="/web-config.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script type="application/javascript" src="/assets/custom.js"></script>
</body> </body>
</html> </html>