photoprism/internal/server/static_precompressed.go

161 lines
4.9 KiB
Go

package server
import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/http/header"
)
// precompressedExtensions maps each supported content-encoding to the
// sibling file suffix produced by frontend/scripts/precompress.js.
var precompressedExtensions = map[string]string{
EncodingZstd: ".zst",
EncodingGzip: ".gz",
}
// PrecompressedStatic serves files from dir, preferring the precompressed
// `.zst`/`.gz` sibling when Accept-Encoding allows. Range requests always go
// through the identity representation (Range offsets are over identity bytes).
// Vary: Accept-Encoding is emitted only when a sibling actually exists; when
// HttpCompressionPreferences is empty the handler degrades to identity-only.
func PrecompressedStatic(conf *config.Config, dir string) gin.HandlerFunc {
fs := http.Dir(dir)
prefs := conf.HttpCompressionPreferences()
return func(c *gin.Context) {
// Gin only registers this handler for GET/HEAD, but guard anyway so
// odd verbs (e.g. via an upstream rewrite) get a clean 405 instead of
// a half-served body.
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.AbortWithStatus(http.StatusMethodNotAllowed)
return
}
rel := c.Param("filepath")
if rel == "" {
rel = "/"
}
// http.Dir.Open rejects path-traversal attempts, returning os.ErrNotExist
// for cleaned paths that escape the root.
f, err := fs.Open(rel)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
info, err := f.Stat()
if err != nil || info.IsDir() {
_ = f.Close()
c.AbortWithStatus(http.StatusNotFound)
return
}
// Vary: Accept-Encoding only when an encoded sibling could be selected.
if len(prefs) > 0 {
for _, suffix := range precompressedExtensions {
if siblingExists(fs, rel+suffix) {
c.Writer.Header().Add(header.Vary, header.AcceptEncoding)
break
}
}
}
// Range requests and disabled compression both fall through to identity.
if c.Request.Header.Get(header.Range) != "" || len(prefs) == 0 {
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
_ = f.Close()
return
}
// Negotiate and stream the encoded sibling when one exists.
switch encoding := NegotiateEncoding(c.Request.Header.Get(header.AcceptEncoding), prefs); encoding {
case EncodingZstd, EncodingGzip:
suffix := precompressedExtensions[encoding]
if suffix != "" && siblingExists(fs, rel+suffix) {
_ = f.Close()
if servePrecompressed(c, fs, rel+suffix, encoding, info.Name(), info.ModTime()) {
return
}
// Sibling vanished between probe and Open — fall back to identity.
if reopen, openErr := fs.Open(rel); openErr == nil {
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), reopen)
_ = reopen.Close()
} else {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
}
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
_ = f.Close()
}
}
// servePrecompressed streams the precompressed sibling via http.ServeContent,
// advertising Content-Encoding and deriving Content-Type from the identity
// filename. Uses identityModTime (not the sibling's) so Last-Modified +
// If-Modified-Since revalidation stays aligned across encodings.
// Returns false if the sibling can no longer be opened so the caller can fall back.
func servePrecompressed(
c *gin.Context,
fs http.FileSystem,
siblingPath, encoding, identityName string,
identityModTime time.Time,
) bool {
sibling, err := fs.Open(siblingPath)
if err != nil {
return false
}
defer func() { _ = sibling.Close() }()
info, err := sibling.Stat()
if err != nil || info.IsDir() {
return false
}
// Set headers before http.ServeContent so its 304 path can strip them.
if ct := contentTypeForName(identityName); ct != "" {
c.Writer.Header().Set(header.ContentType, ct)
}
c.Writer.Header().Set(header.ContentEncoding, encoding)
// http.ServeContent won't set Content-Length when Content-Encoding is set;
// set it explicitly from the sibling's on-disk size.
c.Writer.Header().Set(header.ContentLength, strconv.FormatInt(info.Size(), 10))
http.ServeContent(c.Writer, c.Request, identityName, identityModTime, sibling)
return true
}
// siblingExists reports whether a regular file exists at the given path
// within the static filesystem. Directories are not considered siblings.
func siblingExists(fs http.FileSystem, p string) bool {
f, err := fs.Open(p)
if err != nil {
return false
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
return false
}
return true
}
// contentTypeForName returns the MIME type for a filename via the stdlib
// mime package, or "" when the extension is unknown.
func contentTypeForName(name string) string {
ext := strings.ToLower(filepath.Ext(name))
if ext == "" {
return ""
}
return mime.TypeByExtension(ext)
}