diff --git a/assets/templates/base.html.tmpl b/assets/templates/base.html.tmpl new file mode 100644 index 0000000..1004384 --- /dev/null +++ b/assets/templates/base.html.tmpl @@ -0,0 +1,10 @@ + + +
+ ++#RU URL SHORTENER +================= + +Based on https://0x0.st/, this site allows you to easily shorten URLs using +the command line. + +## USAGE + + # Shorten a URL + curl -F'shorten=http://example.com/some/long/url' https://hashru.link ++{{end}} \ No newline at end of file diff --git a/assets/text/index.txt b/assets/templates/index.txt.tmpl similarity index 100% rename from assets/text/index.txt rename to assets/templates/index.txt.tmpl diff --git a/handlers/handlers.go b/handlers/handlers.go index d8f24cd..164a2f4 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,9 +1,5 @@ package handlers -//go:generate go get github.com/go-bindata/go-bindata -//go:generate go get -u github.com/go-bindata/go-bindata/... -//go:generate go-bindata -pkg $GOPACKAGE -prefix ../assets ../assets/... - import ( "bytes" "crypto/rand" @@ -15,7 +11,6 @@ import ( "net/http" "net/url" "strings" - "text/template" "time" "unicode" @@ -58,10 +53,6 @@ var ReservedPasteKeys [][]byte = [][]byte{[]byte("xd42"), []byte("example")} var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" var base64Encoder = base64.RawURLEncoding.WithPadding(base64.NoPadding) -// Page contents -var baseTemplate = template.New("empty") -var indexTemplate = template.Must(baseTemplate.Parse(string(MustAsset("text/index.txt")))) - func (t PasteType) String() (string, error) { switch t { case TypePaste: @@ -85,9 +76,7 @@ func (t PasteState) String() (string, error) { } func IndexGetHandler(w http.ResponseWriter, r *http.Request) { - if err := indexTemplate.Execute(w, nil); err != nil { - panic(err) - } + Render(w, r, "index", nil) } func IndexPostHandler(w http.ResponseWriter, r *http.Request) { diff --git a/handlers/views.go b/handlers/views.go new file mode 100644 index 0000000..81364cb --- /dev/null +++ b/handlers/views.go @@ -0,0 +1,201 @@ +package handlers + +//go:generate go get github.com/go-bindata/go-bindata +//go:generate go get -u github.com/go-bindata/go-bindata/... +//go:generate go-bindata -pkg $GOPACKAGE -prefix ../assets ../assets/... + +import ( + "fmt" + html "html/template" + "io" + "log" + "net/http" + "path/filepath" + "regexp" + "runtime/debug" + "sort" + "strconv" + "strings" + text "text/template" + + "github.com/pkg/errors" +) + +// Plain text templates +var TextBaseTemplate *text.Template = text.Must(text.New("Empty").Parse(string(MustAsset("templates/base.txt.tmpl")))) +var HTMLBaseTemplate *html.Template = html.Must(html.New("Empty").Parse(string(MustAsset("templates/base.html.tmpl")))) + +// Template collections +var TextTemplates = make(map[string]*text.Template, 0) +var HTMLTemplates = make(map[string]*html.Template, 0) + +// Used by resolveResponseContentType +var acceptHeaderMediaRangeRegex = regexp.MustCompile(`^\s*([^()<>@,;:\\"/\[\]?.=]+)/([^()<>@,;:\\"/\[\]?.=]+)\s*$`) +var acceptHeaderAcceptParamsRegex = regexp.MustCompile(`^\s*(\w+)=([A-Za-z0-9.-])\s*$`) +var acceptHeaderWeight = regexp.MustCompile(`^\s*q=0(?:\.([0-9]{0,3}))|1(?:\.0{0,3})\s*$`) + +// HTML templates +func init() { + for _, tmplPath := range AssetNames() { + if mustMatch("templates/*.txt.tmpl", tmplPath) { + tmpl := text.Must(TextBaseTemplate.Parse(string(MustAsset(tmplPath)))) + tmplName := strings.TrimSuffix(filepath.Base(tmplPath), ".txt.tmpl") + TextTemplates[tmplName] = tmpl + continue + } + if mustMatch("templates/*.html.tmpl", tmplPath) { + tmpl := html.Must(HTMLBaseTemplate.Parse(string(MustAsset(tmplPath)))) + tmplName := strings.TrimSuffix(filepath.Base(tmplPath), ".html.tmpl") + HTMLTemplates[tmplName] = tmpl + continue + } + } +} + +func mustMatch(pattern, name string) bool { + m, err := filepath.Match(pattern, name) + if err != nil { + panic("%v error in call to mustMatch") + } + return m +} + +func parseFail(tmplName string, err error) { + err = errors.Wrapf(err, "parsing of %v failed", tmplName) + panic(err) +} + +func Render(w http.ResponseWriter, r *http.Request, tmplName string, data interface{}) { + contentType, err := resolveResponseContentType(r, []string{"text/plain", "text/html"}) + if err != nil { + w.WriteHeader(http.StatusNotAcceptable) + fmt.Fprintf(w, "error parsing Accept header: %v\n", err) + } + + switch contentType { + case "text/plain": + w.Header().Set("Content-Type", "text/plain") + tmpl := TextTemplates[tmplName] + if tmpl == nil { + err = fmt.Errorf("'%v' not in TextTemplates", tmplName) + break + } + err = tmpl.Execute(w, data) + case "text/html": + w.Header().Set("Content-Type", "text/html") + tmpl := HTMLTemplates[tmplName] + if tmpl == nil { + err = fmt.Errorf("'%v' not in HTMLTemplates", tmplName) + break + } + err = tmpl.Execute(w, data) + default: + w.WriteHeader(http.StatusNotAcceptable) + io.WriteString(w, "could not resolve an acceptable content-type\n") + } + + if err != nil { + panic(err) + } +} + +// Try to resolve the preferred content-type for the response to this request. +// +// This is done by reading from the `types` argument. If one of them matches +// the preferences supplied by the client in their Accept header, we will +// return that one. We will take the clients preferences into account. +// +// Iff no match could be found, this function will return an empty string, and +// the caller should probably respond with a 406 Not Acceptable status code. +// Iff the Accept header was invalid, we will return an error. In this case, +// the situation calls for a 400 Bad Request. +func resolveResponseContentType(r *http.Request, types []string) (string, error) { + // Ref: https://tools.ietf.org/html/rfc7231#section-5.3.2 + if len(types) == 0 { + return "", nil + } + acceptHeader := r.Header.Get("Accept") + if acceptHeader == "" { + return types[0], nil + } + + type AcceptValue struct { + Type string + Subtype string + Weight int + } + + avStrings := strings.Split(acceptHeader, ",") + avs := make([]AcceptValue, len(avStrings)) + for i, avString := range avStrings { + av := AcceptValue{Weight: 1000} + choiceParts := strings.Split(avString, ";") + mediaRange := acceptHeaderMediaRangeRegex.FindStringSubmatch(choiceParts[0]) + if mediaRange == nil { + return "", fmt.Errorf("bad media-range (\"%v\")", choiceParts[0]) + } + av.Type = mediaRange[1] + av.Subtype = mediaRange[2] + // Go through the rest to see if there is a q=... parameter + for choiceParts = choiceParts[1:]; len(choiceParts) > 0; choiceParts = choiceParts[1:] { + // Try to parse the weight param + weight := acceptHeaderWeight.FindStringSubmatch(choiceParts[0]) + if weight != nil { + if weight[1] == "" { + av.Weight = 0 + } else { + var err error + av.Weight, err = strconv.Atoi((weight[1] + "000")[:3]) + if err != nil { + log.Println("error: unreachable statement") + debug.PrintStack() + av.Weight = 1000 // Reset to default value + } + } + break + } + // Check if this parameter is still invalid in any case + acceptParams := acceptHeaderAcceptParamsRegex.FindStringSubmatchIndex(choiceParts[0]) + if acceptParams == nil { + return "", fmt.Errorf("bad accept-params (\"%v\")", choiceParts[0]) + } + } + avs[i] = av + } + + sort.SliceStable(avs, func(i, j int) bool { + if avs[i].Weight > avs[j].Weight { + return true + } + if avs[i].Type != "*" && avs[j].Type == "*" { + return true + } + if avs[i].Subtype != "*" && avs[j].Subtype == "*" { + return true + } + return false + }) + + avArgs := make([]AcceptValue, len(types)) + for i, fulltype := range types { + split := strings.Split(fulltype, "/") + if len(split) == 1 { + avArgs[i] = AcceptValue{Type: split[0]} + } else { + avArgs[i] = AcceptValue{Type: split[0], Subtype: split[1]} + } + } + + for _, av := range avs { + for j, avArg := range avArgs { + if !(av.Type == avArg.Type || av.Type == "*" || avArg.Type == "*") { + continue + } + if !(av.Subtype == avArg.Subtype || av.Subtype == "*" || avArg.Subtype == "*") { + continue + } + return types[j], nil + } + } + return "", nil +}