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