We support several methods of serving assets: * Redirect to blobstore - This requires the backend to support signed URLs. We rely on the backend to support HTTP semantics, like supporting Range headers. * Serve-using-http.ServeContent - This requires the backend to actually be providing a io.ReadSeeker. net/http provides Range/If- conditional support. * Serve-proxy - This is the safest and most compatible method. We don't support conditionals nor Range headers. This mode is unlikely to be suitable for multimedia, like MP3s or video.
254 lines
6.5 KiB
Go
254 lines
6.5 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 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"]
|
|
|
|
attrs, err := a.storageBackend.Attributes(ctx, filename)
|
|
switch gcerrors.Code(err) {
|
|
case gcerrors.NotFound:
|
|
a.notFound(rw, r)
|
|
return
|
|
default:
|
|
log.Printf("rawDownload(%q) Attributes: %v", filename, err)
|
|
a.internalError(rw, r)
|
|
return
|
|
case gcerrors.OK:
|
|
// OK
|
|
}
|
|
|
|
// Do things with attributes?
|
|
if a.redirectToBlobstore {
|
|
u, err := a.storageBackend.SignedURL(ctx, filename, &blob.SignedURLOptions{
|
|
Expiry: a.redirectExpiry,
|
|
})
|
|
switch gcerrors.Code(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, attrs)
|
|
}
|
|
|
|
func (a *Application) rawDownloadDirect(ctx context.Context, rw http.ResponseWriter, r *http.Request, filename string, attrs *blob.Attributes) {
|
|
// TODO(lukegb): Range header and conditionals?.
|
|
rdr, err := a.storageBackend.NewReader(ctx, filename, nil)
|
|
switch gcerrors.Code(err) {
|
|
case gcerrors.NotFound:
|
|
a.notFound(rw, r)
|
|
return
|
|
default:
|
|
a.internalError(rw, r)
|
|
return
|
|
case gcerrors.OK:
|
|
// OK
|
|
}
|
|
defer rdr.Close()
|
|
|
|
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
|
|
}
|