275 lines
8.3 KiB
Go
275 lines
8.3 KiB
Go
package routes
|
|
|
|
import (
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/photoview/photoview/api/log"
|
|
)
|
|
|
|
// SpaHandler implements the http.Handler interface, so we can use it
|
|
// to respond to HTTP requests. The path to the static directory and
|
|
// path to the index file within that static directory are used to
|
|
// serve the SPA in the given static directory.
|
|
type SpaHandler struct {
|
|
staticPath string
|
|
indexPath string
|
|
}
|
|
|
|
func NewSpaHandler(staticPath string, indexPath string) (SpaHandler, error) {
|
|
// Keeping this validation here just because we need the absolute static path anyway
|
|
staticPathAbs, err := filepath.Abs(staticPath)
|
|
if err != nil {
|
|
return SpaHandler{}, fmt.Errorf("static path %s is not valid: %w", staticPath, err)
|
|
}
|
|
|
|
if err := validPath(staticPathAbs, true); err != nil {
|
|
return SpaHandler{}, fmt.Errorf("static path %s validation error: %w", staticPath, err)
|
|
}
|
|
if err := validPath(filepath.Join(staticPathAbs, indexPath), false); err != nil {
|
|
return SpaHandler{}, fmt.Errorf("index path %s validation error: %w", indexPath, err)
|
|
}
|
|
|
|
return SpaHandler{
|
|
indexPath: indexPath,
|
|
staticPath: staticPathAbs,
|
|
}, nil
|
|
}
|
|
|
|
// ServeHTTP inspects the URL path to locate a file within the static dir
|
|
// on the SPA handler. If a file is found, it will be served. If not, the
|
|
// file located at the index path on the SPA handler will be served. This
|
|
// is suitable behavior for serving an SPA (single page application).
|
|
// Pre-compressed files (.br, .zst, .gz) are served if the client supports
|
|
// them, otherwise the original file is served.
|
|
func (h SpaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
relPath := strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")
|
|
fullPath := filepath.Join(h.staticPath, relPath)
|
|
|
|
r = r.WithContext(log.WithAttrs(r.Context(), "static_path", h.staticPath, "requested_path", r.URL.Path))
|
|
|
|
// Special case: root path should serve index.html
|
|
if relPath == "" {
|
|
h.serveIndexHTML(w, r)
|
|
return
|
|
}
|
|
|
|
absPath, err := filepath.Abs(fullPath)
|
|
if err != nil {
|
|
log.Error(r.Context(), "error building absolute path", "error", err)
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
rel, err := filepath.Rel(h.staticPath, absPath)
|
|
if err != nil || strings.Contains(rel, "..") {
|
|
log.Error(r.Context(), "requested path is outside of static path", "error", err)
|
|
http.Error(w, "Invalid request URI", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if the original file exists
|
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
|
// File doesn't exist - let serveOriginal handle SPA routing
|
|
h.serveOriginal(w, r, fullPath, relPath)
|
|
return
|
|
}
|
|
|
|
// Don't compress already-compressed files
|
|
if isCompressedFormat(filepath.Ext(absPath)) {
|
|
h.serveOriginal(w, r, fullPath, relPath)
|
|
return
|
|
}
|
|
// Try to serve pre-compressed file
|
|
if h.servePrecompressedFile(w, r, fullPath, relPath) {
|
|
return
|
|
}
|
|
|
|
// Fallback to original SPA handler logic
|
|
h.serveOriginal(w, r, fullPath, relPath)
|
|
}
|
|
|
|
func (h SpaHandler) serveOriginal(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) {
|
|
// Check whether a file exists at the given path
|
|
_, err := os.Stat(fullPath)
|
|
if os.IsNotExist(err) {
|
|
// File does not exist, serve index.html (SPA routing)
|
|
h.serveIndexHTML(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
// If we got an error (that wasn't that the file doesn't exist) stating the file,
|
|
// return a 500 internal server error and stop
|
|
log.Error(r.Context(), "Error stating file, requested by client", "file_path", fullPath, "error", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set cache headers
|
|
h.setCacheHeaders(w, relPath)
|
|
|
|
// Use http.FileServer to serve the static file with no compression
|
|
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
|
}
|
|
|
|
// servePrecompressedFile attempts to serve a pre-compressed variant of the file
|
|
// Returns true if a pre-compressed file was served, false otherwise
|
|
func (h SpaHandler) servePrecompressedFile(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) bool {
|
|
// Parse Accept-Encoding header
|
|
acceptEncoding := filterZeroQuality(strings.ToLower(r.Header.Get("Accept-Encoding")))
|
|
if acceptEncoding == "" {
|
|
return false
|
|
}
|
|
|
|
// Check for pre-compressed variants in order of preference (br > zstd > gzip)
|
|
encodings := []struct {
|
|
name string
|
|
extension string
|
|
}{
|
|
{"br", ".br"},
|
|
{"zstd", ".zst"},
|
|
{"gzip", ".gz"},
|
|
}
|
|
|
|
for _, enc := range encodings {
|
|
if !strings.Contains(acceptEncoding, enc.name) {
|
|
continue
|
|
}
|
|
|
|
precompressedPath := fullPath + enc.extension
|
|
if stat, err := os.Stat(precompressedPath); err != nil || stat.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// Detect Content-Type from the ORIGINAL file extension, not the compressed one
|
|
contentType := mime.TypeByExtension(filepath.Ext(fullPath))
|
|
if contentType != "" {
|
|
w.Header().Set("Content-Type", contentType)
|
|
}
|
|
|
|
w.Header().Set("Content-Encoding", enc.name)
|
|
w.Header().Add("Vary", "Accept-Encoding")
|
|
// Set cache headers based on request path
|
|
h.setCacheHeaders(w, relPath)
|
|
|
|
// Serve pre-compressed file
|
|
http.ServeFile(w, r, precompressedPath)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// serveIndexHTML serves index.html with pre-compressed priority
|
|
func (h SpaHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
|
|
indexPath := filepath.Join(h.staticPath, h.indexPath)
|
|
|
|
// Try to serve pre-compressed index.html first
|
|
// Use full and relative paths to index.html instead of originally requested paths
|
|
if h.servePrecompressedFile(w, r, indexPath, h.indexPath) {
|
|
return
|
|
}
|
|
|
|
// Fallback to uncompressed index.html
|
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
|
// Index file doesn't exist - this is a serious configuration error, not a regular 404
|
|
log.Error(r.Context(), "Error: index.html not found", "index_path", indexPath)
|
|
http.Error(w, "Application index file not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.setCacheHeaders(w, h.indexPath)
|
|
http.ServeFile(w, r, indexPath)
|
|
}
|
|
|
|
// setCacheHeaders sets appropriate cache headers based on the request path
|
|
func (h SpaHandler) setCacheHeaders(w http.ResponseWriter, relPath string) {
|
|
if strings.HasPrefix(relPath, "assets/") {
|
|
// Long-term cache for fingerprinted assets
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
// Short cache with revalidation for other files
|
|
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
|
}
|
|
}
|
|
|
|
// validPath validates the given path string.
|
|
//
|
|
// Parameters:
|
|
// - pathStr: The path to validate.
|
|
// - isDir: A boolean indicating whether the path is expected to be a directory (true) or a file (false).
|
|
//
|
|
// Returns:
|
|
// - error: An error if the path is invalid, or nil if the path is valid.
|
|
func validPath(pathStr string, isDir bool) error {
|
|
pathAbs, err := filepath.Abs(pathStr)
|
|
if err != nil {
|
|
return fmt.Errorf("path %s is not valid: %w", pathStr, err)
|
|
}
|
|
|
|
stat, err := os.Stat(pathAbs)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("path %s does not exist", pathAbs)
|
|
}
|
|
if os.IsPermission(err) {
|
|
return fmt.Errorf("no permission to access path %s", pathAbs)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("error accessing path %s: %w", pathAbs, err)
|
|
}
|
|
if isDir && !stat.IsDir() {
|
|
return fmt.Errorf("path %s is not a directory", pathAbs)
|
|
}
|
|
if !isDir && stat.IsDir() {
|
|
return fmt.Errorf("path %s is a directory, must be a file", pathAbs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isCompressedFormat(ext string) bool {
|
|
ext = strings.ToLower(ext)
|
|
compressedExts := []string{
|
|
".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp",
|
|
".zip", ".gz", ".br", ".zst",
|
|
".woff", ".woff2",
|
|
}
|
|
|
|
for _, compExt := range compressedExts {
|
|
if ext == compExt {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// filterZeroQuality removes encodings with q=0 or q=0.0 from Accept-Encoding header
|
|
func filterZeroQuality(acceptEncoding string) string {
|
|
// If no q params, return unchanged (fast path for most requests)
|
|
if !strings.Contains(acceptEncoding, "q=") {
|
|
return acceptEncoding
|
|
}
|
|
|
|
var filtered []string
|
|
parts := strings.Split(acceptEncoding, ",")
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if this encoding has q=0 or q=0.0
|
|
normalized := strings.ReplaceAll(part, " ", "")
|
|
if strings.HasSuffix(normalized, ";q=0") || strings.HasSuffix(normalized, ";q=0.0") {
|
|
continue
|
|
}
|
|
|
|
filtered = append(filtered, part)
|
|
}
|
|
|
|
return strings.Join(filtered, ",")
|
|
}
|