// SPDX-FileCopyrightText: 2021 Luke Granger-Brown // // 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 }