summaryrefslogtreecommitdiffstats
path: root/internal/server/fileserver.go
blob: d2315193e94375e73140f138a33efb8482c9fe7a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
package server

import (
	"io/fs"
	"mime"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
)

// AllowedMIMEs maps file extensions (including dot) to Content-Type values.
// A nil map means any extension is allowed; MIME type is detected automatically.
type AllowedMIMEs map[string]string

// FileHandler returns a Gin HandlerFunc that serves files from the given root directory.
// The wildcard param name is *filepath.
func FileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
	return func(c *gin.Context) {
		filepath := c.Param("filepath")
		serveFile(c, root, filepath, allowed)
	}
}

// PublicFileHandler returns a Gin HandlerFunc that serves files using the request URL path.
// Used for NoRoute fallback to serve public static files.
func PublicFileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
	return func(c *gin.Context) {
		filepath := c.Request.URL.Path
		serveFile(c, root, filepath, allowed)
	}
}

func serveFile(c *gin.Context, root, requestPath string, allowed AllowedMIMEs) {
	// Clean the path to prevent directory traversal
	clean := path.Clean(requestPath)
	if strings.Contains(clean, "..") {
		c.AbortWithStatus(http.StatusNotFound)
		return
	}

	// Remove leading slash for filepath.Join
	clean = strings.TrimPrefix(clean, "/")

	// Map empty path or directory to index.html (clean URLs)
	if clean == "" || clean == "." {
		clean = "index.html"
	}

	fullPath := filepath.Join(root, clean)

	// Check file existence; if directory, try index.html inside it
	info, err := os.Stat(fullPath)
	if err == nil && info.IsDir() {
		fullPath = filepath.Join(fullPath, "index.html")
		info, err = os.Stat(fullPath)
	}
	if err != nil {
		// Try appending .html for clean URLs (e.g. /about -> /about.html)
		htmlPath := fullPath + ".html"
		if info2, err2 := os.Stat(htmlPath); err2 == nil && !info2.IsDir() {
			fullPath = htmlPath
			info = info2
			err = nil
		}
	}
	if err != nil || info.IsDir() {
		c.AbortWithStatus(http.StatusNotFound)
		return
	}

	// Determine MIME type
	ext := filepath.Ext(fullPath)
	var mimeType string
	if allowed != nil {
		var ok bool
		mimeType, ok = allowed[ext]
		if !ok {
			c.AbortWithStatus(http.StatusNotFound)
			return
		}
	} else {
		mimeType = mime.TypeByExtension(ext)
		if mimeType == "" {
			mimeType = "application/octet-stream"
		}
	}

	// Serve the file with proper MIME type
	c.Header("Content-Type", mimeType)
	c.File(fullPath)
}

// EmbedHandler serves files from an embedded (or any) fs.FS.
// root is the subdirectory within fsys to serve from.
// The gin wildcard param must be named *filepath.
func EmbedHandler(fsys fs.FS, root string) gin.HandlerFunc {
	sub, err := fs.Sub(fsys, root)
	if err != nil {
		panic("EmbedHandler: " + err.Error())
	}
	fileServer := http.FileServer(http.FS(sub))
	return func(c *gin.Context) {
		req := c.Request.Clone(c.Request.Context())
		req.URL.Path = c.Param("filepath")
		fileServer.ServeHTTP(c.Writer, req)
	}
}