blob: cef998547a4b07094608191de5ff109949d9d71d [file] [log] [blame]
// ssi implements a simple server side include mechanism that allows to replace
// special tags with the output of function calls.
package ssi
/*
Server side includes (SSI) map simple custom tags to function calls.
Such a tag looks like this:
<ssi:tag_id key1=val1 key2=val2>
where 'tag_id' is the name of the tag and the key/value pairs are the
parameters of the tag.
The ProcessSSI function in this package takes a HTML document and
replaces all SSI tags with the output of the function that has
been associated with the respective tag_id.
This covers any use case where we want to generate dynamic content and
leave the placement and configuration of it up to the user.
For example the tag implemented below allows the user to write
<ssi:listgcs path=skia-bucket/path/to/dir>
which would then be replaced with a listing of specified GCS location.
*/
import (
"bytes"
"context"
"fmt"
"html/template"
"regexp"
"sort"
"strings"
"time"
"cloud.google.com/go/storage"
"go.skia.org/infra/go/gcs"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"google.golang.org/api/iterator"
)
// ssiProcessFn is a function that is executed to produce the output for a custom tag.
type ssiProcessFn func(params map[string]string) ([]byte, error)
var (
// processFNs maps the id of an SSI function to the function implementing the tag.
processFNs = map[string]ssiProcessFn{}
// findSSIRegEx finds the ssi tags and extracts the relevant information as groups.
findSSIRegEx = regexp.MustCompile(`<ssi:(?P<tag_id>[a-zA-Z]+)\s*(?P<params>.*?)>`)
)
// Init initializes the package with a client to access GCS and the URL of the
// target repo which is used to generate links to commits.
func Init(repoURL string, client *storage.Client) {
// Set up the tag to list files from GCS.
lister := &gceFolderListing{
client: client,
repoURL: strings.TrimRight(repoURL, "/") + "/",
}
processFNs["listgce"] = lister.generateFolderListing
}
// ProcessSSI finds SSI tags in the given body and resolves them.
// It returns the body with all SSI tags replaced with the output of
// the functions that were registered for them.
func ProcessSSI(body []byte) ([]byte, error) {
// find whether there are any includes
indices := findSSIRegEx.FindAllSubmatchIndex(body, -1)
if len(indices) > 0 {
// parts collects the portions of the original that we keep and the
// newly generated output in the right order.
parts := make([][]byte, 0, len(indices)*2+1)
currStart := 0
totalLen := 0
// Iterate over all tags we have found.
for _, match := range indices {
// Use the start/end indices of the tag to keep track of the parts of the
// document that we want to keep. See the 'prefix' variable below.
tagStart, tagEnd := match[0], match[1]
id := string(body[match[2]:match[3]])
paramStr := string(body[match[4]:match[5]])
// Parse the tag parameters, which are simply space separated 'key=value' pairs.
params, err := parseParams(paramStr)
if err != nil {
return nil, sklog.FmtErrorf("Error parsing params in tag '%s': %s", string(body[tagStart:tagEnd]), err)
}
// Find the function registered for the given tag.
fn, ok := processFNs[id]
if !ok {
return nil, sklog.FmtErrorf("Unable to find function for tag '%s'", string(body[tagStart:tagEnd]))
}
// Run the function and insert the returned byte slice instead of the tag.
fill, err := fn(params)
if err != nil {
return nil, sklog.FmtErrorf("Error processing tag '%s': %s", string(body[tagStart:tagEnd]), err)
}
prefix := body[currStart:tagStart]
parts = append(parts, prefix, fill)
currStart = tagEnd
totalLen += len(prefix) + len(fill)
}
// Add the suffix after the last tag and re-assemble the parts of
// the original document and the newly generated parts into one.
parts = append(parts, body[currStart:])
body = make([]byte, 0, totalLen)
for _, part := range parts {
body = append(body, part...)
}
}
return body, nil
}
// parseParams parses the key=value pairs that make up the parameters of the tag.
func parseParams(paramsStr string) (map[string]string, error) {
paramsStr = strings.TrimSpace(paramsStr)
if paramsStr == "" {
return map[string]string{}, nil
}
kvPairs := strings.Fields(paramsStr)
ret := make(map[string]string, len(kvPairs))
for _, kvPair := range kvPairs {
kv := strings.SplitN(kvPair, "=", 2)
k := strings.TrimSpace(kv[0])
v := ""
if k == "" {
return nil, sklog.FmtErrorf("Missing key in parameters '%s'", paramsStr)
}
if len(kv) == 2 {
v = strings.TrimSpace(kv[1])
}
ret[k] = v
}
return ret, nil
}
// gceFolderList allows to list the contents of folders in GCS. Its
// 'generateFolderListing' function implements the ssiProcessFn signature and
// is used to implement the <ssi:listgcs> tag. That tag takes one
// parameter which is the path (format: bucket/prefix) of the GCS, e.g.
//
// <ssi:listgcs path=mybucket/somefolder>
//
// would create a table that contains the listing of all public files in
// 'bucket' that is in folder 'somefolder'. The files are assumed to have
// these commit related meta data attached to them (relating to the commit
// that generated the file):
// - commit : the commit hash
// - commit_message : the commit message
// - date: the date of the commit
//
type gceFolderListing struct {
client *storage.Client
repoURL string
}
// Note: The variable below are not constants because they are swapped out during
// testing to make verification simpler.
var (
// listGCSTagSnippet is the code snippet that encapsulates that listing of the
// files in a GCS folder.
listGCSTagSnippet = `<table><thead><tr>
<th>Created</th>
<th>Link</th>
<th>Commit Date</th>
<th>Commit</th>
<th>Commit Message</th>
</tr></thead>
<tbody>
%s
</tbody>
</table>`
// listGCSURLTmpl is the template to generate URLs in GCS from bucket and path strings.
listGCSURLTmpl = "https://storage.cloud.google.com/%s/%s"
// nListGCSEntries is the number of entries we'll show in the listing.
nListGCSEntries = 10
// gcsListingRowTmpl is the template to generate a single entry in the GCS folder listing.
gceListingRowTmpl = template.Must(template.New("row").Parse(`<tr>
<td>{{.created}}</td>
<td><a href="{{.url}}">{{.name}}</a></td>
<td>{{.date}}</td>
<td>{{.commit}}</td>
<td><a href="{{.commit_url}}">{{.commit_message}}</a></td>
</tr>`))
)
// generateFolderListing generates HTML that is inserted into the document instead of the
// tag that is tied to this function.
func (g *gceFolderListing) generateFolderListing(params map[string]string) ([]byte, error) {
gcsPath, ok := params["path"]
if !ok {
return nil, sklog.FmtErrorf("No 'path' parameter provided in tag.")
}
bucket, path := gcs.SplitGSPath(gcsPath)
path = strings.TrimRight(path, "/") + "/"
iter := g.client.Bucket(bucket).Objects(context.Background(), &storage.Query{Prefix: path, Delimiter: "/"})
items := []map[string]interface{}{}
for {
objAttrs, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, sklog.FmtErrorf("Error retrieving object attributes: %s", err)
}
// Add entry if it's not a folder, if we have meta data and if the item is public.
if objAttrs.Prefix == "" && objAttrs.Metadata != nil && isPublic(objAttrs.ACL) {
// Copy the meta data attributes.
attrs := map[string]interface{}{}
for k, v := range objAttrs.Metadata {
attrs[k] = v
}
// Generate some additional attributes. Mark the URLs as trusted so they
// are not further escaped.
attrs["name"] = objAttrs.Name[strings.LastIndex(objAttrs.Name, "/")+1:]
attrs["url"] = template.HTML(fmt.Sprintf(listGCSURLTmpl, objAttrs.Bucket, objAttrs.Name))
attrs["commit_url"] = template.HTML(g.repoURL + "+/" + attrs["commit"].(string))
attrs["created"] = objAttrs.Created.Format(time.RFC3339)
items = append(items, attrs)
}
}
// Sort the folder entries in reverse chronological order by their creation time.
sort.Slice(items, func(i, j int) bool {
return items[i]["created"].(string) > items[j]["created"].(string)
})
items = items[:util.MinInt(len(items), nListGCSEntries)]
var buf bytes.Buffer
for _, attrs := range items {
fmt.Println(attrs["created"])
if err := gceListingRowTmpl.Execute(&buf, attrs); err != nil {
return nil, sklog.FmtErrorf("Unable to execute template: %s", err)
}
}
return []byte(fmt.Sprintf(listGCSTagSnippet, buf.String())), nil
}
// isPublic returns true if one in the given ACLRules allows all users to
// read the content.
func isPublic(aclRules []storage.ACLRule) bool {
for _, rule := range aclRules {
if rule.Entity == storage.AllUsers && rule.Role == storage.RoleReader {
return true
}
}
return false
}