This is a very basic implementation of gating that we only return files which haven't expired. If the file has expired, then we just delete it.
293 lines
7.7 KiB
Go
293 lines
7.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"
|
|
"strconv"
|
|
"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"]
|
|
|
|
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
|
|
}
|
|
|
|
var exp time.Time
|
|
if expStr, ok := attrs.Metadata["expires-at"]; ok {
|
|
// Check for expiry.
|
|
expSec, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
log.Printf("parsing expiration %q for %q: %v", expStr, filename, err)
|
|
a.internalError(rw, r)
|
|
return
|
|
}
|
|
|
|
exp = time.Unix(expSec, 0)
|
|
if exp.Before(time.Now()) {
|
|
// This file has expired. Delete it from the backing storage.
|
|
err := a.storageBackend.Delete(ctx, filename)
|
|
switch gcerrors.Code(err) {
|
|
case gcerrors.NotFound:
|
|
// Something probably deleted it before we could get there.
|
|
case gcerrors.OK:
|
|
// This was fine.
|
|
default:
|
|
log.Printf("deleting expired file %q: %v", filename, err)
|
|
}
|
|
a.notFound(rw, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Do things with attributes?
|
|
if a.redirectToBlobstore {
|
|
redirectExpiry := a.redirectExpiry
|
|
if !exp.IsZero() {
|
|
// Cap the redirect lifetime to the remaining lifetime of the object.
|
|
remainingLifetime := exp.Sub(time.Now())
|
|
if remainingLifetime < redirectExpiry {
|
|
redirectExpiry = remainingLifetime
|
|
}
|
|
}
|
|
u, err := a.storageBackend.SignedURL(ctx, filename, &blob.SignedURLOptions{
|
|
Expiry: 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
|
|
}
|