github.com/leplay/upcn@v0.7.0/internal/inject/inject.go (about)

     1  // Package inject provides script and style injection utilities.
     2  package inject
     3  
     4  import (
     5  	"encoding/json"
     6  	"html"
     7  	"io/ioutil"
     8  	"strings"
     9  
    10  	"github.com/apex/log"
    11  	"github.com/apex/up/internal/validate"
    12  	"github.com/pkg/errors"
    13  )
    14  
    15  // TODO: template support
    16  // TODO: move config to "config" pkg
    17  
    18  // locations valid.
    19  var locations = []string{
    20  	"head",
    21  	"body",
    22  }
    23  
    24  // types valid.
    25  var types = []string{
    26  	"literal",
    27  	"comment",
    28  	"style",
    29  	"script",
    30  	"inline style",
    31  	"inline script",
    32  	"google analytics",
    33  	"segment",
    34  }
    35  
    36  // Rules is a set of rules mapped by location.
    37  type Rules map[string][]*Rule
    38  
    39  // Default rules.
    40  func (r Rules) Default() error {
    41  	for pos, rules := range r {
    42  		for i, rule := range rules {
    43  			if err := rule.Default(); err != nil {
    44  				return errors.Wrapf(err, "%s rule #%d", pos, i+1)
    45  			}
    46  		}
    47  	}
    48  
    49  	return nil
    50  }
    51  
    52  // Validate rules.
    53  func (r Rules) Validate() error {
    54  	for pos, rules := range r {
    55  		if err := validate.List(pos, locations); err != nil {
    56  			return errors.Wrap(err, "invalid location")
    57  		}
    58  
    59  		for i, rule := range rules {
    60  			if err := rule.Validate(); err != nil {
    61  				return errors.Wrapf(err, "%s rule #%d", pos, i+1)
    62  			}
    63  		}
    64  	}
    65  
    66  	return nil
    67  }
    68  
    69  // Apply rules to html.
    70  func (r Rules) Apply(html string) string {
    71  	for pos, rules := range r {
    72  		log.Debugf("injecting %s rules", pos)
    73  		for _, rule := range rules {
    74  			log.Debugf("  inject %s %q", rule.Type, rule.Value)
    75  			switch pos {
    76  			case "head":
    77  				html = Head(html, rule.Apply(html))
    78  			case "body":
    79  				html = Body(html, rule.Apply(html))
    80  			}
    81  		}
    82  	}
    83  
    84  	return html
    85  }
    86  
    87  // Rule is an injection rule.
    88  type Rule struct {
    89  	// Type of injection, defaults to "literal" unless File is used,
    90  	// or Value contains .js or .css extensions.
    91  	Type string `json:"type"`
    92  
    93  	// Value is the literal, inline string, or src/href of the injected tag.
    94  	Value string `json:"value"`
    95  
    96  	// File is used to load source from disk instead of providing Value. Note
    97  	// that if Type is not explicitly provided, then it will default to
    98  	// "inline script" or "inline style" for .js and .css files respectively.
    99  	File string `json:"file"`
   100  }
   101  
   102  // Apply rule to html.
   103  func (r *Rule) Apply(html string) string {
   104  	switch r.Type {
   105  	case "literal":
   106  		return r.Value
   107  	case "script":
   108  		return Script(r.Value)
   109  	case "style":
   110  		return Style(r.Value)
   111  	case "inline script":
   112  		return ScriptInline(r.Value)
   113  	case "inline style":
   114  		return StyleInline(r.Value)
   115  	case "comment":
   116  		return Comment(r.Value)
   117  	case "segment":
   118  		return Segment(r.Value)
   119  	case "google analytics":
   120  		return GoogleAnalytics(r.Value)
   121  	default:
   122  		return ""
   123  	}
   124  }
   125  
   126  // Default applies defaults.
   127  func (r *Rule) Default() error {
   128  	if r.Type == "" {
   129  		r.Type = "literal"
   130  	}
   131  
   132  	if r.File != "" {
   133  		if err := r.defaultFile(r.File); err != nil {
   134  			return err
   135  		}
   136  	}
   137  
   138  	return nil
   139  }
   140  
   141  // Validate returns an error if incorrect.
   142  func (r *Rule) Validate() error {
   143  	if err := validate.List(r.Type, types); err != nil {
   144  		return errors.Wrap(err, "invalid .type")
   145  	}
   146  
   147  	if strings.TrimSpace(r.Value) == "" {
   148  		return errors.Errorf(`.value is required`)
   149  	}
   150  
   151  	return nil
   152  }
   153  
   154  // defaultFile defaults value" from the given path.
   155  func (r *Rule) defaultFile(path string) error {
   156  	b, err := ioutil.ReadFile(path)
   157  	if err != nil {
   158  		return err
   159  	}
   160  
   161  	r.Value = string(b)
   162  	return nil
   163  }
   164  
   165  // Head injects a string before the closing head tag.
   166  func Head(html, s string) string {
   167  	return strings.Replace(html, "</head>", "  "+s+"\n  </head>", 1)
   168  }
   169  
   170  // Body injects a string before the closing body tag.
   171  func Body(html, s string) string {
   172  	return strings.Replace(html, "</body>", "  "+s+"\n  </body>", 1)
   173  }
   174  
   175  // Script returns an script.
   176  func Script(src string) string {
   177  	return `<script src="` + html.EscapeString(src) + `"></script>`
   178  }
   179  
   180  // ScriptInline returns an inline script.
   181  func ScriptInline(s string) string {
   182  	return `<script>` + s + `</script>`
   183  }
   184  
   185  // Style returns an style.
   186  func Style(href string) string {
   187  	return `<link rel="stylesheet" href="` + html.EscapeString(href) + `">`
   188  }
   189  
   190  // StyleInline returns an inline style.
   191  func StyleInline(s string) string {
   192  	return `<style>` + s + `</style>`
   193  }
   194  
   195  // Comment returns an html comment.
   196  func Comment(s string) string {
   197  	return "<!-- " + html.EscapeString(s) + " -->"
   198  }
   199  
   200  // Segment inline script with key.
   201  func Segment(key string) string {
   202  	return ScriptInline(`
   203    !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on"];analytics.factory=function(t){return function(){var e=Array.prototype.slice.call(arguments);e.unshift(t);analytics.push(e);return analytics}};for(var t=0;t<analytics.methods.length;t++){var e=analytics.methods[t];analytics[e]=analytics.factory(e)}analytics.load=function(t){var e=document.createElement("script");e.type="text/javascript";e.async=!0;e.src=("https:"===document.location.protocol?"https://":"http://")+"cdn.segment.com/analytics.js/v1/"+t+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)};analytics.SNIPPET_VERSION="4.0.0";
   204    analytics.load("` + key + `");
   205    analytics.page();
   206    }}();
   207  `)
   208  }
   209  
   210  // GoogleAnalytics inline script with tracking key.
   211  func GoogleAnalytics(trackingID string) string {
   212  	return ScriptInline(`
   213    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
   214    (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
   215    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
   216    })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
   217  
   218    ga('create', '` + trackingID + `', 'auto');
   219    ga('send', 'pageview');
   220  `)
   221  }
   222  
   223  // Var injection.
   224  func Var(kind, name string, v interface{}) string {
   225  	b, _ := json.Marshal(v)
   226  	return ScriptInline(kind + ` ` + name + ` = ` + string(b))
   227  }