288 lines
8.3 KiB
Go
288 lines
8.3 KiB
Go
package rushlink
|
|
|
|
//go:generate go-bindata -pkg $GOPACKAGE -prefix assets/ assets/...
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
html "html/template"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime/debug"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
text "text/template"
|
|
|
|
"gitea.hashru.nl/dsprenkels/rushlink/internal/db"
|
|
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const defaultScheme = "https"
|
|
|
|
// Plain text templates
|
|
var textBaseTemplate *text.Template = text.Must(text.New("").Parse(string(MustAsset("templates/txt/base.txt.tmpl"))))
|
|
var htmlBaseTemplate *html.Template = html.Must(html.New("").Parse(string(MustAsset("templates/html/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/*.txt.tmpl", tmplPath) {
|
|
base := text.Must(textBaseTemplate.Clone())
|
|
tmpl, err := base.Parse(string(MustAsset(tmplPath)))
|
|
if err != nil {
|
|
panic(errors.Wrapf(err, "parsing %v", tmplPath))
|
|
}
|
|
tmplName := strings.TrimSuffix(filepath.Base(tmplPath), ".txt.tmpl")
|
|
textTemplates[tmplName] = tmpl
|
|
continue
|
|
}
|
|
if mustMatch("templates/html/*.html.tmpl", tmplPath) {
|
|
base := html.Must(htmlBaseTemplate.Clone())
|
|
tmpl, err := base.Parse(string(MustAsset(tmplPath)))
|
|
if err != nil {
|
|
panic(errors.Wrapf(err, "parsing %v", tmplPath))
|
|
}
|
|
tmplName := strings.TrimSuffix(filepath.Base(tmplPath), ".html.tmpl")
|
|
htmlTemplates[tmplName] = tmpl
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Sanity check. Both maps should not be empty
|
|
if len(textTemplates) == 0 || len(htmlTemplates) == 0 {
|
|
panic("template loading failed")
|
|
}
|
|
}
|
|
|
|
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) {
|
|
panic(errors.Wrapf(err, "parsing of %v failed", tmplName))
|
|
}
|
|
|
|
func mapExtend(m map[string]interface{}, key string, value interface{}) {
|
|
if m[key] != nil {
|
|
return
|
|
}
|
|
m[key] = value
|
|
}
|
|
|
|
func (rl *rushlink) render(w http.ResponseWriter, r *http.Request, tmplName string, data map[string]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)
|
|
}
|
|
|
|
// Add the request to the template data
|
|
mapExtend(data, "Host", rl.resolveHost(r))
|
|
mapExtend(data, "Request", r)
|
|
|
|
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
|
|
}
|
|
|
|
// Construct a (lazy) plain-text view for inclusion in <pre>
|
|
data["Pre"] = func() string {
|
|
tmpl := textTemplates[tmplName]
|
|
if tmpl == nil {
|
|
panic(fmt.Errorf("'%v' not in textTemplates", tmplName))
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
panic(err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if r.Method != "HEAD" {
|
|
err = tmpl.Execute(w, data)
|
|
}
|
|
default:
|
|
// Fall back to plain text without template
|
|
w.WriteHeader(http.StatusNotAcceptable)
|
|
io.WriteString(w, "could not resolve an acceptable content-type\n")
|
|
}
|
|
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func (rl *rushlink) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
|
w.WriteHeader(status)
|
|
rl.render(w, r, "error", map[string]interface{}{"Message": msg})
|
|
}
|
|
|
|
func (rl *rushlink) renderInternalServerError(w http.ResponseWriter, r *http.Request, err interface{}) {
|
|
msg := fmt.Sprintf("internal server error: %v", err)
|
|
rl.renderError(w, r, http.StatusInternalServerError, msg)
|
|
}
|
|
|
|
func (rl *rushlink) renderCreateSuccess(w http.ResponseWriter, r *http.Request, paste *db.Paste, fu *db.FileUpload) {
|
|
if paste == nil {
|
|
panic("paste should not be nil")
|
|
}
|
|
var fileExt string
|
|
if fu != nil {
|
|
fileExt = fu.Ext()
|
|
}
|
|
var redirectURL url.URL
|
|
redirectURL.Path = fmt.Sprintf("/%s%s/meta", paste.Key, fileExt)
|
|
queryVals := redirectURL.Query()
|
|
queryVals.Add("deleteToken", paste.DeleteToken)
|
|
redirectURL.RawQuery = queryVals.Encode()
|
|
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
|
}
|
|
|
|
// resolveHost constructs the `scheme://host` part of rushlinks public API.
|
|
//
|
|
// If the `--host` flag is set, it will return that URL.
|
|
// Otherwise, this function will return 'https://{Host}', where `{Host}` is
|
|
// the value provided by the client in the HTTP `Host` header. This value may
|
|
// be invalid, but it is impossible to handle this error (because we *cannot*
|
|
// know the real host).
|
|
func (rl *rushlink) resolveHost(r *http.Request) string {
|
|
rlHost := rl.Host()
|
|
if rlHost != nil {
|
|
return rlHost.String()
|
|
}
|
|
return defaultScheme + r.Host
|
|
}
|
|
|
|
// 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
|
|
}
|