230 lines
6.7 KiB
Go
230 lines
6.7 KiB
Go
// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
|
|
//
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package fuphttp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/safehtml"
|
|
"github.com/google/safehtml/template"
|
|
"github.com/google/safehtml/template/uncheckedconversions"
|
|
shuncheckedconversions "github.com/google/safehtml/uncheckedconversions"
|
|
"github.com/gorilla/mux"
|
|
"gocloud.dev/blob"
|
|
"hg.lukegb.com/lukegb/depot/web/fup/fuphttp/fngen"
|
|
"hg.lukegb.com/lukegb/depot/web/fup/hashfs"
|
|
)
|
|
|
|
const (
|
|
defaultRedirectExpiry = 5 * time.Minute
|
|
)
|
|
|
|
type Highlighter interface {
|
|
Markdown(ctx context.Context, text string) (safehtml.HTML, error)
|
|
Code(ctx context.Context, filename, theme, text string) (safehtml.HTML, error)
|
|
}
|
|
|
|
type Config struct {
|
|
Templates fs.FS
|
|
Static fs.FS
|
|
StaticRoot safehtml.TrustedResourceURL
|
|
|
|
AppRoot string
|
|
|
|
// If set, redirects to a signed URL if possible instead of serving directly.
|
|
RedirectToBlobstore bool
|
|
|
|
// RedirectExpiry sets the maximum lifetime of a signed URL, if RedirectToBlobstore is in use and the backend supports signed URLs.
|
|
// Note that if a file has an expiry then the signed URL's validity will be capped at the expiry of the underlying file.
|
|
RedirectExpiry time.Duration // Defaults to 5 minutes.
|
|
|
|
// Set one of these, but not both.
|
|
StorageURL string
|
|
StorageBackend *blob.Bucket
|
|
|
|
// FilenameGenerator returns a new filename based on the provided prefix and extension.
|
|
FilenameGenerator fngen.FilenameGenerator
|
|
|
|
// Highlighter is used for syntax highlighting and Markdown rendering.
|
|
// If nil, then no syntax highlighting or Markdown rendering will be performed.
|
|
Highlighter Highlighter
|
|
|
|
// UseDirectDownload decides whether the "pretty" wrapped page or the direct download page is the most appropriate for a given set of parameters.
|
|
UseDirectDownload func(fileExtension string, mimeType string) bool
|
|
}
|
|
|
|
type Application struct {
|
|
indexTmpl *template.Template
|
|
pasteTmpl *template.Template
|
|
notFoundTmpl *template.Template
|
|
viewTextTmpl *template.Template
|
|
viewRenderedTmpl *template.Template
|
|
viewBinaryTmpl *template.Template
|
|
|
|
storageBackend *blob.Bucket
|
|
|
|
appRoot string
|
|
|
|
highlighter Highlighter
|
|
|
|
redirectToBlobstore bool
|
|
redirectExpiry time.Duration
|
|
|
|
filenameGenerator fngen.FilenameGenerator
|
|
useDirectDownload func(fileExtension string, mimeType string) bool
|
|
}
|
|
|
|
func DefaultUseDirectDownload(fileExtension, mimeType string) bool {
|
|
switch mimeType {
|
|
case "application/json", "application/xml", "application/xhtml+xml", "application/x-csh", "application/x-sh":
|
|
return false
|
|
}
|
|
return !strings.HasPrefix(mimeType, "text/")
|
|
}
|
|
|
|
func (a *Application) Handler() http.Handler {
|
|
r := mux.NewRouter()
|
|
|
|
renderTemplate := func(t *template.Template) http.HandlerFunc {
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
if err := t.Execute(rw, nil); err != nil {
|
|
log.Printf("rendering template: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
r.NotFoundHandler = http.HandlerFunc(a.notFound)
|
|
r.HandleFunc("/", renderTemplate(a.indexTmpl))
|
|
r.HandleFunc("/paste", renderTemplate(a.pasteTmpl))
|
|
r.HandleFunc("/raw/{filename}", a.rawDownload)
|
|
r.HandleFunc("/upload", a.upload).Methods("POST")
|
|
r.HandleFunc("/upload/{filename}", a.upload).Methods("PUT")
|
|
r.HandleFunc("/{filename}", a.view)
|
|
|
|
return r
|
|
}
|
|
|
|
func (a *Application) notFound(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusNotFound)
|
|
if err := a.notFoundTmpl.Execute(rw, nil); err != nil {
|
|
log.Printf("rendering 404 template: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *Application) internalError(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-type", "text/plain; charset=utf-8")
|
|
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
|
rw.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintf(rw, "hammed server :(\n")
|
|
}
|
|
|
|
func (a *Application) badRequest(rw http.ResponseWriter, r *http.Request, err error) {
|
|
rw.Header().Set("Content-type", "text/plain; charset=utf-8")
|
|
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
|
rw.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(rw, "bad request: %v\n", err.Error())
|
|
}
|
|
|
|
func (a *Application) appURL(s string) string {
|
|
return a.appRoot + s
|
|
}
|
|
|
|
func parseTemplate(t *template.Template, fsys fs.FS, name string) (*template.Template, error) {
|
|
bs, err := fs.ReadFile(fsys, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading template %q: %w", name, err)
|
|
}
|
|
|
|
return t.ParseFromTrustedTemplate(
|
|
uncheckedconversions.TrustedTemplateFromStringKnownToSatisfyTypeContract(string(bs)),
|
|
)
|
|
}
|
|
|
|
func loadTemplate(fsys fs.FS, name string, funcs template.FuncMap) (*template.Template, error) {
|
|
t := template.New(name).Funcs(funcs)
|
|
var err error
|
|
if t, err = parseTemplate(t, fsys, "base.html"); err != nil {
|
|
return nil, fmt.Errorf("loading base template: %w", err)
|
|
}
|
|
if t, err = parseTemplate(t, fsys, fmt.Sprintf("%s.html", name)); err != nil {
|
|
return nil, fmt.Errorf("loading leaf template: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func New(ctx context.Context, cfg *Config) (*Application, error) {
|
|
a := &Application{
|
|
redirectToBlobstore: cfg.RedirectToBlobstore,
|
|
redirectExpiry: cfg.RedirectExpiry,
|
|
filenameGenerator: cfg.FilenameGenerator,
|
|
useDirectDownload: cfg.UseDirectDownload,
|
|
appRoot: cfg.AppRoot,
|
|
highlighter: cfg.Highlighter,
|
|
}
|
|
if a.redirectExpiry == 0 {
|
|
a.redirectExpiry = defaultRedirectExpiry
|
|
}
|
|
if a.filenameGenerator == nil {
|
|
a.filenameGenerator = fngen.PetnameGenerator
|
|
}
|
|
if a.useDirectDownload == nil {
|
|
a.useDirectDownload = DefaultUseDirectDownload
|
|
}
|
|
|
|
bkt := cfg.StorageBackend
|
|
if bkt == nil {
|
|
var err error
|
|
if bkt, err = blob.OpenBucket(ctx, cfg.StorageURL); err != nil {
|
|
return nil, fmt.Errorf("opening bucket %q: %v", cfg.StorageURL, err)
|
|
}
|
|
}
|
|
a.storageBackend = bkt
|
|
|
|
tmpls := []struct {
|
|
t **template.Template
|
|
name string
|
|
}{
|
|
{&a.indexTmpl, "index"},
|
|
{&a.pasteTmpl, "paste"},
|
|
{&a.notFoundTmpl, "404"},
|
|
{&a.viewTextTmpl, "text"},
|
|
{&a.viewRenderedTmpl, "rendered"},
|
|
{&a.viewBinaryTmpl, "binary"},
|
|
}
|
|
|
|
funcMap := template.FuncMap{
|
|
"app": func(s string) safehtml.URL {
|
|
return safehtml.URLSanitized(a.appRoot + s)
|
|
},
|
|
"static": func(s string) safehtml.TrustedResourceURL {
|
|
staticPath := s
|
|
if fs, ok := cfg.Static.(*hashfs.FS); ok {
|
|
sp, ok := fs.LookupHashedName(staticPath)
|
|
if ok {
|
|
staticPath = sp
|
|
} else {
|
|
log.Printf("warning: couldn't find static file %v", staticPath)
|
|
}
|
|
}
|
|
return shuncheckedconversions.TrustedResourceURLFromStringKnownToSatisfyTypeContract(cfg.StaticRoot.String() + staticPath)
|
|
},
|
|
}
|
|
|
|
for _, tmpl := range tmpls {
|
|
t, err := loadTemplate(cfg.Templates, tmpl.name, funcMap)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading template %q: %w", tmpl.name, err)
|
|
}
|
|
*tmpl.t = t
|
|
}
|
|
|
|
return a, nil
|
|
}
|