Cache Settings for DEV mode

This commit is contained in:
Simon Martens
2025-05-21 14:02:05 +02:00
parent fca2b0dd5e
commit 86b25946eb
10 changed files with 756 additions and 18 deletions

View File

@@ -43,6 +43,7 @@ type Config struct {
WebHookEndpoint string `json:"webhook_endpoint" envconfig:"WEBHOOK_ENDPOINT"`
WebHookSecret string `json:"webhook_secret" envconfig:"WEBHOOK_SECRET"`
Debug bool `json:"debug" envconfig:"DEBUG"`
Cache bool `json:"cache" envconfig:"CACHE"`
Watch bool `json:"watch" envconfig:"WATCH"`
LogData bool `json:"log_data" envconfig:"LOG_DATA"`

View File

@@ -29,7 +29,7 @@ func Register(server server.Server, cfg config.Config) {
Level: compress.LevelBestSpeed,
}))
server.Server.Use(ASSETS_URL, etag.New())
server.Server.Use(ASSETS_URL, middleware.StaticHandler(&views.StaticFS))
server.Server.Use(ASSETS_URL, middleware.StaticHandler(&views.StaticFS, cfg.Debug))
server.Server.Use(func(ctx *fiber.Ctx) error {
ctx.Locals("cfg", cfg)

141
helpers/middleware/etag.go Normal file
View File

@@ -0,0 +1,141 @@
package middleware
import (
"bytes"
"hash/crc32"
"github.com/gofiber/fiber/v2"
"github.com/valyala/bytebufferpool"
)
// Config defines the config for middleware.
type Config struct {
// Weak indicates that a weak validator is used. Weak etags are easy
// to generate, but are far less useful for comparisons. Strong
// validators are ideal for comparisons but can be very difficult
// to generate efficiently. Weak ETag values of two representations
// of the same resources might be semantically equivalent, but not
// byte-for-byte identical. This means weak etags prevent caching
// when byte range requests are used, but strong etags mean range
// requests can still be cached.
Weak bool
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: nil
Next func(c *fiber.Ctx) bool
}
// ConfigDefault is the default config
var ConfigDefault = Config{
Weak: false,
Next: nil,
}
// Helper function to set default values
func configDefault(config ...Config) Config {
// Return default config if nothing provided
if len(config) < 1 {
return ConfigDefault
}
// Override default config
cfg := config[0]
// Set default values
return cfg
}
func New() fiber.Handler {
var (
normalizedHeaderETag = []byte("Etag")
weakPrefix = []byte("W/")
)
const crcPol = 0xD5828281
crc32q := crc32.MakeTable(crcPol)
// Return new handler
return func(c *fiber.Ctx) error {
// Return err if next handler returns one
if err := c.Next(); err != nil {
return err
}
// Don't generate ETags for invalid responses
if c.Response().StatusCode() != fiber.StatusOK {
return nil
}
body := c.Response().Body()
// Skips ETag if no response body is present
if len(body) == 0 {
return nil
}
// Skip ETag if header is already present
if c.Response().Header.PeekBytes(normalizedHeaderETag) != nil {
return nil
}
// Generate ETag for response
bb := bytebufferpool.Get()
defer bytebufferpool.Put(bb)
_ = bb.WriteByte('"') //nolint:errcheck // This will never fail
bb.B = appendUint(bb.Bytes(), uint32(len(body)))
_ = bb.WriteByte('-') //nolint:errcheck // This will never fail
bb.B = appendUint(bb.Bytes(), crc32.Checksum(body, crc32q))
_ = bb.WriteByte('"') //nolint:errcheck // This will never fail
etag := bb.Bytes()
// Get ETag header from request
clientEtag := c.Request().Header.Peek(fiber.HeaderIfNoneMatch)
// Check if client's ETag is weak
if bytes.HasPrefix(clientEtag, weakPrefix) {
// Check if server's ETag is weak
if bytes.Equal(clientEtag[2:], etag) || bytes.Equal(clientEtag[2:], etag[2:]) {
// W/1 == 1 || W/1 == W/1
c.Context().ResetBody()
return c.SendStatus(fiber.StatusNotModified)
}
// W/1 != W/2 || W/1 != 2
c.Response().Header.SetCanonical(normalizedHeaderETag, etag)
return nil
}
if bytes.Contains(clientEtag, etag) {
// 1 == 1
c.Context().ResetBody()
return c.SendStatus(fiber.StatusNotModified)
}
// 1 != 2
c.Response().Header.SetCanonical(normalizedHeaderETag, etag)
return nil
}
}
// appendUint appends n to dst and returns the extended dst.
func appendUint(dst []byte, n uint32) []byte {
var b [20]byte
buf := b[:]
i := len(buf)
var q uint32
for n >= 10 {
i--
q = n / 10
buf[i] = '0' + byte(n-q*10)
n = q
}
i--
buf[i] = '0' + byte(n)
dst = append(dst, buf[i:]...)
return dst
}

View File

@@ -0,0 +1,298 @@
package filesystem
import (
"errors"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/utils"
)
// Config defines the config for middleware.
type Config struct {
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: nil
Next func(c *fiber.Ctx) bool
// Root is a FileSystem that provides access
// to a collection of files and directories.
//
// Required. Default: nil
Root http.FileSystem `json:"-"`
// PathPrefix defines a prefix to be added to a filepath when
// reading a file from the FileSystem.
//
// Use when using Go 1.16 embed.FS
//
// Optional. Default ""
PathPrefix string `json:"path_prefix"`
// Enable directory browsing.
//
// Optional. Default: false
Browse bool `json:"browse"`
// Index file for serving a directory.
//
// Optional. Default: "index.html"
Index string `json:"index"`
// The value for the Cache-Control HTTP-header
// that is set on the file response. MaxAge is defined in seconds.
//
// Optional. Default value 0.
MaxAge int `json:"max_age"`
// File to return if path is not found. Useful for SPA's.
//
// Optional. Default: ""
NotFoundFile string `json:"not_found_file"`
// The value for the Content-Type HTTP-header
// that is set on the file response
//
// Optional. Default: ""
ContentTypeCharset string `json:"content_type_charset"`
// The value for the Content-Type HTTP-header
// that is set on the file response
//
// Optional. Default: true
Dev bool `json:"dev"`
}
// ConfigDefault is the default config
var ConfigDefault = Config{
Next: nil,
Root: nil,
PathPrefix: "",
Browse: false,
Index: "/index.html",
MaxAge: 0,
ContentTypeCharset: "",
Dev: true,
}
// New creates a new middleware handler.
//
// filesystem does not handle url encoded values (for example spaces)
// on it's own. If you need that functionality, set "UnescapePath"
// in fiber.Config
func New(config ...Config) fiber.Handler {
// Set default config
cfg := ConfigDefault
// Override config if provided
if len(config) > 0 {
cfg = config[0]
// Set default values
if cfg.Index == "" {
cfg.Index = ConfigDefault.Index
}
if !strings.HasPrefix(cfg.Index, "/") {
cfg.Index = "/" + cfg.Index
}
if cfg.NotFoundFile != "" && !strings.HasPrefix(cfg.NotFoundFile, "/") {
cfg.NotFoundFile = "/" + cfg.NotFoundFile
}
}
if cfg.Root == nil {
panic("filesystem: Root cannot be nil")
}
if cfg.PathPrefix != "" && !strings.HasPrefix(cfg.PathPrefix, "/") {
cfg.PathPrefix = "/" + cfg.PathPrefix
}
var once sync.Once
var prefix string
cacheControlStr := "public, max-age=" + strconv.Itoa(cfg.MaxAge)
if cfg.Dev {
cacheControlStr = "no-cache, must-revalidate"
}
// Return new handler
return func(c *fiber.Ctx) error {
// Don't execute middleware if Next returns true
if cfg.Next != nil && cfg.Next(c) {
return c.Next()
}
method := c.Method()
// We only serve static assets on GET or HEAD methods
if method != fiber.MethodGet && method != fiber.MethodHead {
return c.Next()
}
// Set prefix once
once.Do(func() {
prefix = c.Route().Path
})
// Strip prefix
path := strings.TrimPrefix(c.Path(), prefix)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// Add PathPrefix
if cfg.PathPrefix != "" {
// PathPrefix already has a "/" prefix
path = cfg.PathPrefix + path
}
if len(path) > 1 {
path = utils.TrimRight(path, '/')
}
file, err := cfg.Root.Open(path)
if err != nil && errors.Is(err, fs.ErrNotExist) && cfg.NotFoundFile != "" {
file, err = cfg.Root.Open(cfg.NotFoundFile)
}
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return c.Status(fiber.StatusNotFound).Next()
}
return fmt.Errorf("failed to open: %w", err)
}
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat: %w", err)
}
// Serve index if path is directory
if stat.IsDir() {
indexPath := utils.TrimRight(path, '/') + cfg.Index
index, err := cfg.Root.Open(indexPath)
if err == nil {
indexStat, err := index.Stat()
if err == nil {
file = index
stat = indexStat
}
}
}
// Browse directory if no index found and browsing is enabled
if stat.IsDir() {
if cfg.Browse {
return dirList(c, file)
}
return fiber.ErrForbidden
}
c.Status(fiber.StatusOK)
modTime := stat.ModTime()
contentLength := int(stat.Size())
// Set Content Type header
if cfg.ContentTypeCharset == "" {
c.Type(getFileExtension(stat.Name()))
} else {
c.Type(getFileExtension(stat.Name()), cfg.ContentTypeCharset)
}
// Set Last Modified header
if !modTime.IsZero() {
c.Set(fiber.HeaderLastModified, modTime.UTC().Format(http.TimeFormat))
}
if method == fiber.MethodGet {
if cfg.MaxAge > 0 {
c.Set(fiber.HeaderCacheControl, cacheControlStr)
}
c.Response().SetBodyStream(file, contentLength)
return nil
}
if method == fiber.MethodHead {
c.Request().ResetBody()
// Fasthttp should skipbody by default if HEAD?
c.Response().SkipBody = true
c.Response().Header.SetContentLength(contentLength)
if err := file.Close(); err != nil {
return fmt.Errorf("failed to close: %w", err)
}
return nil
}
return c.Next()
}
}
// SendFile serves a file from an HTTP file system at the specified path.
// It handles content serving, sets appropriate headers, and returns errors when needed.
// Usage: err := SendFile(ctx, fs, "/path/to/file.txt")
func SendFile(c *fiber.Ctx, filesystem http.FileSystem, path string) error {
file, err := filesystem.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return fiber.ErrNotFound
}
return fmt.Errorf("failed to open: %w", err)
}
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat: %w", err)
}
// Serve index if path is directory
if stat.IsDir() {
indexPath := utils.TrimRight(path, '/') + ConfigDefault.Index
index, err := filesystem.Open(indexPath)
if err == nil {
indexStat, err := index.Stat()
if err == nil {
file = index
stat = indexStat
}
}
}
// Return forbidden if no index found
if stat.IsDir() {
return fiber.ErrForbidden
}
c.Status(fiber.StatusOK)
modTime := stat.ModTime()
contentLength := int(stat.Size())
// Set Content Type header
c.Type(getFileExtension(stat.Name()))
// Set Last Modified header
if !modTime.IsZero() {
c.Set(fiber.HeaderLastModified, modTime.UTC().Format(http.TimeFormat))
}
method := c.Method()
if method == fiber.MethodGet {
c.Response().SetBodyStream(file, contentLength)
return nil
}
if method == fiber.MethodHead {
c.Request().ResetBody()
// Fasthttp should skipbody by default if HEAD?
c.Response().SkipBody = true
c.Response().Header.SetContentLength(contentLength)
if err := file.Close(); err != nil {
return fmt.Errorf("failed to close: %w", err)
}
return nil
}
return nil
}

View File

@@ -0,0 +1,235 @@
//nolint:bodyclose // Much easier to just ignore memory leaks in tests
package filesystem
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/utils"
)
// go test -run Test_FileSystem
func Test_FileSystem(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use("/test", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
}))
app.Use("/dir", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
Browse: true,
}))
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello, World!")
})
app.Use("/spatest", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
Index: "index.html",
NotFoundFile: "index.html",
}))
app.Use("/prefix", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
PathPrefix: "img",
}))
tests := []struct {
name string
url string
statusCode int
contentType string
modifiedTime string
}{
{
name: "Should be returns status 200 with suitable content-type",
url: "/test/index.html",
statusCode: 200,
contentType: "text/html",
},
{
name: "Should be returns status 200 with suitable content-type",
url: "/test",
statusCode: 200,
contentType: "text/html",
},
{
name: "Should be returns status 200 with suitable content-type",
url: "/test/css/style.css",
statusCode: 200,
contentType: "text/css",
},
{
name: "Should be returns status 404",
url: "/test/nofile.js",
statusCode: 404,
},
{
name: "Should be returns status 404",
url: "/test/nofile",
statusCode: 404,
},
{
name: "Should be returns status 200",
url: "/",
statusCode: 200,
contentType: "text/plain; charset=utf-8",
},
{
name: "Should be returns status 403",
url: "/test/img",
statusCode: 403,
},
{
name: "Should list the directory contents",
url: "/dir/img",
statusCode: 200,
contentType: "text/html",
},
{
name: "Should list the directory contents",
url: "/dir/img/",
statusCode: 200,
contentType: "text/html",
},
{
name: "Should be returns status 200",
url: "/dir/img/fiber.png",
statusCode: 200,
contentType: "image/png",
},
{
name: "Should be return status 200",
url: "/spatest/doesnotexist",
statusCode: 200,
contentType: "text/html",
},
{
name: "PathPrefix should be applied",
url: "/prefix/fiber.png",
statusCode: 200,
contentType: "image/png",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, tt.url, nil))
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, tt.statusCode, resp.StatusCode)
if tt.contentType != "" {
ct := resp.Header.Get("Content-Type")
utils.AssertEqual(t, tt.contentType, ct)
}
})
}
}
// go test -run Test_FileSystem_Next
func Test_FileSystem_Next(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Root: http.Dir("../../.github/testdata/fs"),
Next: func(_ *fiber.Ctx) bool {
return true
},
}))
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil))
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, fiber.StatusNotFound, resp.StatusCode)
}
func Test_FileSystem_NonGetAndHead(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use("/test", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
}))
resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/test", nil))
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, 404, resp.StatusCode)
}
func Test_FileSystem_Head(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use("/test", New(Config{
Root: http.Dir("../../.github/testdata/fs"),
}))
req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/test", nil)
utils.AssertEqual(t, nil, err)
resp, err := app.Test(req)
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, 200, resp.StatusCode)
}
func Test_FileSystem_NoRoot(t *testing.T) {
t.Parallel()
defer func() {
utils.AssertEqual(t, "filesystem: Root cannot be nil", recover())
}()
app := fiber.New()
app.Use(New())
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil))
utils.AssertEqual(t, nil, err)
}
func Test_FileSystem_UsingParam(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use("/:path", func(c *fiber.Ctx) error {
return SendFile(c, http.Dir("../../.github/testdata/fs"), c.Params("path")+".html")
})
req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/index", nil)
utils.AssertEqual(t, nil, err)
resp, err := app.Test(req)
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, 200, resp.StatusCode)
}
func Test_FileSystem_UsingParam_NonFile(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use("/:path", func(c *fiber.Ctx) error {
return SendFile(c, http.Dir("../../.github/testdata/fs"), c.Params("path")+".html")
})
req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/template", nil)
utils.AssertEqual(t, nil, err)
resp, err := app.Test(req)
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, 404, resp.StatusCode)
}
func Test_FileSystem_UsingContentTypeCharset(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Root: http.Dir("../../.github/testdata/fs/index.html"),
ContentTypeCharset: "UTF-8",
}))
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil))
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, 200, resp.StatusCode)
utils.AssertEqual(t, "text/html; charset=UTF-8", resp.Header.Get("Content-Type"))
}

View File

@@ -0,0 +1,66 @@
package filesystem
import (
"fmt"
"html"
"net/http"
"os"
"path"
"sort"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/utils"
)
func getFileExtension(p string) string {
n := strings.LastIndexByte(p, '.')
if n < 0 {
return ""
}
return p[n:]
}
func dirList(c *fiber.Ctx, f http.File) error {
fileinfos, err := f.Readdir(-1)
if err != nil {
return fmt.Errorf("failed to read dir: %w", err)
}
fm := make(map[string]os.FileInfo, len(fileinfos))
filenames := make([]string, 0, len(fileinfos))
for _, fi := range fileinfos {
name := fi.Name()
fm[name] = fi
filenames = append(filenames, name)
}
basePathEscaped := html.EscapeString(c.Path())
_, _ = fmt.Fprintf(c, "<html><head><title>%s</title><style>.dir { font-weight: bold }</style></head><body>", basePathEscaped)
_, _ = fmt.Fprintf(c, "<h1>%s</h1>", basePathEscaped)
_, _ = fmt.Fprint(c, "<ul>")
if len(basePathEscaped) > 1 {
parentPathEscaped := html.EscapeString(utils.TrimRight(c.Path(), '/') + "/..")
_, _ = fmt.Fprintf(c, `<li><a href="%s" class="dir">..</a></li>`, parentPathEscaped)
}
sort.Strings(filenames)
for _, name := range filenames {
pathEscaped := html.EscapeString(path.Join(c.Path() + "/" + name))
fi := fm[name]
auxStr := "dir"
className := "dir"
if !fi.IsDir() {
auxStr = fmt.Sprintf("file, %d bytes", fi.Size())
className = "file"
}
_, _ = fmt.Fprintf(c, `<li><a href="%s" class="%s">%s</a>, %s, last modified %s</li>`,
pathEscaped, className, html.EscapeString(name), auxStr, fi.ModTime())
}
_, _ = fmt.Fprint(c, "</ul></body></html>")
c.Type("html")
return nil
}

View File

@@ -4,15 +4,16 @@ import (
"io/fs"
"net/http"
"github.com/Theodor-Springmann-Stiftung/lenz-web/helpers/middleware/filesystem"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
func StaticHandler(fs *fs.FS) fiber.Handler {
func StaticHandler(fs *fs.FS, dev bool) fiber.Handler {
return filesystem.New(filesystem.Config{
Root: http.FS(*fs),
Browse: false,
Index: "index.html",
Dev: dev,
MaxAge: 3600,
})
}

View File

@@ -50,15 +50,3 @@ hand
insertion
subst
Korrekturversion der Lenz-Briefe online
Eine Korrekturversion der Briefe kann jetzt unter https://dev.lenz-briefe.de eingesehen werden. Die Seite aktualisiert sich bei einem push nach https://github.com/Theodor-Springmann-Stiftung/lenz-briefe automatisch, wenn die XML-Syntax gültig ist.
Ein paar Dinge zur Korrektur:
Grundsätzlich hatte ich schon Probleme mit dem Whitespace, sowohl veritikal, als auch horizontal. In allen XML-Dokumentformaten (wie in TEI) werden Block-Elemente und Inline-Elemente unterschieden. Unser einziges Block-Element ist <sidenote> und zzt. <note>, dass bedeutet, dass vor und nach beiden Elementen automatisch ein Zeilenumbruch stattfindet (sie werden als "block" gesetzt). Fängt man eines von beiden dennoch mit <line> an, gibt es halt einen Zeilenumbruch zu Anfang des Elements:
Bitte nach einem <page>-Tag direkt mit der neuen Seite weiter machen (oder <line>, wenn etwa ein Absatz folgt. Bitte auch keinen Zeilenumbruch nach <page>, sonst gibt es ein unschönes Spatium am Zeilenanfang.
Grundsätzlich sind für mein Gefühl zu viele <line type="empty"/>. Zb vor <sidenote> scheinen sie ein bisschen sinnlos, aber das muss man wohl im Einzelfall beurteilen.
<sidenote> und <hand> müssen nicht mit <note> kommentiert werden, was wirklich oft geschieht. Evtl. lohnt es sich einfach alle <note>-Elemente in der briefe.xml zu suchen, evtl. zu löschen und ggf. durch <hand> oder <sidenote> zu ersetzen. Das kann man ganz ohne Textgrundlage machen, einfach auf Basis dessen, was in der <note> steht:
Villeicht müssen wir uns für <sidenote> einen anderen Platz ausdenken, als im laufenden Text; evtl am Seitenende in einer zweiten Spalte oder so?
Dass wir zwischen [added] und <note> unterscheiden, haben wir ja besprochen ([added] sollte selten sein).
Grundsätzlich haben <note> ein bisschen das Problem, dass sie sich nicht wirklich auf etwas beziehen, bzw. der Bezug oft unklar ist. Am liebsten würde mir so etwas wie <edit> und <editreason> aus der Hamann-Ausgabe besser gefallen. Dort ist note durch den rigiden Zeilenfall nicht so sehr das Problem.

View File

@@ -1,7 +1,15 @@
package server
import "github.com/gofiber/fiber/v2"
import (
"log/slog"
"strings"
"github.com/gofiber/fiber/v2"
)
func CacheFunc(c *fiber.Ctx) bool {
return c.Query("noCache") == "true" || c.Response().StatusCode() != fiber.StatusOK
path := c.Path()
// INFO: for now, css and js files are excluded from caching; they get cached via ETag to enable reloading on style changes.
slog.Debug("CacheFunc:", "path", path)
return c.Query("noCache") == "true" || c.Response().StatusCode() != fiber.StatusOK || strings.HasPrefix(path, "/assets")
}

View File

@@ -62,7 +62,7 @@ func New(engine *templating.Engine, storage fiber.Storage, debug bool) Server {
server.Use(cache.New(cache.Config{
Next: CacheFunc,
Expiration: CACHE_TIME,
CacheControl: true,
CacheControl: false,
Storage: storage,
KeyGenerator: KeyGenerator,
}))