diff --git a/cmd/server/flags.go b/cmd/server/flags.go index bb22fd6e91..3aeb2ebbef 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -61,8 +61,8 @@ var flags = []cli.Flag{ Usage: "server fully qualified url for forge's Webhooks (://)", }, &cli.StringFlag{ - EnvVars: []string{"WOODPECKER_ROOT_URL"}, - Name: "root-url", + EnvVars: []string{"WOODPECKER_ROOT_PATH", "WOODPECKER_ROOT_URL"}, + Name: "root-path", Usage: "server url root (used for statics loading when having a url path prefix)", }, &cli.StringFlag{ diff --git a/cmd/server/server.go b/cmd/server/server.go index 1822a3433f..d14a86458c 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -357,7 +357,11 @@ func setupEvilGlobals(c *cli.Context, v store.Store, f forge.Forge) { server.Config.Server.StatusContext = c.String("status-context") 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"), "/") + rootPath := strings.TrimSuffix(c.String("root-path"), "/") + if rootPath != "" && !strings.HasPrefix(rootPath, "/") { + rootPath = "/" + rootPath + } + server.Config.Server.RootPath = rootPath 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") diff --git a/docs/docs/30-administration/00-setup.md b/docs/docs/30-administration/00-setup.md index 3d509a4e00..d1161906d7 100644 --- a/docs/docs/30-administration/00-setup.md +++ b/docs/docs/30-administration/00-setup.md @@ -193,4 +193,4 @@ A [Prometheus endpoint](./90-prometheus.md) is exposed. See the [proxy guide](./70-proxy.md) if you want to see a setup behind Apache, Nginx, Caddy or ngrok. -In the case you need to use Woodpecker with a URL path prefix (like: https://example.org/woodpecker/), you can use the option [`WOODPECKER_ROOT_URL`](./10-server-config.md#woodpecker_root_url). +In the case you need to use Woodpecker with a URL path prefix (like: https://example.org/woodpecker/), you can use the option [`WOODPECKER_ROOT_PATH`](./10-server-config.md#woodpecker_root_path). diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index 9c111257dc..af68873e7c 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -525,12 +525,12 @@ Specify a configuration service endpoint, see [Configuration Extension](./100-ex Specify how many seconds before timeout when fetching the Woodpecker configuration from a Forge -### `WOODPECKER_ROOT_URL` +### `WOODPECKER_ROOT_PATH` > Default: `` Server URL path prefix (used for statics loading when having a url path prefix), should start with `/` -Example: `WOODPECKER_ROOT_URL=/woodpecker` +Example: `WOODPECKER_ROOT_PATH=/woodpecker` --- diff --git a/server/api/login.go b/server/api/login.go index 38f49531fa..3a018188a8 100644 --- a/server/api/login.go +++ b/server/api/login.go @@ -34,14 +34,10 @@ import ( ) func HandleLogin(c *gin.Context) { - var ( - w = c.Writer - r = c.Request - ) - if err := r.FormValue("error"); err != "" { - http.Redirect(w, r, "/login/error?code="+err, 303) + if err := c.Request.FormValue("error"); err != "" { + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login/error?code="+err) } else { - http.Redirect(w, r, "/authorize", 303) + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/authorize") } } @@ -56,7 +52,7 @@ func HandleAuth(c *gin.Context) { tmpuser, err := _forge.Login(c, c.Writer, c.Request) if err != nil { log.Error().Msgf("cannot authenticate user. %s", err) - c.Redirect(http.StatusSeeOther, "/login?error=oauth_error") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=oauth_error") return } // this will happen when the user is redirected by the forge as @@ -77,7 +73,7 @@ func HandleAuth(c *gin.Context) { // if self-registration is disabled we should return a not authorized error if !config.Open && !config.IsAdmin(tmpuser) { log.Error().Msgf("cannot register %s. registration closed", tmpuser.Login) - c.Redirect(http.StatusSeeOther, "/login?error=access_denied") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=access_denied") return } @@ -87,7 +83,7 @@ func HandleAuth(c *gin.Context) { teams, terr := _forge.Teams(c, tmpuser) if terr != nil || !config.IsMember(teams) { log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login) - c.Redirect(303, "/login?error=access_denied") + c.Redirect(303, server.Config.Server.RootPath+"/login?error=access_denied") return } } @@ -108,7 +104,7 @@ func HandleAuth(c *gin.Context) { // insert the user into the database if err := _store.CreateUser(u); err != nil { log.Error().Msgf("cannot insert %s. %s", u.Login, err) - c.Redirect(http.StatusSeeOther, "/login?error=internal_error") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } @@ -137,14 +133,14 @@ func HandleAuth(c *gin.Context) { teams, terr := _forge.Teams(c, u) if terr != nil || !config.IsMember(teams) { log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login) - c.Redirect(http.StatusSeeOther, "/login?error=access_denied") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=access_denied") return } } if err := _store.UpdateUser(u); err != nil { log.Error().Msgf("cannot update %s. %s", u.Login, err) - c.Redirect(http.StatusSeeOther, "/login?error=internal_error") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } @@ -152,7 +148,7 @@ func HandleAuth(c *gin.Context) { tokenString, err := token.New(token.SessToken, u.Login).SignExpires(u.Hash, exp) if err != nil { log.Error().Msgf("cannot create token for %s. %s", u.Login, err) - c.Redirect(http.StatusSeeOther, "/login?error=internal_error") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } @@ -187,13 +183,13 @@ func HandleAuth(c *gin.Context) { httputil.SetCookie(c.Writer, c.Request, "user_sess", tokenString) - c.Redirect(http.StatusSeeOther, "/") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") } func GetLogout(c *gin.Context) { httputil.DelCookie(c.Writer, c.Request, "user_sess") httputil.DelCookie(c.Writer, c.Request, "user_last") - c.Redirect(http.StatusSeeOther, "/") + c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") } func GetLoginToken(c *gin.Context) { diff --git a/server/config.go b/server/config.go index b3efd444ad..e6863c7827 100644 --- a/server/config.go +++ b/server/config.go @@ -67,7 +67,7 @@ var Config = struct { StatusContext string StatusContextFormat string SessionExpires time.Duration - RootURL string + RootPath string CustomCSSFile string CustomJsFile string Migrations struct { diff --git a/server/forge/bitbucket/bitbucket.go b/server/forge/bitbucket/bitbucket.go index d0d4c19820..5e3596a094 100644 --- a/server/forge/bitbucket/bitbucket.go +++ b/server/forge/bitbucket/bitbucket.go @@ -77,7 +77,7 @@ func (c *config) URL() string { // Login authenticates an account with Bitbucket using the oauth2 protocol. The // Bitbucket account details are returned when the user is successfully authenticated. func (c *config) Login(ctx context.Context, w http.ResponseWriter, req *http.Request) (*model.User, error) { - config := c.newConfig(server.Config.Server.Host) + config := c.newConfig(server.Config.Server.Host + server.Config.Server.RootPath) // get the OAuth errors if err := req.FormValue("error"); err != "" { diff --git a/server/forge/gitea/gitea.go b/server/forge/gitea/gitea.go index 1502d8bd4f..e030a3d8fd 100644 --- a/server/forge/gitea/gitea.go +++ b/server/forge/gitea/gitea.go @@ -103,7 +103,7 @@ func (c *Gitea) oauth2Config(ctx context.Context) (*oauth2.Config, context.Conte AuthURL: fmt.Sprintf(authorizeTokenURL, c.url), TokenURL: fmt.Sprintf(accessTokenURL, c.url), }, - RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), + RedirectURL: fmt.Sprintf("%s%s/authorize", server.Config.Server.OAuthHost, server.Config.Server.RootPath), }, context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ diff --git a/server/forge/github/github.go b/server/forge/github/github.go index 7e009bf8a5..5f644a6449 100644 --- a/server/forge/github/github.go +++ b/server/forge/github/github.go @@ -395,9 +395,9 @@ func (c *client) newConfig(req *http.Request) *oauth2.Config { intendedURL := req.URL.Query()["url"] if len(intendedURL) > 0 { - redirect = fmt.Sprintf("%s/authorize?url=%s", server.Config.Server.OAuthHost, intendedURL[0]) + redirect = fmt.Sprintf("%s%s/authorize?url=%s", server.Config.Server.OAuthHost, server.Config.Server.RootPath, intendedURL[0]) } else { - redirect = fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost) + redirect = fmt.Sprintf("%s%s/authorize", server.Config.Server.OAuthHost, server.Config.Server.RootPath) } return &oauth2.Config{ diff --git a/server/forge/gitlab/gitlab.go b/server/forge/gitlab/gitlab.go index a90d1b1189..0e85be2e9a 100644 --- a/server/forge/gitlab/gitlab.go +++ b/server/forge/gitlab/gitlab.go @@ -93,7 +93,7 @@ func (g *GitLab) oauth2Config(ctx context.Context) (*oauth2.Config, context.Cont TokenURL: fmt.Sprintf("%s/oauth/token", g.url), }, Scopes: []string{defaultScope}, - RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), + RedirectURL: fmt.Sprintf("%s%s/authorize", server.Config.Server.OAuthHost, server.Config.Server.RootPath), }, context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ diff --git a/server/router/api.go b/server/router/api.go index 02fd341d7c..9517fca114 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -23,7 +23,7 @@ import ( "github.com/woodpecker-ci/woodpecker/server/router/middleware/session" ) -func apiRoutes(e *gin.Engine) { +func apiRoutes(e *gin.RouterGroup) { apiBase := e.Group("/api") { user := apiBase.Group("/user") diff --git a/server/router/router.go b/server/router/router.go index 94d03d994a..ce02d753d2 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -22,9 +22,9 @@ import ( "github.com/rs/zerolog/log" swaggerfiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" + "github.com/woodpecker-ci/woodpecker/cmd/server/docs" "github.com/woodpecker-ci/woodpecker/server" - "github.com/woodpecker-ci/woodpecker/server/api" "github.com/woodpecker-ci/woodpecker/server/api/metrics" "github.com/woodpecker-ci/woodpecker/server/router/middleware/header" @@ -53,22 +53,29 @@ func Load(noRouteHandler http.HandlerFunc, middleware ...gin.HandlerFunc) http.H e.NoRoute(gin.WrapF(noRouteHandler)) - e.GET("/web-config.js", web.Config) - - e.GET("/logout", api.GetLogout) - e.GET("/login", api.HandleLogin) - auth := e.Group("/authorize") + base := e.Group(server.Config.Server.RootPath) { - auth.GET("", api.HandleAuth) - auth.POST("", api.HandleAuth) - auth.POST("/token", api.GetLoginToken) + base.GET("/web-config.js", web.Config) + + base.GET("/logout", api.GetLogout) + base.GET("/login", api.HandleLogin) + auth := base.Group("/authorize") + { + auth.GET("", api.HandleAuth) + auth.POST("", api.HandleAuth) + auth.POST("/token", api.GetLoginToken) + } + + base.GET("/metrics", metrics.PromHandler()) + base.GET("/version", api.Version) + base.GET("/healthz", api.Health) } e.GET("/metrics", metrics.PromHandler()) e.GET("/version", api.Version) e.GET("/healthz", api.Health) - apiRoutes(e) + apiRoutes(base) setupSwaggerConfigAndRoutes(e) return e @@ -76,8 +83,8 @@ func Load(noRouteHandler http.HandlerFunc, middleware ...gin.HandlerFunc) http.H func setupSwaggerConfigAndRoutes(e *gin.Engine) { docs.SwaggerInfo.Host = getHost(server.Config.Server.Host) - docs.SwaggerInfo.BasePath = server.Config.Server.RootURL + "/api" - e.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) + docs.SwaggerInfo.BasePath = server.Config.Server.RootPath + "/api" + e.GET(server.Config.Server.RootPath+"/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) } func getHost(s string) string { diff --git a/server/web/config.go b/server/web/config.go index 535daa74f5..3d3edb7d13 100644 --- a/server/web/config.go +++ b/server/web/config.go @@ -40,12 +40,12 @@ func Config(c *gin.Context) { } configData := map[string]interface{}{ - "user": user, - "csrf": csrf, - "docs": server.Config.Server.Docs, - "version": version.String(), - "forge": server.Config.Services.Forge.Name(), - "root_url": server.Config.Server.RootURL, + "user": user, + "csrf": csrf, + "docs": server.Config.Server.Docs, + "version": version.String(), + "forge": server.Config.Services.Forge.Name(), + "root_path": server.Config.Server.RootPath, } // default func map with json parser. @@ -74,5 +74,5 @@ window.WOODPECKER_CSRF = "{{ .csrf }}"; window.WOODPECKER_VERSION = "{{ .version }}"; window.WOODPECKER_DOCS = "{{ .docs }}"; window.WOODPECKER_FORGE = "{{ .forge }}"; -window.WOODPECKER_ROOT_URL = "{{ .root_url }}"; +window.WOODPECKER_ROOT_PATH = "{{ .root_path }}"; ` diff --git a/server/web/web.go b/server/web/web.go index 31452c8475..df7b95c77a 100644 --- a/server/web/web.go +++ b/server/web/web.go @@ -17,10 +17,11 @@ package web import ( "bytes" "crypto/md5" + "errors" "fmt" + "io" + "io/fs" "net/http" - "net/url" - "regexp" "strings" "time" @@ -54,24 +55,23 @@ func New() (*gin.Engine, error) { e.Use(setupCache) - rootURL, _ := url.Parse(server.Config.Server.RootURL) - rootPath := rootURL.Path + rootPath := server.Config.Server.RootPath httpFS, err := web.HTTPFS() if err != nil { return nil, err } - 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(handleCustomFilesAndAssets(h))) + f := &prefixFS{httpFS, rootPath} + e.GET(rootPath+"/favicon.svg", redirect(server.Config.Server.RootPath+"/favicons/favicon-light-default.svg", http.StatusPermanentRedirect)) + e.GET(rootPath+"/favicons/*filepath", serveFile(f)) + e.GET(rootPath+"/assets/*filepath", handleCustomFilesAndAssets(f)) e.NoRoute(handleIndex) return e, nil } -func handleCustomFilesAndAssets(assetHandler http.Handler) http.HandlerFunc { +func handleCustomFilesAndAssets(fs *prefixFS) func(ctx *gin.Context) { serveFileOrEmptyContent := func(w http.ResponseWriter, r *http.Request, localFileName string) { if len(localFileName) > 0 { http.ServeFile(w, r, localFileName) @@ -80,13 +80,50 @@ func handleCustomFilesAndAssets(assetHandler http.Handler) http.HandlerFunc { 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) + return func(ctx *gin.Context) { + if strings.HasSuffix(ctx.Request.RequestURI, "/assets/custom.js") { + serveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomJsFile) + } else if strings.HasSuffix(ctx.Request.RequestURI, "/assets/custom.css") { + serveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomCSSFile) } else { - assetHandler.ServeHTTP(w, r) + serveFile(fs)(ctx) + } + } +} + +func serveFile(f *prefixFS) func(ctx *gin.Context) { + return func(ctx *gin.Context) { + file, err := f.Open(ctx.Request.URL.Path) + if err != nil { + code := http.StatusInternalServerError + if errors.Is(err, fs.ErrNotExist) { + code = http.StatusNotFound + } else if errors.Is(err, fs.ErrPermission) { + code = http.StatusForbidden + } + ctx.Status(code) + return + } + data, err := io.ReadAll(file) + if err != nil { + ctx.Status(http.StatusInternalServerError) + return + } + var mime string + switch { + case strings.HasSuffix(ctx.Request.URL.Path, ".js"): + mime = "text/javascript" + case strings.HasSuffix(ctx.Request.URL.Path, ".css"): + mime = "text/css" + case strings.HasSuffix(ctx.Request.URL.Path, ".png"): + mime = "image/png" + case strings.HasSuffix(ctx.Request.URL.Path, ".svg"): + mime = "image/svg" + } + ctx.Status(http.StatusOK) + ctx.Writer.Header().Set("Content-Type", mime) + if _, err := ctx.Writer.Write(replaceBytes(data)); err != nil { + log.Error().Err(err).Msgf("can not write %s", ctx.Request.URL.Path) } } } @@ -112,15 +149,27 @@ func handleIndex(c *gin.Context) { } } +func loadFile(path string) ([]byte, error) { + data, err := web.Lookup(path) + if err != nil { + return nil, err + } + return replaceBytes(data), nil +} + +func replaceBytes(data []byte) []byte { + return bytes.ReplaceAll(data, []byte("/BASE_PATH"), []byte(server.Config.Server.RootPath)) +} + func parseIndex() []byte { - data, err := web.Lookup("index.html") + data, err := loadFile("index.html") if err != nil { log.Fatal().Err(err).Msg("can not find index.html") } - if server.Config.Server.RootURL == "" { - return data - } - return regexp.MustCompile(`/\S+\.(js|css|png|svg)`).ReplaceAll(data, []byte(server.Config.Server.RootURL+"$0")) + data = bytes.ReplaceAll(data, []byte("/web-config.js"), []byte(server.Config.Server.RootPath+"/web-config.js")) + data = bytes.ReplaceAll(data, []byte("/assets/custom.css"), []byte(server.Config.Server.RootPath+"/assets/custom.css")) + data = bytes.ReplaceAll(data, []byte("/assets/custom.js"), []byte(server.Config.Server.RootPath+"/assets/custom.js")) + return data } func setupCache(c *gin.Context) { diff --git a/web/components.d.ts b/web/components.d.ts index 0aa7ba65da..c0a60ebad3 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -101,6 +101,7 @@ declare module '@vue/runtime-core' { Tab: typeof import('./src/components/layout/scaffold/Tab.vue')['default'] Tabs: typeof import('./src/components/layout/scaffold/Tabs.vue')['default'] TextField: typeof import('./src/components/form/TextField.vue')['default'] + UserAPITab: typeof import('./src/components/user/UserAPITab.vue')['default'] Warning: typeof import('./src/components/atomic/Warning.vue')['default'] } } diff --git a/web/index.html b/web/index.html index ed1b99067b..c13be0e559 100644 --- a/web/index.html +++ b/web/index.html @@ -7,8 +7,8 @@ Woodpecker - +
diff --git a/web/package.json b/web/package.json index f7aada656a..b92624a01c 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ }, "scripts": { "start": "vite", - "build": "vite build", + "build": "vite build --base=/BASE_PATH", "serve": "vite preview", "lint": "eslint --max-warnings 0 --ext .js,.ts,.vue,.json .", "formatcheck": "prettier -c .", diff --git a/web/src/assets/logo.svg b/web/src/assets/logo.svg index b9c8b6c6cd..83cf00a614 100644 --- a/web/src/assets/logo.svg +++ b/web/src/assets/logo.svg @@ -1 +1 @@ - + diff --git a/web/src/components/layout/header/Navbar.vue b/web/src/components/layout/header/Navbar.vue index 0af833dfbb..07e2e384db 100644 --- a/web/src/components/layout/header/Navbar.vue +++ b/web/src/components/layout/header/Navbar.vue @@ -5,7 +5,7 @@
- + {{ version }} @@ -53,6 +53,7 @@ import { defineComponent } from 'vue'; import { useRoute } from 'vue-router'; +import WoodpeckerLogo from '~/assets/logo.svg?component'; import Button from '~/components/atomic/Button.vue'; import IconButton from '~/components/atomic/IconButton.vue'; import useAuthentication from '~/compositions/useAuthentication'; @@ -64,7 +65,7 @@ import ActivePipelines from './ActivePipelines.vue'; export default defineComponent({ name: 'Navbar', - components: { Button, ActivePipelines, IconButton }, + components: { Button, ActivePipelines, IconButton, WoodpeckerLogo }, setup() { const config = useConfig(); @@ -72,7 +73,7 @@ export default defineComponent({ const authentication = useAuthentication(); const { darkMode } = useDarkMode(); const docsUrl = config.docs || undefined; - const apiUrl = `${config.rootURL ?? ''}/swagger/index.html`; + const apiUrl = `${config.rootPath ?? ''}/swagger/index.html`; function doLogin() { authentication.authenticate(route.fullPath); diff --git a/web/src/components/repo/pipeline/PipelineRunningIcon.vue b/web/src/components/repo/pipeline/PipelineRunningIcon.vue index e0bc184e8e..6407b542d4 100644 --- a/web/src/components/repo/pipeline/PipelineRunningIcon.vue +++ b/web/src/components/repo/pipeline/PipelineRunningIcon.vue @@ -3,7 +3,7 @@