diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92b18211..88b419aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,21 +2,21 @@ # Better Go Playground Docker image deployment workflow # # Builds and deploys image to Docker hub when a new release created. -# Uses DOCKER_USER, DOCKER_PASS and GTAG env vars from GitHub repo secrets. +# Uses DOCKER_USER and DOCKER_PASS env vars from GitHub repo secrets. # # see: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions # -name: Deploy To Docker Hub +name: Build Docker Image on: release: types: - created repository_dispatch: - types: manual-deploy + types: manual-build jobs: - deploy: + build: runs-on: ubuntu-latest steps: - id: go-cache-paths @@ -42,8 +42,8 @@ jobs: run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV - name: Build Docker image run: | - make docker-image TAG=${{ env.RELEASE_VERSION }} GTAG=${{ secrets.GTAG }} - - name: Deploy image to Docker hub + make docker-image TAG=${{ env.RELEASE_VERSION }} + - name: Push image to Docker hub run: | make docker-push-image TAG=${{ env.RELEASE_VERSION }} \ DOCKER_USER=${{ secrets.DOCKER_USER }} \ diff --git a/Makefile b/Makefile index 61cb2d72..c9ee5746 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,16 @@ include docker.mk .PHONY:run run: - @GOROOT=$(GOROOT) $(GO) run $(PKG) -f ./data/packages.json -static-dir="$(UI)/build" -debug=$(DEBUG) -addr $(LISTEN_ADDR) + @GOROOT=$(GOROOT) $(GO) run $(PKG) \ + -f ./data/packages.json \ + -static-dir="$(UI)/build" \ + -gtag-id="$(GTAG)" \ + -debug=$(DEBUG) \ + -addr $(LISTEN_ADDR) .PHONY:ui ui: - @cd $(UI) && REACT_APP_LANG_SERVER=http://$(LISTEN_ADDR) REACT_APP_GTAG=$(GTAG) REACT_APP_VERSION=testing yarn start + @cd $(UI) && REACT_APP_LANG_SERVER='//$(LISTEN_ADDR)' REACT_APP_VERSION=testing yarn start .PHONY: cover cover: diff --git a/build/Dockerfile b/build/Dockerfile index 10eed988..88b73f11 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -2,10 +2,9 @@ FROM node:17-alpine as ui-build COPY web /tmp/web WORKDIR /tmp/web ARG APP_VERSION=1.0.0 -ARG APP_GTAG="" -ARG GITHUB_URL=https://github.com/x1unix/go-playground +ARG GITHUB_URL='https://github.com/x1unix/go-playground' RUN yarn install --silent && \ - REACT_APP_VERSION=$APP_VERSION REACT_APP_GITHUB_URL=$GITHUB_URL REACT_APP_GTAG=$APP_GTAG yarn build + REACT_APP_VERSION=$APP_VERSION NODE_ENV=production REACT_APP_GITHUB_URL=$GITHUB_URL yarn build FROM golang:1.17-alpine as build WORKDIR /tmp/playground @@ -26,11 +25,18 @@ ENV APP_CLEAN_INTERVAL=10m ENV APP_DEBUG=false ENV APP_PLAYGROUND_URL='https://play.golang.org' ENV APP_GOTIP_URL='https://gotipplay.golang.org' +ENV APP_GTAG_ID='' COPY data ./data COPY --from=ui-build /tmp/web/build ./public COPY --from=build /tmp/playground/server . COPY --from=build /tmp/playground/worker.wasm ./public COPY --from=build /tmp/playground/wasm_exec.js ./public EXPOSE 8000 -ENTRYPOINT /opt/playground/server -f=/opt/playground/data/packages.json -addr=:8000 \ - -clean-interval=${APP_CLEAN_INTERVAL} -debug=${APP_DEBUG} -playground-url=${APP_PLAYGROUND_URL} -gotip-url=${APP_GOTIP_URL} \ No newline at end of file +ENTRYPOINT /opt/playground/server \ + -f='/opt/playground/data/packages.json' \ + -clean-interval="${APP_CLEAN_INTERVAL}" \ + -debug="${APP_DEBUG}" \ + -playground-url="${APP_PLAYGROUND_URL}" \ + -gotip-url="${APP_GOTIP_URL}" \ + -gtag-id="${APP_GTAG_ID}" \ + -addr=:8000 \ No newline at end of file diff --git a/cmd/playground/main.go b/cmd/playground/main.go index ea825d81..698c745b 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "github.com/x1unix/go-playground/pkg/langserver/webutil" "net/http" "os" "path/filepath" @@ -33,6 +34,7 @@ type appArgs struct { cleanupInterval string assetsDirectory string connectTimeout time.Duration + googleAnalyticsID string } func (a appArgs) getCleanDuration() (time.Duration, error) { @@ -56,6 +58,7 @@ func main() { flag.BoolVar(&args.debug, "debug", false, "Enable debug mode") flag.StringVar(&args.assetsDirectory, "static-dir", filepath.Join(wd, "public"), "Path to web page assets (HTML, JS, etc)") flag.DurationVar(&args.connectTimeout, "timeout", 15*time.Second, "Go Playground server connect timeout") + flag.StringVar(&args.googleAnalyticsID, "gtag-id", "", "Google Analytics tag ID (optional)") l := getLogger(args.debug) defer l.Sync() //nolint:errcheck @@ -123,12 +126,24 @@ func start(goRoot string, args appArgs) error { GoTip: goTipClient, } // API routes - langserver.New(Version, clients, packages, compiler.NewBuildService(zap.S(), store)). + svcCfg := langserver.ServiceConfig{ + Version: Version, + } + langserver.New(svcCfg, clients, packages, compiler.NewBuildService(zap.S(), store)). Mount(r.PathPrefix("/api").Subrouter()) // Web UI routes - indexHandler := langserver.NewIndexFileServer(args.assetsDirectory) - spaHandler := langserver.NewSpaFileServer(args.assetsDirectory) + tplVars := langserver.TemplateArguments{ + GoogleTagID: args.googleAnalyticsID, + } + if err := webutil.ValidateGTag(tplVars.GoogleTagID); err != nil { + zap.L().Error("invalid GTag ID value, parameter will be ignored", + zap.String("gtag", tplVars.GoogleTagID), zap.Error(err)) + tplVars.GoogleTagID = "" + } + + indexHandler := langserver.NewTemplateFileServer(zap.L(), filepath.Join(args.assetsDirectory, langserver.IndexFileName), tplVars) + spaHandler := langserver.NewSpaFileServer(args.assetsDirectory, tplVars) r.Path("/"). Handler(indexHandler) r.Path("/snippet/{snippetID:[A-Za-z0-9_-]+}"). diff --git a/docker.mk b/docker.mk index 9664c24c..ba43e1fc 100644 --- a/docker.mk +++ b/docker.mk @@ -32,4 +32,4 @@ docker-image: @echo ":: Building '$(IMG_NAME):latest' $(TAG)..." docker image build -t $(IMG_NAME):latest -t $(IMG_NAME):$(TAG) -f $(DOCKERFILE) \ --build-arg APP_VERSION=$(TAG) \ - --build-arg APP_GTAG=$(GTAG) . + . diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index e4a0a4c2..f3541c41 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -38,7 +38,7 @@ type PlaygroundServices struct { // Service is language server service type Service struct { - version string + config ServiceConfig log *zap.SugaredLogger index analyzer.PackageIndex compiler compiler.BuildService @@ -46,11 +46,15 @@ type Service struct { limiter *rate.Limiter } +type ServiceConfig struct { + Version string +} + // New is Service constructor -func New(version string, playgrounds *PlaygroundServices, packages []*analyzer.Package, builder compiler.BuildService) *Service { +func New(cfg ServiceConfig, playgrounds *PlaygroundServices, packages []*analyzer.Package, builder compiler.BuildService) *Service { return &Service{ + config: cfg, compiler: builder, - version: version, playgrounds: playgrounds, log: zap.S().Named("langserver"), index: analyzer.BuildPackageIndex(packages), @@ -131,7 +135,7 @@ func (s *Service) provideSuggestion(req SuggestionRequest) (*SuggestionsResponse // HandleGetVersion handles /api/version func (s *Service) HandleGetVersion(w http.ResponseWriter, _ *http.Request) error { - WriteJSON(w, VersionResponse{Version: s.version}) + WriteJSON(w, VersionResponse{Version: s.config.Version}) return nil } diff --git a/pkg/langserver/spa.go b/pkg/langserver/spa.go index 6cb68f88..a264713d 100644 --- a/pkg/langserver/spa.go +++ b/pkg/langserver/spa.go @@ -22,25 +22,11 @@ func (i httpStatusInterceptor) WriteHeader(_ int) { i.ResponseWriter.WriteHeader(i.desiredStatus) } -type IndexFileServer struct { - indexFilePath string -} - -// NewIndexFileServer returns handler which serves index.html page from root. -func NewIndexFileServer(root string) *IndexFileServer { - return &IndexFileServer{ - indexFilePath: filepath.Join(string(root), IndexFileName), - } -} - -func (fs IndexFileServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - http.ServeFile(rw, r, fs.indexFilePath) -} - // SpaFileServer is a wrapper around http.FileServer for serving SPA contents. type SpaFileServer struct { root string NotFoundHandler http.Handler + templateVars TemplateArguments } // ServeHTTP implements http.Handler @@ -93,11 +79,12 @@ func containsDotDot(v string) bool { func isSlashRune(r rune) bool { return r == '/' || r == '\\' } // NewSpaFileServer returns SPA handler -func NewSpaFileServer(root string) *SpaFileServer { +func NewSpaFileServer(root string, tplVars TemplateArguments) *SpaFileServer { notFoundHandler := NewFileServerWithStatus(filepath.Join(root, NotFoundFileName), http.StatusNotFound) return &SpaFileServer{ NotFoundHandler: notFoundHandler, root: root, + templateVars: tplVars, } } diff --git a/pkg/langserver/tmphandler.go b/pkg/langserver/tmphandler.go new file mode 100644 index 00000000..baba6316 --- /dev/null +++ b/pkg/langserver/tmphandler.go @@ -0,0 +1,94 @@ +package langserver + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "text/template" + "time" + + "go.uber.org/zap" +) + +type nopRSeeker struct { + size int64 + io.Reader +} + +func newNopRSeeker(size int, r io.Reader) *nopRSeeker { + return &nopRSeeker{ + size: int64(size), + Reader: r, + } +} + +type TemplateArguments struct { + GoogleTagID string +} + +type TemplateFileServer struct { + log *zap.Logger + filePath string + templateVars TemplateArguments + once *sync.Once + buffer io.ReadSeeker + modTime time.Time +} + +// Seek implements io.Seeker +func (n nopRSeeker) Seek(_ int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + return 0, nil + default: + return n.size, nil + } +} + +// NewTemplateFileServer returns handler which compiles and serves HTML page template. +func NewTemplateFileServer(logger *zap.Logger, filePath string, tplVars TemplateArguments) *TemplateFileServer { + return &TemplateFileServer{ + log: logger, + once: new(sync.Once), + filePath: filePath, + templateVars: tplVars, + } +} + +func (fs *TemplateFileServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + fs.once.Do(fs.precompileTemplate) + + if fs.buffer == nil { + http.ServeFile(rw, r, fs.filePath) + return + } + + http.ServeContent(rw, r, fs.filePath, fs.modTime, fs.buffer) +} + +func (fs *TemplateFileServer) precompileTemplate() { + stat, err := os.Stat(fs.filePath) + if err != nil { + fs.log.Error("failed to read template file", zap.Error(err), zap.String("filePath", fs.filePath)) + return + } + + tpl, err := template.New(filepath.Base(fs.filePath)).ParseFiles(fs.filePath) + if err != nil { + fs.log.Error("failed to parse page template", zap.Error(err), zap.String("filePath", fs.filePath)) + return + } + + buff := bytes.NewBuffer(make([]byte, 0, stat.Size())) + if err := tpl.Execute(buff, fs.templateVars); err != nil { + fs.log.Error("failed to execute page template", zap.Error(err), zap.String("filePath", fs.filePath)) + return + } + + fs.log.Info("successfully compiled page template", zap.String("filePath", fs.filePath)) + fs.buffer = newNopRSeeker(buff.Len(), buff) + fs.modTime = time.Now() +} diff --git a/pkg/langserver/webutil/gtag.go b/pkg/langserver/webutil/gtag.go new file mode 100644 index 00000000..d625190f --- /dev/null +++ b/pkg/langserver/webutil/gtag.go @@ -0,0 +1,16 @@ +package webutil + +import ( + "errors" + "regexp" +) + +var gtagRegExp = regexp.MustCompile(`(?i)^[A-Z]{2}-[A-Z0-9\-\+]+$`) + +// ValidateGTag validates Google Analytics tag ID +func ValidateGTag(gtag string) error { + if !gtagRegExp.MatchString(gtag) { + return errors.New("invalid GTag ID value") + } + return nil +} diff --git a/pkg/langserver/webutil/gtag_test.go b/pkg/langserver/webutil/gtag_test.go new file mode 100644 index 00000000..9c7f89f3 --- /dev/null +++ b/pkg/langserver/webutil/gtag_test.go @@ -0,0 +1,26 @@ +package webutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateGTag(t *testing.T) { + cases := map[string]bool{ + "EN-123456789-0": true, + `EN-123456789-0"/>`: false, + `EN-12345/foobar.js`: false, + `foo.js`: false, + } + for input, expect := range cases { + t.Run(input, func(t *testing.T) { + err := ValidateGTag(input) + if !expect { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/tools/cover.txt b/tools/cover.txt index be90f522..2c76937e 100644 --- a/tools/cover.txt +++ b/tools/cover.txt @@ -1,3 +1,4 @@ ./pkg/compiler/... ./pkg/analyzer/... -./pkg/goplay/... \ No newline at end of file +./pkg/goplay/... +./pkg/langserver/webutil diff --git a/web/public/index.html b/web/public/index.html index 1581d411..89cc6145 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -6,7 +6,6 @@ - @@ -17,23 +16,17 @@
+ <% if (process.env.NODE_ENV === 'production') { %> + {{ if .GoogleTagID }} + + {{ end }} + <% } %> \ No newline at end of file