depot/web/fup/fuphttp/fuphttp.go

284 lines
8.6 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"
"git.lukegb.com/lukegb/depot/web/fup/fuphttp/fngen"
"git.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
// AuthMiddleware is a Gorilla middleware to provide authentication information.
//
// It runs on every handler, including public ones, and will be provided with information:
// - if the page is an upload page (either the homepage, the paste textbox page, or the upload handler), the IsMutate will return true.
// - if the page is an API handler, then IsAPIRequest will return true.
AuthMiddleware mux.MiddlewareFunc
}
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
authMiddleware mux.MiddlewareFunc
}
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 isAPIRequest(r *http.Request) bool {
return r.Header.Get("Accept") == "application/json"
}
type contextKey string
const (
ctxKeyIsMutate = contextKey("isMutate")
ctxKeyIsAPIRequest = contextKey("isAPIRequest")
)
// IsAPIRequest determines whether a request was made from an API client rather than from a browser.
func IsAPIRequest(ctx context.Context) bool {
return ctx.Value(ctxKeyIsAPIRequest).(bool)
}
// IsMutate determines whether a request for the given context is for a "mutate".
// This includes things like the HTML for the home page, which in itself is not a mutate but is useless if you're not allowed to auth.
func IsMutate(ctx context.Context) bool {
return ctx.Value(ctxKeyIsMutate).(bool)
}
func contextPopulateMiddleware(isMutate bool) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, ctxKeyIsMutate, isMutate)
ctx = context.WithValue(ctx, ctxKeyIsAPIRequest, isAPIRequest(r))
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
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)
authR := r.PathPrefix("/").Subrouter()
authR.HandleFunc("/", renderTemplate(a.indexTmpl))
authR.HandleFunc("/paste", renderTemplate(a.pasteTmpl))
authR.HandleFunc("/upload", a.upload).Methods("POST", "PUT")
authR.HandleFunc("/upload/{filename}", a.upload).Methods("PUT")
publicR := r.PathPrefix("/").Subrouter()
publicR.HandleFunc("/raw/{filename}", a.rawDownload)
publicR.HandleFunc("/{filename}", a.view)
if a.authMiddleware != nil {
authR.Use(contextPopulateMiddleware(true))
authR.Use(a.authMiddleware)
publicR.Use(contextPopulateMiddleware(false))
publicR.Use(a.authMiddleware)
}
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,
authMiddleware: cfg.AuthMiddleware,
}
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
}