2026-05-08 07:27:06 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"apigo.cc/go/file"
|
|
|
|
|
|
"apigo.cc/go/log"
|
2026-08-22 18:23:16 +08:00
|
|
|
|
"io"
|
2026-05-08 07:27:06 +08:00
|
|
|
|
"mime"
|
|
|
|
|
|
"net/http"
|
2026-06-05 21:15:04 +08:00
|
|
|
|
"net/url"
|
2026-08-22 18:23:16 +08:00
|
|
|
|
pathpkg "path"
|
2026-05-08 07:27:06 +08:00
|
|
|
|
"path/filepath"
|
2026-08-22 18:23:16 +08:00
|
|
|
|
"sort"
|
2026-05-08 07:27:06 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-22 18:23:16 +08:00
|
|
|
|
type staticFSMatch struct {
|
|
|
|
|
|
fileSystem http.FileSystem
|
|
|
|
|
|
name string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 07:27:06 +08:00
|
|
|
|
// Static 注册静态文件目录
|
2026-06-02 13:22:23 +08:00
|
|
|
|
func (hc *HostContext) Static(path, rootPath string) *HostContext {
|
|
|
|
|
|
host := hc.host
|
|
|
|
|
|
if host == "*" {
|
|
|
|
|
|
host = ""
|
|
|
|
|
|
}
|
2026-06-04 18:16:46 +08:00
|
|
|
|
hc.ws.StaticByHost(path, rootPath, host)
|
2026-06-02 13:22:23 +08:00
|
|
|
|
return hc
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Static 注册静态文件目录 (使用默认 Host "*")
|
2026-05-08 07:27:06 +08:00
|
|
|
|
func Static(path, rootPath string) {
|
2026-06-04 18:16:46 +08:00
|
|
|
|
DefaultServer.Static(path, rootPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) Static(path, rootPath string) {
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.Host("*").Static(path, rootPath)
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:23:16 +08:00
|
|
|
|
// StaticFS registers a custom static file source for a URL prefix.
|
|
|
|
|
|
func (hc *HostContext) StaticFS(path string, source http.FileSystem) *HostContext {
|
|
|
|
|
|
host := hc.host
|
|
|
|
|
|
if host == "*" {
|
|
|
|
|
|
host = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
hc.ws.staticsByHostLock.Lock()
|
|
|
|
|
|
if hc.ws.staticFSByHost[host] == nil {
|
|
|
|
|
|
hc.ws.staticFSByHost[host] = map[string]http.FileSystem{}
|
|
|
|
|
|
}
|
|
|
|
|
|
hc.ws.staticFSByHost[host][path] = source
|
|
|
|
|
|
hc.ws.staticsByHostLock.Unlock()
|
|
|
|
|
|
return hc
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ReplaceStaticFS atomically replaces custom static file sources for a host.
|
|
|
|
|
|
func ReplaceStaticFS(host string, config map[string]http.FileSystem) {
|
|
|
|
|
|
DefaultServer.ReplaceStaticFS(host, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (ws *WebServer) ReplaceStaticFS(host string, config map[string]http.FileSystem) {
|
|
|
|
|
|
ws.replaceStaticFS(host, config, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ReplaceStaticFSExclusive atomically replaces custom static file sources for
|
|
|
|
|
|
// a host and stops custom StaticFS fallback after that host is selected.
|
|
|
|
|
|
func ReplaceStaticFSExclusive(host string, config map[string]http.FileSystem) {
|
|
|
|
|
|
DefaultServer.ReplaceStaticFSExclusive(host, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (ws *WebServer) ReplaceStaticFSExclusive(host string, config map[string]http.FileSystem) {
|
|
|
|
|
|
ws.replaceStaticFS(host, config, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (ws *WebServer) replaceStaticFS(host string, config map[string]http.FileSystem, exclusive bool) {
|
|
|
|
|
|
if host == "*" {
|
|
|
|
|
|
host = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
next := make(map[string]http.FileSystem, len(config))
|
|
|
|
|
|
for route, source := range config {
|
|
|
|
|
|
if source != nil {
|
|
|
|
|
|
next[route] = source
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ws.staticsByHostLock.Lock()
|
|
|
|
|
|
if len(next) == 0 {
|
|
|
|
|
|
delete(ws.staticFSByHost, host)
|
|
|
|
|
|
delete(ws.staticFSExclusive, host)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ws.staticFSByHost[host] = next
|
|
|
|
|
|
ws.staticFSExclusive[host] = exclusive
|
|
|
|
|
|
}
|
|
|
|
|
|
ws.staticsByHostLock.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 07:27:06 +08:00
|
|
|
|
// StaticByHost 为指定域名注册静态文件目录
|
|
|
|
|
|
func StaticByHost(path, rootPath, host string) {
|
2026-06-04 18:16:46 +08:00
|
|
|
|
DefaultServer.StaticByHost(path, rootPath, host)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) StaticByHost(path, rootPath, host string) {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
if !filepath.IsAbs(rootPath) {
|
|
|
|
|
|
if absPath, err := filepath.Abs(rootPath); err == nil {
|
|
|
|
|
|
rootPath = absPath
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.staticsByHostLock.Lock()
|
|
|
|
|
|
defer ws.staticsByHostLock.Unlock()
|
2026-05-08 07:27:06 +08:00
|
|
|
|
|
2026-06-04 18:16:46 +08:00
|
|
|
|
if ws.codeStatics[host] == nil {
|
|
|
|
|
|
ws.codeStatics[host] = make(map[string]*string)
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.codeStatics[host][path] = &rootPath
|
|
|
|
|
|
ws.rebuildStaticsUnderLock(host)
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
2026-05-12 23:18:31 +08:00
|
|
|
|
|
2026-05-12 23:53:16 +08:00
|
|
|
|
// ReplaceStatics 使用 Copy-on-Write 机制原子地替换指定 host 下的动态静态目录规则
|
2026-05-12 23:18:31 +08:00
|
|
|
|
func ReplaceStatics(host string, config map[string]string) {
|
2026-06-04 18:16:46 +08:00
|
|
|
|
DefaultServer.ReplaceStatics(host, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) ReplaceStatics(host string, config map[string]string) {
|
2026-05-12 23:18:31 +08:00
|
|
|
|
newStatics := make(map[string]*string, len(config))
|
|
|
|
|
|
for path, rootPath := range config {
|
|
|
|
|
|
rp := rootPath
|
|
|
|
|
|
if !filepath.IsAbs(rp) {
|
|
|
|
|
|
if absPath, err := filepath.Abs(rp); err == nil {
|
|
|
|
|
|
rp = absPath
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
newStatics[path] = &rp
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.staticsByHostLock.Lock()
|
|
|
|
|
|
defer ws.staticsByHostLock.Unlock()
|
2026-05-12 23:53:16 +08:00
|
|
|
|
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.dynamicStatics[host] = newStatics
|
|
|
|
|
|
ws.rebuildStaticsUnderLock(host)
|
2026-05-12 23:18:31 +08:00
|
|
|
|
}
|
2026-05-08 07:27:06 +08:00
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) getStaticFilePath(requestPath, host string) string {
|
2026-06-05 21:15:04 +08:00
|
|
|
|
requestPath, _ = url.PathUnescape(requestPath)
|
2026-06-04 18:16:46 +08:00
|
|
|
|
ws.staticsByHostLock.RLock()
|
|
|
|
|
|
defer ws.staticsByHostLock.RUnlock()
|
2026-05-08 07:27:06 +08:00
|
|
|
|
|
2026-08-22 18:23:16 +08:00
|
|
|
|
for _, candidate := range hostCandidates(host) {
|
|
|
|
|
|
if candidate == "*" {
|
|
|
|
|
|
candidate = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if filePath := ws.findMatchedPathSorted(ws.hostStatics[candidate], requestPath); filePath != "" {
|
|
|
|
|
|
return filePath
|
|
|
|
|
|
}
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
2026-08-22 18:23:16 +08:00
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-05-08 07:27:06 +08:00
|
|
|
|
|
2026-08-22 18:23:16 +08:00
|
|
|
|
func (ws *WebServer) getStaticFSMatches(requestPath, host string) []staticFSMatch {
|
|
|
|
|
|
requestPath, _ = url.PathUnescape(requestPath)
|
|
|
|
|
|
ws.staticsByHostLock.RLock()
|
|
|
|
|
|
defer ws.staticsByHostLock.RUnlock()
|
|
|
|
|
|
matches := make([]staticFSMatch, 0)
|
|
|
|
|
|
for _, candidate := range hostCandidates(host) {
|
|
|
|
|
|
if candidate == "*" {
|
|
|
|
|
|
candidate = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
config, exists := ws.staticFSByHost[candidate]
|
|
|
|
|
|
if !exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
routes := make([]string, 0, len(config))
|
|
|
|
|
|
for route := range config {
|
|
|
|
|
|
routes = append(routes, route)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(routes, func(i, j int) bool { return len(routes[i]) > len(routes[j]) })
|
|
|
|
|
|
for _, route := range routes {
|
|
|
|
|
|
if !strings.HasPrefix(requestPath, route) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
name := strings.TrimPrefix(requestPath, route)
|
|
|
|
|
|
name = strings.TrimPrefix(pathpkg.Clean("/"+name), "/")
|
|
|
|
|
|
matches = append(matches, staticFSMatch{fileSystem: config[route], name: name})
|
|
|
|
|
|
}
|
|
|
|
|
|
if ws.staticFSExclusive[candidate] {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return matches
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) findMatchedPathSorted(config []*staticType, requestPath string) string {
|
2026-06-06 08:59:14 +08:00
|
|
|
|
for _, rule := range config {
|
|
|
|
|
|
if strings.HasPrefix(requestPath, rule.path) {
|
|
|
|
|
|
return filepath.Join(*rule.rootPath, requestPath[len(rule.path):])
|
2026-05-08 07:27:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 09:34:20 +08:00
|
|
|
|
func (ws *WebServer) processStatic(requestPath string, request *Request, response *Response, logger *log.Logger) bool {
|
2026-08-22 18:23:16 +08:00
|
|
|
|
for _, match := range ws.getStaticFSMatches(requestPath, request.Host) {
|
|
|
|
|
|
if ws.processStaticFS(match, request, response) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-04 18:16:46 +08:00
|
|
|
|
filePath := ws.getStaticFilePath(requestPath, request.Host)
|
2026-05-08 07:27:06 +08:00
|
|
|
|
if filePath == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 16:39:20 +08:00
|
|
|
|
info := file.GetFileInfo(filePath)
|
|
|
|
|
|
if info == nil {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 16:39:20 +08:00
|
|
|
|
if info.IsDir {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
// 自动查找索引文件
|
2026-06-04 18:16:46 +08:00
|
|
|
|
indexFiles := ws.Config.IndexFiles
|
|
|
|
|
|
if len(indexFiles) == 0 {
|
|
|
|
|
|
indexFiles = []string{"index.html", "index.htm"}
|
|
|
|
|
|
}
|
2026-08-17 12:38:07 +08:00
|
|
|
|
|
2026-06-04 18:16:46 +08:00
|
|
|
|
for _, indexFile := range indexFiles {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
f := filepath.Join(filePath, indexFile)
|
2026-05-09 16:39:20 +08:00
|
|
|
|
if i := file.GetFileInfo(f); i != nil && !i.IsDir {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
filePath = f
|
|
|
|
|
|
info = i
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 16:39:20 +08:00
|
|
|
|
if info.IsDir {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 19:01:53 +08:00
|
|
|
|
// 静态文件通过 Cookie 维护 ID,不应答 Device-Id / Session-Id 头
|
|
|
|
|
|
response.Header().Del(ws.usedDeviceIdKey)
|
|
|
|
|
|
response.Header().Del(ws.usedSessionIdKey)
|
|
|
|
|
|
|
2026-05-08 07:27:06 +08:00
|
|
|
|
// 检查 304
|
2026-06-05 19:05:21 +08:00
|
|
|
|
if ifModifiedSince := request.Header().Get("If-Modified-Since"); ifModifiedSince != "" {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {
|
2026-05-09 16:39:20 +08:00
|
|
|
|
if time.Unix(info.ModTime, 0).Truncate(time.Second).Before(t.Truncate(time.Second)) ||
|
|
|
|
|
|
time.Unix(info.ModTime, 0).Truncate(time.Second).Equal(t.Truncate(time.Second)) {
|
2026-05-08 07:27:06 +08:00
|
|
|
|
response.WriteHeader(http.StatusNotModified)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 发送文件
|
|
|
|
|
|
contentType := mime.TypeByExtension(filepath.Ext(filePath))
|
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
|
contentType = "application/octet-stream"
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Header().Set("Content-Type", contentType)
|
2026-05-09 16:39:20 +08:00
|
|
|
|
response.Header().Set("Last-Modified", time.Unix(info.ModTime, 0).UTC().Format(http.TimeFormat))
|
2026-05-08 07:27:06 +08:00
|
|
|
|
|
|
|
|
|
|
data, err := file.ReadBytes(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 18:23:16 +08:00
|
|
|
|
if ws.webDevEnabled {
|
|
|
|
|
|
_, _ = response.WriteFiltered(data)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_, _ = response.Write(data)
|
|
|
|
|
|
}
|
2026-05-08 07:27:06 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-08-22 18:23:16 +08:00
|
|
|
|
|
|
|
|
|
|
func (ws *WebServer) processStaticFS(match staticFSMatch, request *Request, response *Response) bool {
|
|
|
|
|
|
name := match.name
|
|
|
|
|
|
opened, err := match.fileSystem.Open(name)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = opened.Close() }()
|
|
|
|
|
|
info, err := opened.Stat()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
|
_ = opened.Close()
|
|
|
|
|
|
indexFiles := ws.Config.IndexFiles
|
|
|
|
|
|
if len(indexFiles) == 0 {
|
|
|
|
|
|
indexFiles = []string{"index.html", "index.htm"}
|
|
|
|
|
|
}
|
|
|
|
|
|
found := false
|
|
|
|
|
|
for _, indexFile := range indexFiles {
|
|
|
|
|
|
candidate := pathpkg.Join(name, indexFile)
|
|
|
|
|
|
opened, err = match.fileSystem.Open(candidate)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
info, err = opened.Stat()
|
|
|
|
|
|
if err == nil && !info.IsDir() {
|
|
|
|
|
|
name = candidate
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = opened.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
seeker, ok := opened.(interface {
|
|
|
|
|
|
Read([]byte) (int, error)
|
|
|
|
|
|
Seek(int64, int) (int64, error)
|
|
|
|
|
|
})
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Header().Del(ws.usedDeviceIdKey)
|
|
|
|
|
|
response.Header().Del(ws.usedSessionIdKey)
|
|
|
|
|
|
contentType := mime.TypeByExtension(filepath.Ext(name))
|
|
|
|
|
|
if contentType != "" {
|
|
|
|
|
|
response.Header().Set("Content-Type", contentType)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ws.webDevEnabled && strings.HasPrefix(contentType, "text/html") {
|
|
|
|
|
|
data, readErr := io.ReadAll(seeker)
|
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat))
|
|
|
|
|
|
_, _ = response.WriteFiltered(data)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
http.ServeContent(&staticResponseWriter{response: response}, request.Request, info.Name(), info.ModTime(), seeker)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type staticResponseWriter struct {
|
|
|
|
|
|
response *Response
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (w *staticResponseWriter) Header() http.Header {
|
|
|
|
|
|
return w.response.Header().H
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (w *staticResponseWriter) WriteHeader(statusCode int) {
|
|
|
|
|
|
w.response.WriteHeader(statusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (w *staticResponseWriter) Write(data []byte) (int, error) {
|
|
|
|
|
|
return w.response.Write(data)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var _ http.ResponseWriter = (*staticResponseWriter)(nil)
|