mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-11-11 11:32:01 +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",
|
||||
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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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("<div class='banner-message'>This is a demo banner message :)</div>")
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## 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`
|
||||
|
||||
|
|
|
@ -68,6 +68,8 @@ var Config = struct {
|
|||
StatusContextFormat string
|
||||
SessionExpires time.Duration
|
||||
RootURL string
|
||||
CustomCSSFile string
|
||||
CustomJsFile string
|
||||
Migrations struct {
|
||||
AllowLong bool
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
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="theme-color" content="#65a30d" />
|
||||
<title>Woodpecker</title>
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script type="" src="/web-config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="application/javascript" src="/assets/custom.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Loading…
Reference in a new issue