1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-13 20:04:49 +02:00

Chore: Replace http.FileServer with custom controller to correctly encode gzipped error responses for embed.FS

Go v1.23 removes the Content-Encoding header from error responses, breaking pages such as 404's while using gzip compression middleware.
This commit is contained in:
Ralph Slooten
2025-02-08 15:15:07 +13:00
parent 3528bc8da7
commit dac9fcf735
3 changed files with 84 additions and 59 deletions

74
server/embed.go Normal file
View File

@@ -0,0 +1,74 @@
package server
import (
"embed"
"net/http"
"path"
"strings"
"github.com/axllent/mailpit/config"
)
var (
//go:embed ui
distFS embed.FS
)
// EmbedController is a simple controller to return a file from the embedded filesystem.
//
// This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes
// the Content-Encoding header from error responses, breaking pages such as 404's while
// using gzip compression middleware.
func embedController(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if strings.HasSuffix(p, "/") {
p = p + "index.html"
}
p = strings.TrimLeft(p, config.Webroot) // server webroot config
p = path.Join("ui", p) // add go:embed path to path prefix
b, err := distFS.ReadFile(p)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// ensure any HTML files have the correct nonce
if strings.HasSuffix(p, ".html") {
nonce := r.Header.Get("mp-nonce")
b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce))
}
w.Header().Set("Content-Type", contentType(p))
_, _ = w.Write(b)
}
// ContentType supports only a few content types, limited to this application's needs.
func contentType(p string) string {
switch {
case strings.HasSuffix(p, ".html"):
return "text/html; charset=utf-8"
case strings.HasSuffix(p, ".css"):
return "text/css; charset=utf-8"
case strings.HasSuffix(p, ".js"):
return "application/javascript; charset=utf-8"
case strings.HasSuffix(p, ".json"):
return "application/json"
case strings.HasSuffix(p, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(p, ".ico"):
return "image/x-icon"
case strings.HasSuffix(p, ".png"):
return "image/png"
case strings.HasSuffix(p, ".jpg"):
return "image/jpeg"
case strings.HasSuffix(p, ".gif"):
return "image/gif"
case strings.HasSuffix(p, ".woff2"):
return "font/woff2"
default:
return "text/plain"
}
}

View File

@@ -4,10 +4,8 @@ package server
import ( import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"embed"
"fmt" "fmt"
"io" "io"
"io/fs"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -31,9 +29,6 @@ import (
"github.com/lithammer/shortuuid/v4" "github.com/lithammer/shortuuid/v4"
) )
//go:embed ui
var embeddedFS embed.FS
var ( var (
// AccessControlAllowOrigin CORS policy // AccessControlAllowOrigin CORS policy
AccessControlAllowOrigin string AccessControlAllowOrigin string
@@ -48,12 +43,6 @@ func Listen() {
isReady.Store(false) isReady.Store(false)
stats.Track() stats.Track()
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err.Error())
os.Exit(1)
}
websockets.MessageHub = websockets.NewHub() websockets.MessageHub = websockets.NewHub()
go websockets.MessageHub.Run() go websockets.MessageHub.Run()
@@ -70,12 +59,12 @@ func Listen() {
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET") r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// virtual filesystem for /dist/ & some individual files // virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot))))) r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
// redirect to webroot if no trailing slash // redirect to webroot if no trailing slash
if config.Webroot != "/" { if config.Webroot != "/" {
@@ -277,44 +266,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
} }
} }
// MiddlewareHandler http middleware adds optional basic authentication
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
})
}
// Redirect to webroot // Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) { func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound) http.Redirect(w, r, config.Webroot, http.StatusFound)
@@ -328,7 +279,7 @@ func apiWebsocket(w http.ResponseWriter, r *http.Request) {
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified // Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) { func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json") f, err := distFS.ReadFile("ui/api/v1/swagger.json")
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -363,7 +314,7 @@ func index(w http.ResponseWriter, r *http.Request) {
<body class="h-100"> <body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}"> <div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle"> <noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
You need a browser with JavaScript support to use Mailpit You need a browser with JavaScript enabled to use Mailpit
</noscript> </noscript>
</div> </div>
@@ -394,6 +345,6 @@ func index(w http.ResponseWriter, r *http.Request) {
panic(err) panic(err)
} }
w.Header().Add("Content-Type", "text/html") w.Header().Add("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buff.Bytes()) _, _ = w.Write(buff.Bytes())
} }

View File

@@ -8,7 +8,7 @@
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive"> <meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="../../favicon.svg"> <link rel="icon" href="../../favicon.svg">
<script src="../../dist/docs.js"></script> <script src="../../dist/docs.js" nonce="%%NONCE%%"></script>
</head> </head>
<body> <body>