depot/web/fup/fuphttp/fuphttp.go
Luke Granger-Brown 4c4bd46aa8 fup: factor out metadata retrieval into a separate file
We'll need this for cleanup operations as well. This should likely be factored
out again into an entirely separate package that deals with storage access.
2021-03-21 15:17:46 +00:00

266 lines
7 KiB
Go

// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
//
// SPDX-License-Identifier: Apache-2.0
package fuphttp
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"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"
"gocloud.dev/gcerrors"
"hg.lukegb.com/lukegb/depot/web/fup/hashfs"
)
const (
defaultRedirectExpiry = 5 * time.Minute
)
type Config struct {
Templates fs.FS
Static fs.FS
StaticRoot safehtml.TrustedResourceURL
// 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
}
type Application struct {
indexTmpl *template.Template
notFoundTmpl *template.Template
storageBackend *blob.Bucket
redirectToBlobstore bool
redirectExpiry time.Duration
}
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("/raw/{filename}", a.rawDownload)
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")
rw.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(rw, "hammed server :(\n")
}
func (a *Application) rawDownload(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vars := mux.Vars(r)
filename := vars["filename"]
meta, err := metadata(ctx, a.storageBackend, filename)
switch errorCode(err) {
case gcerrors.NotFound:
a.notFound(rw, r)
return
default:
log.Printf("rawDownload(%q) metadata: %v", filename, err)
a.internalError(rw, r)
return
case gcerrors.OK:
// OK
}
// Do things with attributes?
if a.redirectToBlobstore {
redirectExpiry := a.redirectExpiry
if !meta.ExpiresAt.IsZero() {
// Cap the redirect lifetime to the remaining lifetime of the object.
remainingLifetime := meta.ExpiresAt.Sub(time.Now())
if remainingLifetime < redirectExpiry {
redirectExpiry = remainingLifetime
}
}
u, err := a.storageBackend.SignedURL(ctx, filename, &blob.SignedURLOptions{
Expiry: redirectExpiry,
})
switch errorCode(err) {
case gcerrors.NotFound:
// This is unlikely to get returned.
a.notFound(rw, r)
return
case gcerrors.OK:
http.Redirect(rw, r, u, http.StatusFound)
return
case gcerrors.Unimplemented:
// Fall back to serving directly.
default:
log.Printf("rawDownload(%q) SignedURL (continuing with fallback): %v", filename, err)
}
}
// Perform direct serving.
a.rawDownloadDirect(ctx, rw, r, filename, meta)
}
func (a *Application) rawDownloadDirect(ctx context.Context, rw http.ResponseWriter, r *http.Request, filename string, meta *Metadata) {
// TODO(lukegb): Range header and conditionals?.
rdr, err := a.storageBackend.NewReader(ctx, filename, nil)
switch errorCode(err) {
case gcerrors.NotFound:
a.notFound(rw, r)
return
default:
a.internalError(rw, r)
return
case gcerrors.OK:
// OK
}
defer rdr.Close()
attrs := meta.Attributes
if v := attrs.CacheControl; v != "" {
rw.Header().Set("Cache-Control", v)
}
if v := attrs.ContentDisposition; v != "" {
rw.Header().Set("Content-Disposition", v)
}
if v := attrs.ContentEncoding; v != "" {
rw.Header().Set("Content-Encoding", v)
}
if v := attrs.ContentLanguage; v != "" {
rw.Header().Set("Content-Language", v)
}
if v := attrs.ContentType; v != "" {
rw.Header().Set("Content-Type", v)
}
if v := attrs.ETag; v != "" {
rw.Header().Set("ETag", v)
}
// If we're serving from local disk, use http.ServeContent, using this somewhat opaque method.
var ior io.Reader
if rdr.As(&ior) {
if iors, ok := ior.(io.ReadSeeker); ok {
http.ServeContent(rw, r, filename, attrs.ModTime, iors)
return
}
}
if v := attrs.ModTime; !v.IsZero() {
rw.Header().Set("Last-Modified", v.UTC().Format(http.TimeFormat))
}
if v := attrs.Size; v != 0 {
rw.Header().Set("Content-Length", fmt.Sprintf("%d", v))
}
if _, err := io.Copy(rw, rdr); err != nil {
log.Printf("rawDownloadDirect(%q) copy: %v", filename, err)
}
}
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,
}
if a.redirectExpiry == 0 {
a.redirectExpiry = defaultRedirectExpiry
}
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.notFoundTmpl, "404"},
}
funcMap := template.FuncMap{
"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
}