mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-09-22 19:50:06 +00:00
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:
parent
f2c33a0d89
commit
2c1fc4b500
7 changed files with 167 additions and 1 deletions
|
@ -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",
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
70
server/web/web_test.go
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue