From 2e219e8ef69d0739e48da250a0ae6df15e40bb63 Mon Sep 17 00:00:00 2001 From: infogulch Date: Wed, 18 Oct 2023 23:15:00 -0500 Subject: [PATCH] Refactor file handler, part 2 --- caddy/module.go | 28 +-- cmd/main.go | 7 +- integration/templates/subdir/file.html | 2 + integration/templates/subdir/index.html | 2 + integration/tests/routing.hurl | 19 ++ integration/tests/templates.hurl | 6 + serve.go | 314 ++++++++++++++++++------ templates.go | 51 ++-- tplcontext.go | 154 ++---------- 9 files changed, 324 insertions(+), 259 deletions(-) create mode 100644 integration/templates/subdir/file.html create mode 100644 integration/templates/subdir/index.html create mode 100644 integration/tests/routing.hurl create mode 100644 integration/tests/templates.hurl diff --git a/caddy/module.go b/caddy/module.go index 371ee67..edd0427 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -57,8 +57,9 @@ type XTemplateModule struct { FuncsModules []string `json:"funcs_modules,omitempty"` - template *xtemplate.XTemplate - halt chan<- struct{} + handler http.Handler + db *sql.DB + halt chan<- struct{} } type FuncsProvider interface { @@ -136,6 +137,7 @@ func (m *XTemplateModule) Provision(ctx caddy.Context) error { return err } t.DB = db + m.db = db } if len(m.Delimiters) != 0 { @@ -147,14 +149,13 @@ func (m *XTemplateModule) Provision(ctx caddy.Context) error { } { - err := t.Reload() + h, err := t.Build() if err != nil { return err } + m.handler = h } - m.template = t - if len(watchDirs) > 0 { changed, halt, err := watch.WatchDirs(watchDirs, 200*time.Millisecond) if err != nil { @@ -162,10 +163,11 @@ func (m *XTemplateModule) Provision(ctx caddy.Context) error { } m.halt = halt watch.React(changed, halt, func() (halt bool) { - err := t.Reload() + newhandler, err := t.Build() if err != nil { log.Info("failed to reload xtemplate", "error", err) } else { + m.handler = newhandler log.Info("reloaded templates after file changed") } return @@ -175,7 +177,7 @@ func (m *XTemplateModule) Provision(ctx caddy.Context) error { } func (m *XTemplateModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error { - m.template.ServeHTTP(w, r) + m.handler.ServeHTTP(w, r) return nil } @@ -186,14 +188,10 @@ func (m *XTemplateModule) Cleanup() error { close(m.halt) m.halt = nil } - if m.template != nil { - var dberr error - if m.template.DB != nil { - dberr = m.template.DB.Close() - m.template.DB = nil - } - m.template = nil - return dberr + if m.db != nil { + err := m.db.Close() + m.db = nil + return err } return nil } diff --git a/cmd/main.go b/cmd/main.go index 54b3ac3..e298048 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -83,7 +83,7 @@ func main() { DB: db, Log: log.WithGroup("xtemplate"), } - err = x.Reload() + handler, err := x.Build() if err != nil { log.Error("failed to load xtemplate", "error", err) os.Exit(2) @@ -109,10 +109,11 @@ func main() { os.Exit(4) } watch.React(changed, halt, func() (halt bool) { - err := x.Reload() + newhandler, err := x.Build() if err != nil { log.Info("failed to reload xtemplate", "error", err) } else { + handler = newhandler log.Info("reloaded templates after file changed") } return @@ -121,7 +122,7 @@ func main() { } log.Info("serving", "address", flags.listen_addr) - fmt.Printf("server stopped: %v\n", http.ListenAndServe(flags.listen_addr, &x)) + fmt.Printf("server stopped: %v\n", http.ListenAndServe(flags.listen_addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r) }))) } type kv struct{ Key, Value string } diff --git a/integration/templates/subdir/file.html b/integration/templates/subdir/file.html new file mode 100644 index 0000000..8ae04c6 --- /dev/null +++ b/integration/templates/subdir/file.html @@ -0,0 +1,2 @@ + +hello! diff --git a/integration/templates/subdir/index.html b/integration/templates/subdir/index.html new file mode 100644 index 0000000..938b112 --- /dev/null +++ b/integration/templates/subdir/index.html @@ -0,0 +1,2 @@ + +subdir diff --git a/integration/tests/routing.hurl b/integration/tests/routing.hurl new file mode 100644 index 0000000..dda0703 --- /dev/null +++ b/integration/tests/routing.hurl @@ -0,0 +1,19 @@ +# index +GET http://localhost:8080/ + +HTTP 200 +[Asserts] +body contains "

Hello world!

" + +# subdir +GET http://localhost:8080/subdir + +HTTP 200 +[Asserts] +body contains "subdir" + +GET http://localhost:8080/subdir/file + +HTTP 200 +[Asserts] +body contains "hello!" diff --git a/integration/tests/templates.hurl b/integration/tests/templates.hurl new file mode 100644 index 0000000..2116860 --- /dev/null +++ b/integration/tests/templates.hurl @@ -0,0 +1,6 @@ +# index +GET http://localhost:8080/visible + +HTTP 200 +[Asserts] +body contains "

You can't see me

" diff --git a/serve.go b/serve.go index 8c3d066..e21e5ba 100644 --- a/serve.go +++ b/serve.go @@ -5,134 +5,302 @@ import ( "context" "database/sql" "errors" + "fmt" + "html/template" + "io" "log/slog" + "math" "net/http" + "path" "strconv" + "strings" "time" + "github.com/infogulch/pathmatcher" "github.com/segmentio/ksuid" ) -func (t *XTemplate) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (rt *runtime) ServeHTTP(w http.ResponseWriter, r *http.Request) { start := time.Now() - runtime := t.runtime // copy the runtime in case it's updated during the request - _, template, params, _ := runtime.router.Find(r.Method, r.URL.Path) - if template == nil { - t.Log.Debug("no handler for request", "method", r.Method, "path", r.URL.Path) + _, handler, params, _ := rt.router.Find(r.Method, r.URL.Path) + if handler == nil { + rt.log.Debug("no handler for request", "method", r.Method, "path", r.URL.Path) http.NotFound(w, r) return } - log := t.Log.With(slog.Group("serve", + log := rt.log.With(slog.Group("serving", slog.String("requestid", getRequestId(r.Context())), slog.String("method", r.Method), slog.String("path", r.URL.Path), )) log.DebugContext(r.Context(), "serving request", - slog.String("template-name", template.Name()), slog.Any("params", params), slog.Duration("handler-lookup-duration", time.Since(start)), slog.String("user-agent", r.Header.Get("User-Agent")), ) - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - defer bufPool.Put(buf) + ctx := context.WithValue(r.Context(), ctxKey{}, ctxValue{params: params, log: log, runtime: rt}) + handler.ServeHTTP(w, r.WithContext(ctx)) - var tx *sql.Tx - var err error - if t.DB != nil { - tx, err = t.DB.Begin() - if err != nil { - log.Info("failed to begin database transaction", "error", err) - http.Error(w, "unable to connect to the database", http.StatusInternalServerError) - return + log.Debug("served", slog.Duration("response-duration", time.Since(start))) +} + +func getRequestId(ctx context.Context) string { + // caddy request id + if v := ctx.Value("vars"); v != nil { + if mv, ok := v.(map[string]any); ok { + if anyrid, ok := mv["uuid"]; ok { + if rid, ok := anyrid.(string); ok { + return rid + } + } } } + return ksuid.New().String() +} - var headers = http.Header{} - context := &TemplateContext{ - Req: r, - Params: params, +type ctxKey struct{} - Headers: WrappedHeader{headers}, - status: http.StatusOK, +type ctxValue struct { + params pathmatcher.Params + log *slog.Logger + runtime *runtime +} - log: log, - tx: tx, - runtime: runtime, - } +func getContext(ctx context.Context) (pathmatcher.Params, *slog.Logger, *runtime) { + val := ctx.Value(ctxKey{}).(ctxValue) + return val.params, val.log, val.runtime +} - r.ParseForm() - err = template.Execute(buf, context) +func serveTemplateHandler(tmpl *template.Template) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + params, log, runtime := getContext(r.Context()) - log.Debug("executed template", slog.Any("template error", err), slog.Int("length", buf.Len())) + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufPool.Put(buf) - var returnErr ReturnError - if err != nil && !errors.As(err, &returnErr) { - var handlerErr HandlerError - if errors.As(err, &handlerErr) { + var tx *sql.Tx + var err error + if runtime.db != nil { + tx, err = runtime.db.Begin() + if err != nil { + log.Info("failed to begin database transaction", "error", err) + http.Error(w, "unable to connect to the database", http.StatusInternalServerError) + return + } + } + + var headers = http.Header{} + context := &TemplateContext{ + Req: r, + Params: params, + + Headers: WrappedHeader{headers}, + status: http.StatusOK, + + log: log, + tx: tx, + runtime: runtime, + } + + r.ParseForm() + err = tmpl.Execute(buf, context) + + log.Debug("executed template", slog.Any("template error", err), slog.Int("length", buf.Len())) + + var returnErr ReturnError + if err != nil && !errors.As(err, &returnErr) { + var handlerErr HandlerError + if errors.As(err, &handlerErr) { + if tx != nil { + if dberr := tx.Commit(); dberr != nil { + log.Info("failed to commit transaction", "error", dberr) + } + } + log.Debug("forwarding response handling", "handler", handlerErr) + handlerErr.ServeHTTP(w, r) + return + } + log.Info("error executing template", "error", err) if tx != nil { - if dberr := tx.Commit(); dberr != nil { - log.Info("failed to commit transaction", "error", dberr) + if dberr := tx.Rollback(); dberr != nil { + log.Info("failed to roll back transaction", "error", dberr) } } - log.Debug("forwarding response handling", "handler", handlerErr) - handlerErr.ServeHTTP(w, r) + http.Error(w, "failed to render response", http.StatusInternalServerError) return + } else if tx != nil { + if dberr := tx.Commit(); dberr != nil { + log.Info("failed to commit transaction", "error", dberr) + http.Error(w, "failed to commit database transaction", http.StatusInternalServerError) + return + } } - log.Info("error executing template", "error", err) - if tx != nil { - if dberr := tx.Rollback(); dberr != nil { - log.Info("failed to roll back transaction", "error", dberr) + + wheader := w.Header() + for name, values := range headers { + for _, value := range values { + wheader.Add(name, value) } } - http.Error(w, "failed to render response", http.StatusInternalServerError) + + wheader.Set("Content-Type", "text/html; charset=utf-8") + wheader.Set("Content-Length", strconv.Itoa(buf.Len())) + wheader.Del("Accept-Ranges") // we don't know ranges for dynamically-created content + wheader.Del("Last-Modified") // useless for dynamic content since it's always changing + + // we don't know a way to quickly generate etag for dynamic content, + // and weak etags still cause browsers to rely on it even after a + // refresh, so disable them until we find a better way to do this + wheader.Del("Etag") + + w.WriteHeader(context.status) + w.Write(buf.Bytes()) + }) +} + +var serveFileHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, log, runtime := getContext(r.Context()) + + urlpath := path.Clean(r.URL.Path) + fileinfo, ok := runtime.files[urlpath] + if !ok { + // should not happen; we only add handlers for existent files + log.Error("tried to serve a file that doesn't exist", slog.String("path", urlpath), slog.String("urlpath", r.URL.Path)) + http.NotFound(w, r) return - } else if tx != nil { - if dberr := tx.Commit(); dberr != nil { - log.Info("failed to commit transaction", "error", dberr) - http.Error(w, "failed to commit database transaction", http.StatusInternalServerError) - return + } + + // if the request provides a hash, check that it matches. if not, we don't have that file + if queryhash := r.URL.Query().Get("hash"); queryhash != "" && queryhash != fileinfo.hash { + log.Debug("request for file with wrong hash query parameter", slog.String("expected", fileinfo.hash), slog.String("queryhash", queryhash)) + http.NotFound(w, r) + return + } + + // negotiate encoding between the client's q value preference and fileinfo.encodings ordering (prefer earlier listed encodings first) + encoding, err := negiotiateEncoding(r.Header["Accept-Encoding"], fileinfo.encodings) + if err != nil { + log.Error("error selecting encoding to serve", slog.Any("error", err)) + } + // we may have gotten an encoding even if there was an error; test separately + if encoding == nil { + http.Error(w, "internal server error", 500) + return + } + + log.Debug("serving file request", slog.String("path", urlpath), slog.String("encoding", encoding.encoding), slog.String("contenttype", fileinfo.contentType)) + file, err := runtime.templateFS.Open(encoding.path) + if err != nil { + log.Debug("failed to open file", slog.Any("error", err), slog.String("encoding.path", encoding.path), slog.String("requestpath", r.URL.Path)) + http.Error(w, "internal server error", 500) + return + } + defer file.Close() + + // check if file was modified since loading it + { + stat, err := file.Stat() + if err != nil { + log.Debug("error getting stat of file", slog.Any("error", err)) + } else if modtime := stat.ModTime(); !modtime.Equal(encoding.modtime) { + log.Error("file maybe modified since loading", slog.Time("expected-modtime", encoding.modtime), slog.Time("actual-modtime", modtime)) } } - wheader := w.Header() - for name, values := range headers { - for _, value := range values { - wheader.Add(name, value) + w.Header().Add("Etag", `"`+fileinfo.hash+`"`) + w.Header().Add("Content-Type", fileinfo.contentType) + w.Header().Add("Content-Encoding", encoding.encoding) + w.Header().Add("Vary", "Accept-Encoding") + // w.Header().Add("Access-Control-Allow-Origin", "*") // ??? + if r.URL.Query().Get("hash") != "" { + // cache aggressively if the request is disambiguated by a valid hash + // should be `public` ??? + w.Header().Set("Cache-Control", "public, max-age=31536000") + } + http.ServeContent(w, r, encoding.path, encoding.modtime, file.(io.ReadSeeker)) +}) + +func negiotiateEncoding(acceptHeaders []string, encodings []encodingInfo) (*encodingInfo, error) { + var err error + // shortcuts + if len(encodings) == 0 { + return nil, fmt.Errorf("impossible condition, fileInfo contains no encodings") + } + if len(encodings) == 1 { + if encodings[0].encoding != "identity" { + // identity should always be present, but return whatever we got anyway + err = fmt.Errorf("identity encoding missing") } + return &encodings[0], err } - wheader.Set("Content-Type", "text/html; charset=utf-8") - wheader.Set("Content-Length", strconv.Itoa(buf.Len())) - wheader.Del("Accept-Ranges") // we don't know ranges for dynamically-created content - wheader.Del("Last-Modified") // useless for dynamic content since it's always changing + // default to identity encoding, q = 0.0 + var maxq float64 + var maxqIdx int = -1 + for i, e := range encodings { + if e.encoding == "identity" { + maxqIdx = i + break + } + } + if maxqIdx == -1 { + err = fmt.Errorf("identity encoding missing") + maxqIdx = len(encodings) - 1 + } - // we don't know a way to quickly generate etag for dynamic content, - // and weak etags still cause browsers to rely on it even after a - // refresh, so disable them until we find a better way to do this - wheader.Del("Etag") + for _, header := range acceptHeaders { + header = strings.TrimSpace(header) + if header == "" { + continue + } + for _, requestedEncoding := range strings.Split(header, ",") { + requestedEncoding = strings.TrimSpace(requestedEncoding) + if requestedEncoding == "" { + continue + } - w.WriteHeader(context.status) - w.Write(buf.Bytes()) + parts := strings.Split(requestedEncoding, ";") + encpart := strings.TrimSpace(parts[0]) + requestedIdx := -1 - log.Debug("done", slog.Duration("response-duration", time.Since(start)), slog.Int("response-status", context.status)) -} + // find out if we can provide that encoding + for i, e := range encodings { + if e.encoding == encpart { + requestedIdx = i + break + } + } + if requestedIdx == -1 { + continue // we don't support that encoding, try next + } -func getRequestId(ctx context.Context) string { - // caddy request id - if v := ctx.Value("vars"); v != nil { - if mv, ok := v.(map[string]any); ok { - if anyrid, ok := mv["uuid"]; ok { - if rid, ok := anyrid.(string); ok { - return rid + // determine q value + q := 1.0 // default 1.0 + for _, part := range parts[1:] { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "q=") { + part = strings.TrimSpace(strings.TrimPrefix(part, "q=")) + if parsed, err := strconv.ParseFloat(part, 64); err == nil { + q = parsed + break + } } } + + // use this encoding over previously selected encoding if: + // 1. client has a strong preference for this encoding, OR + // 2. client's preference is small and this encoding is listed earlier + if q-maxq > 0.1 || (math.Abs(q-maxq) <= 0.1 && requestedIdx < maxqIdx) { + maxq = q + maxqIdx = requestedIdx + } } } - return ksuid.New().String() + return &encodings[maxqIdx], err } // ReturnError is a sentinel value returned by the `return` template diff --git a/templates.go b/templates.go index 702e88d..408339c 100644 --- a/templates.go +++ b/templates.go @@ -46,9 +46,11 @@ type runtime struct { contextFS fs.FS config map[string]string funcs template.FuncMap + db *sql.DB templates *template.Template - router *pathmatcher.HttpMatcher[*template.Template] + router *pathmatcher.HttpMatcher[http.Handler] files map[string]fileInfo + log *slog.Logger } type fileInfo struct { @@ -62,7 +64,7 @@ type encodingInfo struct { modtime time.Time } -func (t *XTemplate) Reload() error { +func (t *XTemplate) Build() (http.Handler, error) { log := t.Log.WithGroup("reload") r := &runtime{ @@ -70,9 +72,11 @@ func (t *XTemplate) Reload() error { contextFS: t.ContextFS, config: t.Config, delims: t.Delims, + db: t.DB, funcs: make(template.FuncMap), files: make(map[string]fileInfo), - router: pathmatcher.NewHttpMatcher[*template.Template](), + router: pathmatcher.NewHttpMatcher[http.Handler](), + log: t.Log, } // Init funcs @@ -85,8 +89,6 @@ func (t *XTemplate) Reload() error { // Define the template instance that will accumulate all template definitions. r.templates = template.New(".").Delims(r.delims.L, r.delims.R).Funcs(r.funcs) - r.templates.AddParseTree("servefile", serveFileTemplate) - // scan all files from the templatefs root if err := fs.WalkDir(r.templateFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -102,11 +104,9 @@ func (t *XTemplate) Reload() error { } return err }); err != nil { - return fmt.Errorf("error scanning files: %v", err) + return nil, fmt.Errorf("error scanning files: %v", err) } - log.Debug("router", slog.Any("router", r.router)) - // Invoke all initilization templates, aka any template whose name starts with "INIT " for _, tmpl := range r.templates.Templates() { if strings.HasPrefix(tmpl.Name(), "INIT ") { @@ -115,7 +115,7 @@ func (t *XTemplate) Reload() error { if t.DB != nil { tx, err = t.DB.Begin() if err != nil { - return fmt.Errorf("failed to begin transaction for '%s': %w", tmpl.Name(), err) + return nil, fmt.Errorf("failed to begin transaction for '%s': %w", tmpl.Name(), err) } } err = tmpl.Execute(io.Discard, &TemplateContext{ @@ -130,33 +130,30 @@ func (t *XTemplate) Reload() error { err = errors.Join(err, txerr) } } - return fmt.Errorf("template initializer '%s' failed: %w", tmpl.Name(), err) + return nil, fmt.Errorf("template initializer '%s' failed: %w", tmpl.Name(), err) } if tx != nil { err = tx.Commit() if err != nil { - return fmt.Errorf("template initializer commit failed: %w", err) + return nil, fmt.Errorf("template initializer commit failed: %w", err) } } } } - // Set runtime in one pointer assignment, avoiding race conditions where the - // inner fields don't match. - t.runtime = r - return nil + return r, nil } func (r *runtime) handleStaticFile(path_, ext string, log *slog.Logger) error { fsfile, err := r.templateFS.Open(path_) if err != nil { - return fmt.Errorf("could not open raw file '%s': %w", path_, err) + return fmt.Errorf("failed to open static file '%s': %w", path_, err) } defer fsfile.Close() seeker := fsfile.(io.ReadSeeker) stat, err := fsfile.Stat() if err != nil { - return fmt.Errorf("could not stat file '%s': %w", path_, err) + return fmt.Errorf("failed to stat file '%s': %w", path_, err) } size := stat.Size() @@ -178,7 +175,7 @@ func (r *runtime) handleStaticFile(path_, ext string, log *slog.Logger) error { encoding = "br" } if err != nil { - return fmt.Errorf("could not create decompressor for file `%s`: %w", path_, err) + return fmt.Errorf("failed to create decompressor for file `%s`: %w", path_, err) } } else { basepath = path.Clean("/" + path_) @@ -187,7 +184,7 @@ func (r *runtime) handleStaticFile(path_, ext string, log *slog.Logger) error { hash := sha512.New384() _, err = io.Copy(hash, reader) if err != nil { - return fmt.Errorf("could not hash file %w", err) + return fmt.Errorf("failed to hash file %w", err) } sri = "sha384-" + base64.StdEncoding.EncodeToString(hash.Sum(nil)) } @@ -206,9 +203,8 @@ func (r *runtime) handleStaticFile(path_, ext string, log *slog.Logger) error { file.contentType = http.DetectContentType(content[:count]) } file.encodings = []encodingInfo{{encoding: encoding, path: path_, size: size, modtime: stat.ModTime()}} - tmpl := r.templates.Lookup("servefile") - r.router.Add("GET", basepath, tmpl) - r.router.Add("HEAD", basepath, tmpl) + r.router.Add("GET", basepath, serveFileHandler) + r.router.Add("HEAD", basepath, serveFileHandler) log.Debug("added static file handler", slog.String("path", basepath), slog.String("filepath", path_), slog.String("contenttype", file.contentType), slog.Int64("size", size), slog.Time("modtime", stat.ModTime()), slog.String("hash", sri)) } else { if file.hash != sri { @@ -247,24 +243,17 @@ func (r *runtime) handleTemplateFile(path_, ext string, log *slog.Logger) error if path.Base(routePath) == "index" { routePath = path.Dir(routePath) } - r.router.Add("GET", routePath, tmpl) + r.router.Add("GET", routePath, serveTemplateHandler(tmpl)) log.Debug("added path template handler", "method", "GET", "path", routePath, "template_path", path_) } else if matches := routeMatcher.FindStringSubmatch(name); len(matches) == 3 { method, path_ := matches[1], matches[2] - r.router.Add(method, path_, tmpl) + r.router.Add(method, path_, serveTemplateHandler(tmpl)) log.Debug("added named template handler", "method", method, "path", path_, "template_name", name, "template_path", path_) } } return nil } -var serveFileTemplate *parse.Tree - -func init() { - serveFiles, _ := parse.Parse("servefile", "{{.ServeFile}}", "{{", "}}") - serveFileTemplate = serveFiles["servefile"] -} - var extensionContentTypes map[string]string = map[string]string{ ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", diff --git a/tplcontext.go b/tplcontext.go index 914d681..2dcb9e1 100644 --- a/tplcontext.go +++ b/tplcontext.go @@ -8,11 +8,9 @@ import ( "io" "io/fs" "log/slog" - "math" "net" "net/http" "path" - "strconv" "strings" "sync" "time" @@ -137,7 +135,7 @@ func (c *TemplateContext) ListFiles(name string) ([]string, error) { return names, nil } -// funcFileExists returns true if filename can be opened successfully. +// FileExists returns true if filename can be opened successfully. func (c *TemplateContext) FileExists(filename string) (bool, error) { if c.runtime.contextFS == nil { return false, fmt.Errorf("context file system is not configured") @@ -150,155 +148,37 @@ func (c *TemplateContext) FileExists(filename string) (bool, error) { return false, nil } -func (c *TemplateContext) SRI(urlpath string) (string, error) { - urlpath = path.Clean("/" + urlpath) - fileinfo, ok := c.runtime.files[urlpath] - if !ok { - return "", fmt.Errorf("file does not exist: '%s'", urlpath) - } - return fileinfo.hash, nil -} - -func (c *TemplateContext) ServeFile() (string, error) { +// ServeFile aborts execution of the template and instead responds to the request with the content of the contextfs file at urlpath +func (c *TemplateContext) ServeFile(path_ string) (string, error) { return "", NewHandlerError("ServeFile", func(w http.ResponseWriter, r *http.Request) { - // find file - urlpath := path.Clean(r.URL.Path) - fileinfo, ok := c.runtime.files[urlpath] - if !ok { - // should not happen; we only add handlers for existent files - c.log.Error("tried to serve a file that doesn't exist", slog.String("path", urlpath), slog.String("urlpath", r.URL.Path)) - http.NotFound(w, r) - return - } + path_ = path.Clean(path_) - // if the request provides a hash, check that it matches. if not, we don't have that file - if queryhash := r.URL.Query().Get("hash"); queryhash != "" && queryhash != fileinfo.hash { - c.log.Debug("request for file with wrong hash query parameter", slog.String("expected", fileinfo.hash), slog.String("queryhash", queryhash)) - http.NotFound(w, r) - return - } - - // negotiate encoding between the client's q value preference and fileinfo.encodings ordering (prefer earlier listed encodings first) - encoding, err := negiotiateEncoding(r.Header["Accept-Encoding"], fileinfo.encodings) - if err != nil { - c.log.Error("error selecting encoding to serve", slog.Any("error", err)) - } - // we may have gotten an encoding even if there was an error; test separately - if encoding == nil { - http.Error(w, "internal server error", 500) - return - } + c.log.Debug("serving file request", slog.String("path", path_)) - c.log.Debug("serving file request", slog.String("path", urlpath), slog.String("encoding", encoding.encoding), slog.String("contenttype", fileinfo.contentType)) - file, err := c.runtime.templateFS.Open(encoding.path) + file, err := c.runtime.contextFS.Open(path_) if err != nil { - c.log.Debug("failed to open file", slog.Any("error", err), slog.String("encoding.path", encoding.path), slog.String("requestpath", r.URL.Path)) + c.log.Debug("failed to open file", slog.Any("error", err), slog.String("path", path_)) http.Error(w, "internal server error", 500) return } defer file.Close() - // check if file was modified since loading it - { - stat, err := file.Stat() - if err != nil { - c.log.Debug("error getting stat of file", slog.Any("error", err)) - } else if modtime := stat.ModTime(); !modtime.Equal(encoding.modtime) { - c.log.Error("file maybe modified since loading", slog.Time("expected-modtime", encoding.modtime), slog.Time("actual-modtime", modtime)) - } + stat, err := file.Stat() + if err != nil { + c.log.Debug("error getting stat of file", slog.Any("error", err), slog.String("path", path_)) } - w.Header().Add("Etag", `"`+fileinfo.hash+`"`) - w.Header().Add("Content-Type", fileinfo.contentType) - w.Header().Add("Content-Encoding", encoding.encoding) - w.Header().Add("Vary", "Accept-Encoding") - // w.Header().Add("Access-Control-Allow-Origin", "*") // ??? - if r.URL.Query().Get("hash") != "" { - // cache aggressively if the request is disambiguated by a valid hash - // should be `public` ??? - w.Header().Set("Cache-Control", "public, max-age=31536000") - } - http.ServeContent(w, r, encoding.path, encoding.modtime, file.(io.ReadSeeker)) + http.ServeContent(w, r, path_, stat.ModTime(), file.(io.ReadSeeker)) }) } -func negiotiateEncoding(acceptHeaders []string, encodings []encodingInfo) (*encodingInfo, error) { - var err error - // shortcuts - if len(encodings) == 0 { - return nil, fmt.Errorf("impossible condition, fileInfo contains no encodings") - } - if len(encodings) == 1 { - if encodings[0].encoding != "identity" { - // identity should always be present, but return whatever we got anyway - err = fmt.Errorf("identity encoding missing") - } - return &encodings[0], err - } - - // default to identity encoding, q = 0.0 - var maxq float64 - var maxqIdx int = -1 - for i, e := range encodings { - if e.encoding == "identity" { - maxqIdx = i - break - } - } - if maxqIdx == -1 { - err = fmt.Errorf("identity encoding missing") - maxqIdx = len(encodings) - 1 - } - - for _, header := range acceptHeaders { - header = strings.TrimSpace(header) - if header == "" { - continue - } - for _, requestedEncoding := range strings.Split(header, ",") { - requestedEncoding = strings.TrimSpace(requestedEncoding) - if requestedEncoding == "" { - continue - } - - parts := strings.Split(requestedEncoding, ";") - encpart := strings.TrimSpace(parts[0]) - requestedIdx := -1 - - // find out if we can provide that encoding - for i, e := range encodings { - if e.encoding == encpart { - requestedIdx = i - break - } - } - if requestedIdx == -1 { - continue // we don't support that encoding, try next - } - - // determine q value - q := 1.0 // default 1.0 - for _, part := range parts[1:] { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "q=") { - part = strings.TrimSpace(strings.TrimPrefix(part, "q=")) - if parsed, err := strconv.ParseFloat(part, 64); err == nil { - q = parsed - break - } - } - } - - // use this encoding over previously selected encoding if: - // 1. client has a strong preference for this encoding, OR - // 2. client's preference is small and this encoding is listed earlier - if q-maxq > 0.1 || (math.Abs(q-maxq) <= 0.1 && requestedIdx < maxqIdx) { - maxq = q - maxqIdx = requestedIdx - } - } +func (c *TemplateContext) SRI(urlpath string) (string, error) { + urlpath = path.Clean("/" + urlpath) + fileinfo, ok := c.runtime.files[urlpath] + if !ok { + return "", fmt.Errorf("file does not exist: '%s'", urlpath) } - return &encodings[maxqIdx], err + return fileinfo.hash, nil } func (c *TemplateContext) Exec(query string, params ...any) (result sql.Result, err error) {