├── .editorconfig ├── .env ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── config └── config.go ├── logger └── logger.go ├── main.go └── parser └── parser.go /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | indent_style = space 4 | 5 | [*.go] 6 | indent_size = 4 7 | indent_style = tab -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | HYPERNOVA_BATCH=http://hypernova:3000/batch 2 | CONFIG_FILE=config.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | build -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.8 2 | 3 | WORKDIR /go/src/app 4 | 5 | COPY main.go main.go 6 | 7 | RUN go get -d -v ./... 8 | RUN go install -v ./... 9 | 10 | CMD ["app"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Felipe Guizar Diaz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nova Proxy 2 | Hypernova Proxy is an Reverse Proxy whick look in the hosts responses for [Hypernova Directives](https://github.com/marconi1992/hypernova-blade-directive) in order to inject the components rendered by [Hypernova Server](https://github.com/airbnb/hypernova). 3 | 4 | ## Environment Variables 5 | 6 | ```env 7 | HYPERNOVA_BATCH=http://hypernova:3000/batch 8 | CONFIG_FILE=config.json 9 | ``` 10 | 11 | ## Configuration File 12 | 13 | Nova Proxy needs a configuration file: 14 | 15 | ```json 16 | //nova-proxy.json 17 | 18 | { 19 | "locations": [ 20 | { 21 | "path": "/", 22 | "host": "http://blog:8000", 23 | "modifyResponse": true 24 | } 25 | ] 26 | } 27 | ``` 28 | 29 | The `locations` items require the `path` and `host` to let know to Nova Proxy which the application is responsible to serve the requested page. By default the path `/` passes all the requests to the declared host. 30 | 31 | The `modifyResponse` enable the serve-side includes to that location. 32 | 33 | ## Using Nova Proxy with [Ara CLI](https://github.com/ara-framework/ara-cli) 34 | 35 | Before to run the command we need to set the `HYPERNOVA_BATCH` variable using the Nova service endpoint. 36 | 37 | ```shell 38 | export HYPERNOVA_BATCH=http://localhost:3000/batch 39 | ``` 40 | 41 | The command uses a configuration file named `nova-proxy.json` in the folder where the command is running, otherwise you need to pass the `--config` parameter with a different path. 42 | ``` 43 | ara run:proxy --config ./nova-proxy.json 44 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | package_name="nova-proxy" 3 | 4 | 5 | platforms=("windows/amd64" "windows/386" "darwin/amd64" "linux/amd64") 6 | 7 | for platform in "${platforms[@]}" 8 | do 9 | platform_split=(${platform//\// }) 10 | GOOS=${platform_split[0]} 11 | GOARCH=${platform_split[1]} 12 | output_name=$package_name'-'$GOOS'-'$GOARCH 13 | if [ $GOOS = "windows" ]; then 14 | output_name+='.exe' 15 | fi 16 | 17 | env GOOS=$GOOS GOARCH=$GOARCH go build -o build/$output_name 18 | if [ $? -ne 0 ]; then 19 | echo 'An error has occurred! Aborting the script execution...' 20 | exit 1 21 | fi 22 | done -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "os" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/ara-framework/nova-proxy/logger" 14 | "github.com/ara-framework/nova-proxy/parser" 15 | env "github.com/joho/godotenv" 16 | ) 17 | 18 | type location struct { 19 | Path string 20 | Host string 21 | ModifyResponse bool 22 | } 23 | 24 | type configuration struct { 25 | Locations []location 26 | } 27 | 28 | var jsonConfig configuration 29 | var origin *url.URL 30 | 31 | // LoadEnv should load .env file 32 | func LoadEnv() { 33 | env.Load() 34 | } 35 | 36 | // ReadConfigFile should initialize once jsonConfig 37 | func ReadConfigFile() { 38 | // logger should stop execution if there is no file found 39 | e, err := ioutil.ReadFile(os.Getenv("CONFIG_FILE")) 40 | logger.Error(err, "Config file not found") 41 | 42 | err = json.Unmarshal(e, &jsonConfig) 43 | logger.Fatal(err, "Unable to parse " + os.Getenv("CONFIG_FILE")) 44 | 45 | } 46 | 47 | // SetUpLocations should add handlers for config.json locations 48 | func SetUpLocations() error { 49 | for _, location := range jsonConfig.Locations { 50 | origin, err := url.Parse(location.Host) 51 | logger.Fatal(err, "Malformed Host field ", location.Host) 52 | 53 | proxy := httputil.NewSingleHostReverseProxy(origin) 54 | if location.ModifyResponse { 55 | proxy.ModifyResponse = modifyResponse 56 | proxy.Director = modifyRequest(origin) 57 | } 58 | http.Handle(location.Path, proxy) 59 | } 60 | return nil 61 | } 62 | 63 | func isValidHeader(r *http.Response) bool { 64 | contentType := r.Header.Get("Content-Type") 65 | return strings.HasPrefix(contentType, "text/html") 66 | } 67 | 68 | func modifyResponse(r *http.Response) error { 69 | if !isValidHeader(r) { 70 | return nil 71 | } 72 | 73 | html, err := ioutil.ReadAll(r.Body) 74 | if err != nil { 75 | logger.Error(err, "Malformed HTML in Content Body") 76 | return err 77 | } 78 | 79 | newHTML := parser.ModifyBody(string(html)) 80 | 81 | r.Body = ioutil.NopCloser(strings.NewReader(newHTML)) 82 | r.ContentLength = int64(len(newHTML)) 83 | r.Header.Set("Content-Length", strconv.Itoa(len(newHTML))) 84 | return nil 85 | } 86 | 87 | func modifyRequest(origin *url.URL) func(req *http.Request) { 88 | return func(req *http.Request) { 89 | req.Header.Add("X-Forwarded-Host", req.Host) 90 | req.Header.Add("X-Origin-Host", origin.Host) 91 | req.Header.Del("Accept-Encoding") 92 | req.URL.Scheme = "http" 93 | req.URL.Host = origin.Host 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "io" 5 | "log" 6 | ) 7 | 8 | // Warning initialize logger package 9 | func Warning(err error, description ...string) { 10 | var warningHandle io.Writer 11 | if err != nil { 12 | for _, obj := range description { 13 | log.New(warningHandle, 14 | "WARNING: "+obj+" ", 15 | log.Ldate|log.Ltime|log.Lshortfile) 16 | } 17 | } 18 | } 19 | 20 | // Error initialize logger package 21 | func Error(err error, description ...string) { 22 | var errorHandle io.Writer 23 | if err != nil { 24 | for _, obj := range description { 25 | log.New( 26 | errorHandle, 27 | "ERROR: "+obj+" ", 28 | log.Ldate|log.Ltime|log.Lshortfile) 29 | } 30 | } 31 | } 32 | 33 | // Fatal initialize logger package 34 | func Fatal(err error, description ...string) { 35 | var errorHandle io.Writer 36 | if err != nil { 37 | log.Fatal(errorHandle, 38 | "FATAL: ", 39 | log.Ldate|log.Ltime|log.Lshortfile, description, err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/ara-framework/nova-proxy/config" 9 | "github.com/gookit/color" 10 | ) 11 | 12 | func init() { 13 | config.LoadEnv() 14 | config.ReadConfigFile() 15 | } 16 | 17 | func main() { 18 | config.SetUpLocations() 19 | 20 | port := os.Getenv("PORT") 21 | 22 | if len(port) == 0 { 23 | port = "8080" 24 | } 25 | 26 | color.Info.Printf("Nova proxy running on http://0.0.0.0:%s\n", port) 27 | log.Fatal(http.ListenAndServe(":"+port, nil)) 28 | } 29 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/PuerkitoBio/goquery" 13 | "github.com/ara-framework/nova-proxy/logger" 14 | ) 15 | 16 | // ViewJobError is an error happened during and after a view is requesting. 17 | type ViewJobError struct { 18 | Name string `json:"name"` 19 | Message string `json:"message"` 20 | } 21 | 22 | type hypernovaResult struct { 23 | Success bool 24 | Html string 25 | Name string 26 | Error ViewJobError 27 | } 28 | 29 | type hypernovaResponse struct { 30 | Results map[string]hypernovaResult 31 | } 32 | 33 | // ModifyBody should modify specific body characters 34 | func ModifyBody(html string) string { 35 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) 36 | logger.Fatal(err, "Cannot handle incoming html ") 37 | 38 | batch := make(map[string]map[string]interface{}) 39 | 40 | doc.Find("div[data-hypernova-key]").Each(func(i int, s *goquery.Selection) { 41 | uuid, uuidOk := s.Attr("data-hypernova-id") 42 | name, nameOk := s.Attr("data-hypernova-key") 43 | if !uuidOk || !nameOk { 44 | return 45 | } 46 | 47 | scriptQuery := createQuery("script", uuid, name) 48 | 49 | script := doc.Find(scriptQuery).First() 50 | 51 | if script == nil { 52 | return 53 | } 54 | 55 | content := script.Text() 56 | content = content[4:(len(content) - 3)] 57 | 58 | var data interface{} 59 | 60 | json.Unmarshal([]byte(content), &data) 61 | 62 | batch[uuid] = make(map[string]interface{}) 63 | batch[uuid]["name"] = name 64 | batch[uuid]["data"] = data 65 | }) 66 | 67 | if len(batch) == 0 { 68 | return html 69 | } 70 | 71 | b, encodeErr := json.Marshal(batch) 72 | logger.Fatal(encodeErr, "Cannot convert batch into byte ") 73 | 74 | payload := string(b) 75 | 76 | resp, reqErr := http.Post( 77 | os.Getenv("HYPERNOVA_BATCH"), 78 | "application/json", 79 | strings.NewReader(payload)) 80 | 81 | if reqErr != nil { 82 | logger.Error(reqErr, "Cannot reach end with html given") 83 | return html 84 | } 85 | 86 | defer resp.Body.Close() 87 | 88 | body, bodyErr := ioutil.ReadAll(resp.Body) 89 | 90 | if bodyErr != nil { 91 | log.Fatal(bodyErr) 92 | } 93 | 94 | var hypernovaResponse hypernovaResponse 95 | 96 | json.Unmarshal(body, &hypernovaResponse) 97 | 98 | for uuid, result := range hypernovaResponse.Results { 99 | divQuery := createQuery("div", uuid, result.Name) 100 | 101 | if !result.Success { 102 | doc.Find(divQuery).PrependHtml("") 103 | continue 104 | } 105 | 106 | scriptQuery := createQuery("script", uuid, result.Name) 107 | doc.Find(scriptQuery).Remove() 108 | 109 | doc.Find(divQuery).ReplaceWithHtml(result.Html) 110 | } 111 | 112 | html, htmlError := doc.Html() 113 | 114 | if htmlError != nil { 115 | logger.Fatal(htmlError, "Cannot parse html element") 116 | } 117 | 118 | return html 119 | } 120 | 121 | func createQuery(tag string, uuid string, name string) string { 122 | query := fmt.Sprintf("%s[data-hypernova-id=\"%s\"][data-hypernova-key=\"%s\"]", tag, uuid, name) 123 | 124 | return query 125 | } 126 | --------------------------------------------------------------------------------