精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
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, ",")
}