Skip to content

Commit f78eff2

Browse files
authored
Frontend development: redirect instead of proxy (#19177)
* [dev] make-user-admin: Grant all admin permissions * [dev] Change frontend-dev to redirect instead of transparently proxy This removes the "proxy needs network access to upstream" constraint, by relying on the developers browser * [dev] front-end dev: more convenience, and added to readme
1 parent ad2a077 commit f78eff2

File tree

8 files changed

+249
-55
lines changed

8 files changed

+249
-55
lines changed

components/BUILD.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ scripts:
268268
echo "No input."
269269
else
270270
echo "User: $user"
271-
query="update d_b_user set rolesOrPermissions = '[\"admin\"]', fgaRelationshipsVersion=0 where name=\"$user\";"
271+
query="update d_b_user set rolesOrPermissions = '[\"admin\", \"admin-permissions\"]', fgaRelationshipsVersion=0 where name=\"$user\";"
272272
mysql -e "$query" -u$DB_USERNAME -p$DB_PASSWORD -h 127.0.0.1 gitpod
273273
fi
274274
kill $PID || true

components/dashboard/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,26 @@ After creating a new component, run the following to update the license header:
3636

3737
## How to develop in gitpod.io
3838

39+
### Against any* Gitpod installation
40+
41+
Gitpod installations have a feature that - if you are authorized - allow different versions of the dashboard. This allows for front-end development with live data and super-quick turnarounds.
42+
43+
**Preconditions**
44+
1. logged in user on the respective Gitpod installation (e.g. gitpod.example.org)
45+
1. user has the `"developer"` role
46+
47+
**Steps**
48+
1. Start a workspace (on any installation), and start the dev-server with `yarn start-local`
49+
1. Configure your browser to always send header `X-Frontend-Dev-URL` with value set to the result of `gp url 3000` to the Gitpod installation you want to modify (gitpod.example.org)
50+
1. Visit https://gitpod.example.org, start modifying your `dashboard` in your workspace, and experience the effect live (incl. hot reloading)
51+
52+
*: This feature is _not_ enabled on all installations, and requires special user privileges.
53+
54+
### Outdated, in-workspace (?)
55+
3956
All the commands in this section are meant to be executed from the `components/dashboard` directory.
4057

41-
### 1. Environment variables
58+
#### 1. Environment variables
4259

4360
Set the following 2 [environment variables](https://www.gitpod.io/docs/environment-variables) either [via your account settings](https://gitpod.io/variables) or [via the command line](https://www.gitpod.io/docs/environment-variables#using-the-command-line-gp-env).
4461

@@ -63,7 +80,7 @@ Replace `AUTHENTICATION_COOKIE_VALUE` with the value of your auth cookie taken f
6380
| -------------------------------------------------------------------------- |
6481
| ![Where to get the auth cookie name and value from](how-to-get-cookie.png) |
6582

66-
### 2. Start the dashboard app
83+
#### 2. Start the dashboard app
6784

6885
🚀 After following the above steps, run `yarn run start` to start developing.
6986
You can view the dashboard at https://`PORT_NUMBER`-`GITPOD_WORKSPACE_URL` (`PORT_NUMBER` is usually `3000`).

components/dashboard/craco.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const { when } = require("@craco/craco");
77
const path = require("path");
88
const webpack = require("webpack");
99

10+
function withEndingSlash(str) {
11+
return str.endsWith("/") ? str : str + "/";
12+
}
13+
1014
module.exports = {
1115
style: {
1216
postcss: {
@@ -49,6 +53,16 @@ module.exports = {
4953
Buffer: ["buffer", "Buffer"],
5054
}),
5155
],
56+
// If ASSET_PATH is set, we imply that we also want a statically named main.js, so we can reference it from the outside
57+
output: !!process.env.ASSET_PATH
58+
? {
59+
...(webpack?.configure?.output || {}),
60+
filename: (pathData) => {
61+
return pathData.chunk.name === "main" ? "static/js/main.js" : undefined;
62+
},
63+
publicPath: withEndingSlash(process.env.ASSET_PATH),
64+
}
65+
: undefined,
5266
},
5367
},
5468
devServer: {

components/dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
},
9797
"scripts": {
9898
"start": "BROWSER=none HMR_HOST=`gp url 3001` craco start",
99-
"start-local": "BROWSER=none HMR_HOST=`gp url 3000` craco start",
99+
"start-local": "gp ports visibility 3000:public; BROWSER=none HMR_HOST=`gp url 3000` ASSET_PATH=`gp url 3000` craco start",
100100
"build": "craco build --verbose",
101101
"lint": "eslint --max-warnings=0 --ext=.jsx,.js,.tsx,.ts ./src",
102102
"test": "yarn test:unit",

components/proxy/conf/Caddyfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,9 @@ https://{$GITPOD_DOMAIN} {
376376
header_up -Upgrade
377377
}
378378
# Then handle it with our plugin!
379-
gitpod.frontend_dev
379+
gitpod.frontend_dev {
380+
upstream http://dashboard.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3001
381+
}
380382
}
381383

382384
reverse_proxy dashboard.{$KUBE_NAMESPACE}.{$KUBE_DOMAIN}:3001 {

components/proxy/plugins/frontend_dev/frontend_dev.go

Lines changed: 111 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
// Licensed under the GNU Affero General Public License (AGPL).
33
// See License.AGPL.txt in the project root for license information.
44

5-
package workspacedownload
5+
package frontend_dev
66

77
import (
8+
"bytes"
89
"fmt"
10+
"io"
911
"net/http"
1012
"net/http/httputil"
1113
"net/url"
1214
"os"
15+
"regexp"
1316
"strings"
1417

1518
"github.com/caddyserver/caddy/v2"
@@ -25,24 +28,26 @@ const (
2528
)
2629

2730
func init() {
28-
caddy.RegisterModule(Config{})
31+
caddy.RegisterModule(FrontendDev{})
2932
httpcaddyfile.RegisterHandlerDirective(frontendDevModule, parseCaddyfile)
3033
}
3134

32-
// Config implements an HTTP handler that extracts gitpod headers
33-
type Config struct {
35+
// FrontendDev implements an HTTP handler that extracts gitpod headers
36+
type FrontendDev struct {
37+
Upstream string `json:"upstream,omitempty"`
38+
UpstreamUrl *url.URL
3439
}
3540

3641
// CaddyModule returns the Caddy module information.
37-
func (Config) CaddyModule() caddy.ModuleInfo {
42+
func (FrontendDev) CaddyModule() caddy.ModuleInfo {
3843
return caddy.ModuleInfo{
3944
ID: "http.handlers.frontend_dev",
40-
New: func() caddy.Module { return new(Config) },
45+
New: func() caddy.Module { return new(FrontendDev) },
4146
}
4247
}
4348

4449
// ServeHTTP implements caddyhttp.MiddlewareHandler.
45-
func (m Config) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
50+
func (m FrontendDev) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
4651
enabled := os.Getenv(frontendDevEnabledEnvVarName)
4752
if enabled != "true" {
4853
caddy.Log().Sugar().Debugf("Dev URL header present but disabled")
@@ -60,70 +65,127 @@ func (m Config) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp
6065
return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("unexpected error forwarding to dev URL"))
6166
}
6267

63-
targetQuery := devURL.RawQuery
6468
director := func(req *http.Request) {
65-
req.URL.Scheme = devURL.Scheme
66-
req.URL.Host = devURL.Host
67-
req.Host = devURL.Host // override host header so target proxy can handle this request properly
68-
69-
req.URL.Path, req.URL.RawPath = joinURLPath(devURL, req.URL)
70-
if targetQuery == "" || req.URL.RawQuery == "" {
71-
req.URL.RawQuery = targetQuery + req.URL.RawQuery
72-
} else {
73-
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
74-
}
69+
req.URL.Scheme = m.UpstreamUrl.Scheme
70+
req.URL.Host = m.UpstreamUrl.Host
71+
req.Host = m.UpstreamUrl.Host
7572
if _, ok := req.Header["User-Agent"]; !ok {
7673
// explicitly disable User-Agent so it's not set to default value
7774
req.Header.Set("User-Agent", "")
7875
}
76+
req.Header.Set("Accept-Encoding", "") // we can't handle other than plain text
77+
// caddy.Log().Sugar().Infof("director request (mod): %v", req.URL.String())
7978
}
80-
proxy := httputil.ReverseProxy{Director: director}
79+
proxy := httputil.ReverseProxy{Director: director, Transport: &RedirectingTransport{baseUrl: devURL}}
8180
proxy.ServeHTTP(w, r)
8281

8382
return nil
8483
}
8584

86-
func joinURLPath(a, b *url.URL) (path, rawpath string) {
87-
if a.RawPath == "" && b.RawPath == "" {
88-
return singleJoiningSlash(a.Path, b.Path), ""
85+
type RedirectingTransport struct {
86+
baseUrl *url.URL
87+
}
88+
89+
func (rt *RedirectingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
90+
// caddy.Log().Sugar().Infof("issuing upstream request: %s", req.URL.Path)
91+
resp, err := http.DefaultTransport.RoundTrip(req)
92+
if err != nil {
93+
return nil, err
8994
}
90-
// Same as singleJoiningSlash, but uses EscapedPath to determine
91-
// whether a slash should be added
92-
apath := a.EscapedPath()
93-
bpath := b.EscapedPath()
94-
95-
aslash := strings.HasSuffix(apath, "/")
96-
bslash := strings.HasPrefix(bpath, "/")
97-
98-
switch {
99-
case aslash && bslash:
100-
return a.Path + b.Path[1:], apath + bpath[1:]
101-
case !aslash && !bslash:
102-
return a.Path + "/" + b.Path, apath + "/" + bpath
95+
96+
// gpl: Do we have better means to avoid checking the body?
97+
if resp.StatusCode < 300 && strings.HasPrefix(resp.Header.Get("Content-type"), "text/html") {
98+
// caddy.Log().Sugar().Infof("trying to match request: %s", req.URL.Path)
99+
modifiedResp := MatchAndRewriteRootRequest(resp, rt.baseUrl)
100+
if modifiedResp != nil {
101+
caddy.Log().Sugar().Debugf("using modified upstream response: %s", req.URL.Path)
102+
return modifiedResp, nil
103+
}
103104
}
104-
return a.Path + b.Path, apath + bpath
105+
caddy.Log().Sugar().Debugf("forwarding upstream response: %s", req.URL.Path)
106+
107+
return resp, nil
105108
}
106109

107-
func singleJoiningSlash(a, b string) string {
108-
aslash := strings.HasSuffix(a, "/")
109-
bslash := strings.HasPrefix(b, "/")
110-
switch {
111-
case aslash && bslash:
112-
return a + b[1:]
113-
case !aslash && !bslash:
114-
return a + "/" + b
110+
func MatchAndRewriteRootRequest(or *http.Response, baseUrl *url.URL) *http.Response {
111+
// match index.html?
112+
prefix := []byte("<!doctype html>")
113+
var buf bytes.Buffer
114+
bodyReader := io.TeeReader(or.Body, &buf)
115+
prefixBuf := make([]byte, len(prefix))
116+
_, err := io.ReadAtLeast(bodyReader, prefixBuf, len(prefix))
117+
if err != nil {
118+
caddy.Log().Sugar().Debugf("prefix match: can't read response body: %w", err)
119+
return nil
120+
}
121+
if !bytes.Equal(prefix, prefixBuf) {
122+
caddy.Log().Sugar().Debugf("prefix mismatch: %s", string(prefixBuf))
123+
return nil
115124
}
116-
return a + b
125+
126+
caddy.Log().Sugar().Debugf("match index.html")
127+
_, err = io.Copy(&buf, or.Body)
128+
if err != nil {
129+
caddy.Log().Sugar().Debugf("unable to copy response body: %w, path: %s", err, or.Request.URL.Path)
130+
return nil
131+
}
132+
fullBody := buf.String()
133+
134+
mainJs := regexp.MustCompile(`"[^"]+?main\.[0-9a-z]+\.js"`)
135+
fullBody = mainJs.ReplaceAllStringFunc(fullBody, func(s string) string {
136+
return fmt.Sprintf(`"%s/static/js/main.js"`, baseUrl.String())
137+
})
138+
139+
mainCss := regexp.MustCompile(`<link[^>]+?rel="stylesheet">`)
140+
fullBody = mainCss.ReplaceAllString(fullBody, "")
141+
142+
hrefs := regexp.MustCompile(`href="/`)
143+
fullBody = hrefs.ReplaceAllString(fullBody, fmt.Sprintf(`href="%s/`, baseUrl.String()))
144+
145+
or.Body = io.NopCloser(strings.NewReader(fullBody))
146+
or.Header.Set("Content-Length", fmt.Sprintf("%d", len(fullBody)))
147+
or.Header.Set("Etag", "")
148+
return or
117149
}
118150

119151
// UnmarshalCaddyfile implements Caddyfile.Unmarshaler.
120-
func (m *Config) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
152+
func (m *FrontendDev) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
153+
if !d.Next() {
154+
return d.Err("expected token following filter")
155+
}
156+
157+
for d.NextBlock(0) {
158+
key := d.Val()
159+
var value string
160+
d.Args(&value)
161+
if d.NextArg() {
162+
return d.ArgErr()
163+
}
164+
165+
switch key {
166+
case "upstream":
167+
m.Upstream = value
168+
169+
default:
170+
return d.Errf("unrecognized subdirective '%s'", value)
171+
}
172+
}
173+
174+
if m.Upstream == "" {
175+
return fmt.Errorf("frontend_dev: 'upstream' config field may not be empty")
176+
}
177+
178+
upstreamURL, err := url.Parse(m.Upstream)
179+
if err != nil {
180+
return fmt.Errorf("frontend_dev: 'upstream' is not a valid URL: %w", err)
181+
}
182+
m.UpstreamUrl = upstreamURL
121183

122184
return nil
123185
}
124186

125187
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
126-
m := new(Config)
188+
m := new(FrontendDev)
127189
err := m.UnmarshalCaddyfile(h.Dispenser)
128190
if err != nil {
129191
return nil, err
@@ -134,6 +196,6 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
134196

135197
// Interface guards
136198
var (
137-
_ caddyhttp.MiddlewareHandler = (*Config)(nil)
138-
_ caddyfile.Unmarshaler = (*Config)(nil)
199+
_ caddyhttp.MiddlewareHandler = (*FrontendDev)(nil)
200+
_ caddyfile.Unmarshaler = (*FrontendDev)(nil)
139201
)

0 commit comments

Comments
 (0)