diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 0e0b521a..338843db 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -62,13 +62,13 @@ func start(packagesFile, addr, goRoot string, debug bool) error { r := mux.NewRouter() langserver.New(packages).Mount(r.PathPrefix("/api").Subrouter()) - r.PathPrefix("/").Handler(http.FileServer(http.Dir("./public"))) + r.PathPrefix("/").Handler(langserver.SpaFileServer("./public")) zap.S().Infof("Listening on %q", addr) var handler http.Handler if debug { - zap.S().Warn("Debug mode enabled, CORS disabled") + zap.S().Info("Debug mode enabled, CORS disabled") handler = langserver.NewCORSDisablerWrapper(r) } else { handler = r diff --git a/docker.mk b/docker.mk index 125c4e5a..b2921005 100644 --- a/docker.mk +++ b/docker.mk @@ -1,6 +1,5 @@ DOCKERFILE ?= ./build/Dockerfile IMG_NAME ?= x1unix/go-playground -TAG ?= 1.0.0 .PHONY: docker docker: docker-login docker-make-image @@ -20,5 +19,8 @@ docker-login: .PHONY: docker-make-image docker-make-image: + @if [ -z "$(TAG)" ]; then\ + echo "required parameter TAG is undefined" && exit 1; \ + fi; @echo "- Building '$(IMG_NAME):latest' $(TAG)..." docker image build -t $(IMG_NAME):latest -t $(IMG_NAME):$(TAG) -f $(DOCKERFILE) . \ No newline at end of file diff --git a/pkg/analyzer/decl.go b/pkg/analyzer/decl.go index 95be91c4..0b314a02 100644 --- a/pkg/analyzer/decl.go +++ b/pkg/analyzer/decl.go @@ -10,7 +10,7 @@ func formatFieldAndType(t ast.Expr, id *ast.Ident) string { return id.String() + " " + typeStr } -func formatFuncParams(params *ast.FieldList) (string, int) { +func formatFieldsList(params *ast.FieldList, joinChar string) (string, int) { if params == nil { return "", 0 } @@ -30,7 +30,7 @@ func formatFuncParams(params *ast.FieldList) (string, int) { } } - return strings.Join(fieldTypePair, ", "), paramsLen + return strings.Join(fieldTypePair, joinChar), paramsLen } func valSpecToItem(isConst bool, v *ast.ValueSpec, withPrivate bool) []*CompletionItem { @@ -61,26 +61,30 @@ func valSpecToItem(isConst bool, v *ast.ValueSpec, withPrivate bool) []*Completi return items } -func funcToItem(fn *ast.FuncDecl) *CompletionItem { - ci := &CompletionItem{ - Label: fn.Name.String(), - Kind: Function, - Documentation: fn.Doc.Text(), - } - - params, _ := formatFuncParams(fn.Type.Params) - ci.Detail = "func(" + params + ")" - ci.InsertText = ci.Label + "(" + params + ")" - - returns, retCount := formatFuncParams(fn.Type.Results) +func funcToString(fn *ast.FuncType) string { + params, _ := formatFieldsList(fn.Params, ", ") + str := "func(" + params + ")" + returns, retCount := formatFieldsList(fn.Results, ", ") switch retCount { case 0: break case 1: - ci.Detail += " " + returns + str += " " + returns default: - ci.Detail += " (" + returns + ")" + str += " (" + returns + ")" + } + + return str +} + +func funcToItem(fn *ast.FuncDecl) *CompletionItem { + ci := &CompletionItem{ + Label: fn.Name.String(), + Kind: Function, + Documentation: fn.Doc.Text(), } + ci.Detail = funcToString(fn.Type) + ci.InsertText = ci.Label + "()" return ci } diff --git a/pkg/analyzer/expr.go b/pkg/analyzer/expr.go index 76d516eb..853f2346 100644 --- a/pkg/analyzer/expr.go +++ b/pkg/analyzer/expr.go @@ -23,6 +23,24 @@ func expToString(exp ast.Expr) string { return v.Sel.String() case *ast.StarExpr: return "*" + expToString(v.X) + case *ast.Ellipsis: + return "..." + expToString(v.Elt) + case *ast.MapType: + keyT := expToString(v.Key) + valT := expToString(v.Value) + return "map[" + keyT + "]" + valT + case *ast.ChanType: + chanT := expToString(v.Value) + return "chan " + chanT + case *ast.InterfaceType: + typ := "interface{" + fields, fieldCount := formatFieldsList(v.Methods, "\n") + if fieldCount > 0 { + typ += "\n" + fields + "\n" + } + return typ + "}" + case *ast.FuncType: + return funcToString(v) default: log.Warnf("expToString: unknown expression - [%[1]T %[1]v]", exp) return "interface{}" diff --git a/pkg/goplay/client.go b/pkg/goplay/client.go index 89486ade..dba3113e 100644 --- a/pkg/goplay/client.go +++ b/pkg/goplay/client.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "go.uber.org/zap" "io" "net/http" "net/url" @@ -22,29 +21,47 @@ const ( var ErrSnippetTooLarge = fmt.Errorf("code snippet too large (max %d bytes)", maxSnippetSize) +func newRequest(ctx context.Context, method, queryPath string, body io.Reader) (*http.Request, error) { + uri := goPlayURL + "/" + queryPath + req, err := http.NewRequestWithContext(ctx, method, uri, body) + if err != nil { + return nil, err + } + + req.Header.Add("User-Agent", userAgent) + return req, nil +} + +func getRequest(ctx context.Context, queryPath string) (*http.Response, error) { + req, err := newRequest(ctx, http.MethodGet, queryPath, nil) + if err != nil { + return nil, nil + } + + return http.DefaultClient.Do(req) +} + func doRequest(ctx context.Context, method, url, contentType string, body io.Reader) ([]byte, error) { - url = goPlayURL + "/" + url - zap.S().Debug("doRequest ", url) - req, err := http.NewRequestWithContext(ctx, method, url, body) + req, err := newRequest(ctx, method, url, body) if err != nil { return nil, err } req.Header.Add("Content-Type", contentType) - req.Header.Add("User-Agent", userAgent) response, err := http.DefaultClient.Do(req) if err != nil { return nil, err } - var bodyBytes bytes.Buffer - _, err = io.Copy(&bodyBytes, io.LimitReader(response.Body, maxSnippetSize+1)) + bodyBytes := &bytes.Buffer{} + _, err = io.Copy(bodyBytes, io.LimitReader(response.Body, maxSnippetSize+1)) defer response.Body.Close() if err != nil { return nil, err } - if bodyBytes.Len() > maxSnippetSize { - return nil, ErrSnippetTooLarge + if err = ValidateContentLength(bodyBytes); err != nil { + return nil, err } + return bodyBytes.Bytes(), nil } diff --git a/pkg/goplay/errors.go b/pkg/goplay/errors.go new file mode 100644 index 00000000..92af7464 --- /dev/null +++ b/pkg/goplay/errors.go @@ -0,0 +1,18 @@ +package goplay + +import "errors" + +var ErrSnippetNotFound = errors.New("snippet not found") + +type CompileFailedError struct { + msg string +} + +func (c CompileFailedError) Error() string { + return c.msg +} + +func IsCompileError(err error) bool { + _, ok := err.(CompileFailedError) + return ok +} diff --git a/pkg/goplay/methods.go b/pkg/goplay/methods.go index bc72e078..8802600e 100644 --- a/pkg/goplay/methods.go +++ b/pkg/goplay/methods.go @@ -5,9 +5,60 @@ import ( "encoding/json" "fmt" "go.uber.org/zap" + "io" + "io/ioutil" + "net/http" "net/url" ) +type lener interface { + Len() int +} + +func ValidateContentLength(r lener) error { + if r.Len() > maxSnippetSize { + return ErrSnippetTooLarge + } + + return nil +} + +func GetSnippet(ctx context.Context, snippetID string) (*Snippet, error) { + fileName := snippetID + ".go" + resp, err := getRequest(ctx, "p/"+fileName) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + snippet, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return &Snippet{ + FileName: fileName, + Contents: string(snippet), + }, nil + case http.StatusNotFound: + return nil, ErrSnippetNotFound + default: + return nil, fmt.Errorf("error from Go Playground server - %d %s", resp.StatusCode, resp.Status) + } +} + +func Share(ctx context.Context, src io.Reader) (string, error) { + resp, err := doRequest(ctx, http.MethodPost, "share", "text/plain", src) + if err != nil { + return "", err + } + + shareID := string(resp) + return shareID, nil +} + func GoImports(ctx context.Context, src []byte) (*FmtResponse, error) { form := url.Values{} form.Add("imports", "true") diff --git a/pkg/goplay/types.go b/pkg/goplay/types.go index 8ffdc4c4..b4007603 100644 --- a/pkg/goplay/types.go +++ b/pkg/goplay/types.go @@ -5,6 +5,12 @@ import ( "time" ) +// Snippet represents shared snippet +type Snippet struct { + FileName string + Contents string +} + // FmtResponse is the response returned from // upstream play.golang.org/fmt request type FmtResponse struct { @@ -17,7 +23,7 @@ func (r *FmtResponse) HasError() error { return nil } - return errors.New(r.Error) + return CompileFailedError{msg: r.Error} } // CompileEvent represents individual diff --git a/pkg/langserver/request.go b/pkg/langserver/request.go index 7b62caf1..8078a000 100644 --- a/pkg/langserver/request.go +++ b/pkg/langserver/request.go @@ -13,6 +13,15 @@ import ( "github.com/x1unix/go-playground/pkg/analyzer" ) +type SnippetResponse struct { + FileName string `json:"fileName"` + Code string `json:"code"` +} + +type ShareResponse struct { + SnippetID string `json:"snippetID"` +} + type SuggestionRequest struct { PackageName string `json:"packageName"` Value string `json:"value"` diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index f6f54963..9d587b52 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -27,6 +27,8 @@ func (s *Service) Mount(r *mux.Router) { r.Path("/suggest").HandlerFunc(s.GetSuggestion) r.Path("/compile").Methods(http.MethodPost).HandlerFunc(s.Compile) r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.FormatCode) + r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.Share) + r.Path("/snippet/{id}").Methods(http.MethodGet).HandlerFunc(s.GetSnippet) } func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { @@ -107,7 +109,7 @@ func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, } if err = resp.HasError(); err != nil { - Errorf(http.StatusBadRequest, err.Error()) + Errorf(http.StatusBadRequest, err.Error()).Write(w) return nil, err, false } @@ -118,6 +120,10 @@ func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { code, err, _ := s.goImportsCode(w, r) if err != nil { + if goplay.IsCompileError(err) { + return + } + s.log.Error(err) return } @@ -125,9 +131,53 @@ func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { WriteJSON(w, CompilerResponse{Formatted: string(code)}) } +func (s *Service) Share(w http.ResponseWriter, r *http.Request) { + shareID, err := goplay.Share(r.Context(), r.Body) + defer r.Body.Close() + if err != nil { + if err == goplay.ErrSnippetTooLarge { + Errorf(http.StatusRequestEntityTooLarge, err.Error()).Write(w) + return + } + + s.log.Error("failed to share code: ", err) + NewErrorResponse(err).Write(w) + } + + WriteJSON(w, ShareResponse{SnippetID: shareID}) +} + +func (s *Service) GetSnippet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + snippetID := vars["id"] + snippet, err := goplay.GetSnippet(r.Context(), snippetID) + if err != nil { + if err == goplay.ErrSnippetNotFound { + Errorf(http.StatusNotFound, "snippet %q not found", snippetID).Write(w) + return + } + + s.log.Errorw("failed to get snippet", + "snippetID", snippetID, + "err", err, + ) + NewErrorResponse(err).Write(w) + return + } + + WriteJSON(w, SnippetResponse{ + FileName: snippet.FileName, + Code: snippet.Contents, + }) +} + func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { src, err, changed := s.goImportsCode(w, r) if err != nil { + if goplay.IsCompileError(err) { + return + } + s.log.Error(err) return } @@ -149,6 +199,6 @@ func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { result.Formatted = string(src) } - s.log.Debugw("resp from compiler", "res", res) + s.log.Debugw("response from compiler", "res", res) WriteJSON(w, result) } diff --git a/pkg/langserver/spa.go b/pkg/langserver/spa.go new file mode 100644 index 00000000..5abb689b --- /dev/null +++ b/pkg/langserver/spa.go @@ -0,0 +1,70 @@ +package langserver + +import ( + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +// Advanced static server +type spaFileServer struct { + root http.Dir + NotFoundHandler func(http.ResponseWriter, *http.Request) +} + +func (fs *spaFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + if containsDotDot(r.URL.Path) { + Errorf(http.StatusBadRequest, "Bad Request").Write(w) + return + } + + //if empty, set current directory + dir := string(fs.root) + if dir == "" { + dir = "." + } + + //add prefix and clean + upath := r.URL.Path + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + r.URL.Path = upath + } + upath = path.Clean(upath) + + //path to file + name := path.Join(dir, filepath.FromSlash(upath)) + + //check if file exists + f, err := os.Open(name) + if err != nil { + if os.IsNotExist(err) { + http.ServeFile(w, r, string(fs.root)+"/index.html") + return + } + } + defer f.Close() + + http.ServeFile(w, r, name) +} + +func containsDotDot(v string) bool { + if !strings.Contains(v, "..") { + return false + } + for _, ent := range strings.FieldsFunc(v, isSlashRune) { + if ent == ".." { + return true + } + } + return false +} + +func isSlashRune(r rune) bool { return r == '/' || r == '\\' } + +func SpaFileServer(root http.Dir) http.Handler { + return &spaFileServer{root: root} +} diff --git a/web/package.json b/web/package.json index b99c8044..d0c554d4 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "@testing-library/user-event": "^7.1.2", "axios": "^0.19.1", "circular-dependency-plugin": "^5.2.0", + "connected-react-router": "^6.6.1", "file-saver": "^2.0.2", "monaco-editor": "^0.19.3", "monaco-editor-webpack-plugin": "^1.8.2", @@ -17,8 +18,10 @@ "react-dom": "^16.12.0", "react-monaco-editor": "^0.33.0", "react-redux": "^7.1.3", + "react-router-dom": "^5.1.2", "react-scripts": "3.3.0", "redux": "^4.0.5", + "redux-thunk": "^2.3.0", "typescript": "^3.7.4" }, "scripts": { diff --git a/web/public/index.html b/web/public/index.html index 4f18b394..f919bf47 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -7,7 +7,7 @@