Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
038e14f3d8 | ||
|
|
a7c08cdf26 | ||
|
|
c891d37fad | ||
|
|
fbf7e6475c | ||
|
|
582de60053 | ||
|
|
94a4be81ec | ||
|
|
ff34d11c9b | ||
|
|
fe3b420d35 | ||
|
|
44951a9ab6 |
@ -878,17 +878,6 @@
|
||||
"Precision": 0,
|
||||
"WithoutKey": false,
|
||||
"Hide": false
|
||||
},
|
||||
{
|
||||
"Index": 8,
|
||||
"Name": "CallStacks",
|
||||
"KeyName": "",
|
||||
"AttachBefore": false,
|
||||
"Color": "",
|
||||
"Format": "",
|
||||
"Precision": 0,
|
||||
"WithoutKey": false,
|
||||
"Hide": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@ -1,5 +1,39 @@
|
||||
# CHANGELOG - go/service
|
||||
|
||||
## v1.5.12 (2026-06-07)
|
||||
- **基础设施对齐: 切换至 starter v1.5.3 编排模式**:
|
||||
- 弃用已废弃的 `starter.Run()`,全面转向 `starter.Start() / starter.Wait()`。
|
||||
|
||||
## v1.5.11 (2026-06-06)
|
||||
- **修复: 路由与静态文件匹配鲁棒性增强**:
|
||||
- **路径参数提取**: 修复了正则匹配路由(如 `{name}`)无法正确提取并注入路径参数到业务函数的问题。
|
||||
- **静态文件匹配**: 引入 `hostStatics` 有序路由表,实现“最长前缀匹配”策略,解决在复杂或重叠的静态目录配置下的匹配歧义问题。
|
||||
- **URL 兼容性**: 针对包含空格、中文字符及特殊符号的复杂 URL,在路由与静态文件匹配阶段统一进行 Robust 处理,彻底解决 404 隐患。
|
||||
- **Host 匹配增强**: 验证并明确了 Host 匹配的灵活性,支持 `hostname:port`, `hostname`, `:port` 的自动降级匹配。
|
||||
|
||||
## v1.5.10 (2026-06-05)
|
||||
- **修复: Static 服务 URL 解码**:
|
||||
- 修复了 \`service.Static()\` 在处理包含空格或特殊字符(如 \`%20\`)的请求路径时,因未解码导致文件匹配失败的问题。
|
||||
- 在路由匹配阶段提前进行 URL Path 解码,提升整体路径匹配的健壮性。
|
||||
|
||||
## v1.5.9 (2026-06-05)
|
||||
- **优化: 低代码环境深度对齐**:
|
||||
- **JS 友好型 Header**: 引入 \`service.Header\` 包装类,提供大小写不敏感的 \`Get/Set/Add/Del\` 方法,提升脚本开发体验。
|
||||
- **Cookie 遮蔽**: 在 \`Request\` 中实现方法遮蔽,确保 JS 侧看到的 Cookie 参数均为简化的 \`Service_Cookie\`,彻底解决穿透问题。
|
||||
- **API 统一**: 将 \`Request.Headers()\` 重命名为 \`Request.Header()\`,与 \`Response.Header()\` 保持命名对齐。
|
||||
- **重构**: 给内部字段(如 \`*http.Request\`, \`ResponseWriter\`)增加 \`js:"-"\` 标签,精准管控对 JS 暴露的 API 边界。
|
||||
- **修复**: 解决了因 Header 包装导致的 Go 内部代码(\`handler.go\`, \`static.go\`)编译错误。
|
||||
- **新特性: EnableWebDev 支持**:
|
||||
- 引入了 `service.EnableWebDev(config watch.Config)`,支持自动刷新页面的开发模式。
|
||||
- **WebSocket 同步**: 自动注册 `/_watch` 服务,与文件监听器协同工作。
|
||||
- **智能 HTML 注入**: 采用 `OutFilter` 在 HTML 响应末尾精准注入 WebSocket 重连脚本,支持静态文件与动态服务。
|
||||
- **性能优化**: 仅在开启开发模式时启用响应缓冲,生产环境无任何性能损失。
|
||||
- **基础设施**: 增加包级 `AddShutdownHook` 支持,提供更优雅的资源回收机制。
|
||||
- **依赖同步**: 升级至 `log v1.5.5`,对齐不带堆栈的 Warning 规范。
|
||||
|
||||
## v1.5.5 (2026-06-05)
|
||||
- **依赖同步**: 全量对齐至 `@go` 基础设施最新版本(`log v1.5.4`, `starter v1.5.2`, `db v1.5.2`)。
|
||||
|
||||
## v1.5.4 (2026-06-05)
|
||||
- **优化: 生命周期日志剥离**:
|
||||
- 彻底移除了 `WebServer` 内部冗余的 `starting / stopping / stopped` 控制台日志输出。
|
||||
|
||||
@ -24,7 +24,7 @@ func MakeDocument() []Api {
|
||||
return DefaultServer.MakeDocument()
|
||||
}
|
||||
|
||||
func (ws *webServer) MakeDocument() []Api {
|
||||
func (ws *WebServer) MakeDocument() []Api {
|
||||
out := make([]Api, 0)
|
||||
|
||||
// 1. Rewrite & Proxy
|
||||
|
||||
4
go.mod
4
go.mod
@ -10,10 +10,10 @@ require (
|
||||
apigo.cc/go/http v1.5.0
|
||||
apigo.cc/go/id v1.5.0
|
||||
apigo.cc/go/jsmod v1.5.0
|
||||
apigo.cc/go/log v1.5.4
|
||||
apigo.cc/go/log v1.5.5
|
||||
apigo.cc/go/redis v1.5.0
|
||||
apigo.cc/go/safe v1.5.0
|
||||
apigo.cc/go/starter v1.5.2
|
||||
apigo.cc/go/starter v1.5.3
|
||||
apigo.cc/go/timer v1.5.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/net v0.54.0
|
||||
|
||||
6
go.sum
6
go.sum
@ -16,8 +16,7 @@ apigo.cc/go/id v1.5.0 h1:MjNWPhBhDsoXaLeJDv/0wfJmVMU9EvOs8pWYfsTQ6e8=
|
||||
apigo.cc/go/id v1.5.0/go.mod h1:qhu4a1/KLc/XcBpcsRu+mXZt7U7Wvd9zMcPs4VspuPA=
|
||||
apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec=
|
||||
apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
|
||||
apigo.cc/go/log v1.5.4 h1:LNyU4v09gfcnZOY53ctnXoKzo45FHoEcPR33lk6PBaY=
|
||||
apigo.cc/go/log v1.5.4/go.mod h1:Djy+I5aLhGB/EjwRz4KHqkVEz584IAD55FAFiIfInuo=
|
||||
apigo.cc/go/log v1.5.5 h1:AFU7d7AQxkpgDHl7SnlEwd6yzGSFAlnrrjbrNDQnQHI=
|
||||
apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E=
|
||||
apigo.cc/go/rand v1.5.0/go.mod h1:Lh98S2dm9UY0X+M+kNQQEKyXHG5pcCKSFPyXN0QCGdk=
|
||||
apigo.cc/go/redis v1.5.0 h1:VXNDqzKj87BchF7ubDEH+T6lp8NrjeK0izU4ooo7u1A=
|
||||
@ -26,8 +25,7 @@ apigo.cc/go/safe v1.5.0 h1:W1NblmcU8cex1f9Y5z8mNLUJOzZTE1s6fszb3FbhGnk=
|
||||
apigo.cc/go/safe v1.5.0/go.mod h1:OfQ5d6COePSGEuPvMeOk6KagX2sezw7nvKh7exj9SeM=
|
||||
apigo.cc/go/shell v1.5.0 h1:WLDMMqUU0INeaBDmQsTPr0h/NfB2RknAtiJ5NL467+Q=
|
||||
apigo.cc/go/shell v1.5.0/go.mod h1:rYHA77d5hEsQHcJrbAWf1pHy0sxayeJ0gU55LA/JWQk=
|
||||
apigo.cc/go/starter v1.5.2 h1:bSNByK9uU+4+Rw8a83TNhQnUUjCPfJj7DVUpEuCD2wg=
|
||||
apigo.cc/go/starter v1.5.2/go.mod h1:iu3AnpqpriJBiTJC0MJyeFxGmmFto+SU+S2d96XhRco=
|
||||
apigo.cc/go/starter v1.5.3 h1:kakDapul+l63w3Ah1pnBxD1mup9Fbt821omWCiaGwCE=
|
||||
apigo.cc/go/timer v1.5.0 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk=
|
||||
apigo.cc/go/timer v1.5.0/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
|
||||
|
||||
74
handler.go
74
handler.go
@ -7,6 +7,7 @@ import (
|
||||
"apigo.cc/go/timer"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
@ -15,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
type RouteHandler struct {
|
||||
ws *webServer
|
||||
ws *WebServer
|
||||
webRequestingNum int64
|
||||
}
|
||||
|
||||
@ -33,15 +34,17 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
request := NewRequest(r)
|
||||
request.Id = requestId
|
||||
response := NewResponse(w)
|
||||
response := NewResponse(w, ws)
|
||||
response.Id = requestId
|
||||
requestLogger := log.New(requestId)
|
||||
|
||||
// 0. 延迟处理日志与状态检查
|
||||
var s *webServiceType
|
||||
var wsc *websocketServiceType
|
||||
var authLevel int
|
||||
var priority int
|
||||
var args = make(map[string]any)
|
||||
var result any
|
||||
|
||||
defer func() {
|
||||
// 捕捉 Panic
|
||||
@ -83,7 +86,7 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 过滤响应头
|
||||
respHeaders := make(map[string]string)
|
||||
for k, v := range response.Header() {
|
||||
for k, v := range response.Header().H {
|
||||
respHeaders[k] = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
@ -146,22 +149,20 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 3. 路由匹配
|
||||
path := r.URL.Path
|
||||
path, _ := url.PathUnescape(r.URL.Path)
|
||||
host := r.Host
|
||||
|
||||
// 处理静态文件
|
||||
if ws.processStatic(path, request, response, requestLogger) {
|
||||
return
|
||||
goto filter
|
||||
}
|
||||
|
||||
var wsc *websocketServiceType
|
||||
s, wsc = ws.findService(r.Method, host, path)
|
||||
s, wsc = ws.findService(r.Method, host, path, args)
|
||||
|
||||
// 4. 参数解析 (Form & Body)
|
||||
parseRequestArgs(request, args)
|
||||
|
||||
// 5. 前置过滤器
|
||||
var result any
|
||||
for _, filter := range ws.inFilters {
|
||||
result = filter(&args, request, response, requestLogger)
|
||||
if result != nil {
|
||||
@ -205,13 +206,18 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if s == nil && result == nil && !response.changed {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
result = "404 page not found"
|
||||
}
|
||||
|
||||
// 7. 后置过滤器
|
||||
filter:
|
||||
// 7. 后置过滤器 (即使 response.changed 也要执行,比如静态文件的 HTML 注入)
|
||||
for _, filter := range ws.outFilters {
|
||||
newResult, done := filter(args, request, response, result, requestLogger)
|
||||
if newResult != nil {
|
||||
result = newResult
|
||||
// 如果 response.changed 为 true,说明已经有内容写出了。
|
||||
// 如果过滤器返回了非 nil 的 result,我们通常认为它想替换或追加内容。
|
||||
// 特别是对于静态文件,如果我们清空了 body 并返回了新内容,result 就不再是 nil。
|
||||
}
|
||||
if done {
|
||||
break
|
||||
@ -219,7 +225,19 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 8. 输出结果
|
||||
if ws.hasOutFilter {
|
||||
// 过滤器模式:所有内容都应该从 result 或 response.body 中写出
|
||||
if result != nil {
|
||||
outputResult(response, result)
|
||||
} else if response.changed {
|
||||
response.PhysicalWrite(response.body)
|
||||
}
|
||||
} else {
|
||||
// 普通模式:result (业务返回值) 需要写出,而 response.changed (比如静态文件) 已经由 Response.Write 写过了
|
||||
if result != nil {
|
||||
outputResult(response, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hostOnly(host string) string {
|
||||
@ -227,7 +245,7 @@ func hostOnly(host string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
func (ws *webServer) findService(method, host, path string) (*webServiceType, *websocketServiceType) {
|
||||
func (ws *WebServer) findService(method, host, path string, args map[string]any) (*webServiceType, *websocketServiceType) {
|
||||
ws.webServicesLock.RLock()
|
||||
defer ws.webServicesLock.RUnlock()
|
||||
|
||||
@ -271,6 +289,14 @@ func (ws *webServer) findService(method, host, path string) (*webServiceType, *w
|
||||
continue
|
||||
}
|
||||
if s.pathMatcher != nil && s.pathMatcher.MatchString(path) {
|
||||
matches := s.pathMatcher.FindStringSubmatch(path)
|
||||
if len(matches) > 1 {
|
||||
for i, name := range s.pathArgs {
|
||||
if i+1 < len(matches) {
|
||||
args[name] = matches[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
@ -293,7 +319,7 @@ func parseRequestArgs(request *Request, args map[string]any) {
|
||||
|
||||
// Form params
|
||||
if request.Method == http.MethodPost || request.Method == http.MethodPut {
|
||||
contentType := request.Header.Get("Content-Type")
|
||||
contentType := request.Header().Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
_ = request.Body.Close()
|
||||
@ -313,7 +339,7 @@ func parseRequestArgs(request *Request, args map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *webServer) checkAuth(authLevel int, options *WebServiceOptions, request *Request, response *Response, args map[string]any, logger *log.Logger) (bool, any) {
|
||||
func (ws *WebServer) checkAuth(authLevel int, options *WebServiceOptions, request *Request, response *Response, args map[string]any, logger *log.Logger) (bool, any) {
|
||||
ac := ws.webAuthCheckers[authLevel]
|
||||
if ac == nil {
|
||||
ac = ws.webAuthChecker
|
||||
@ -332,7 +358,7 @@ func (ws *webServer) checkAuth(authLevel int, options *WebServiceOptions, reques
|
||||
return pass, obj
|
||||
}
|
||||
|
||||
func (ws *webServer) doWebService(service *webServiceType, request *Request, response *Response, args map[string]any,
|
||||
func (ws *WebServer) doWebService(service *webServiceType, request *Request, response *Response, args map[string]any,
|
||||
result any, logger *log.Logger, object any) any {
|
||||
if result != nil {
|
||||
return result
|
||||
@ -403,15 +429,19 @@ func outputResult(response *Response, result any) {
|
||||
if contentType != "" && response.Header().Get("Content-Type") == "" {
|
||||
response.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
_, _ = response.Write(data)
|
||||
}
|
||||
|
||||
func (ws *webServer) handleClientKeys(request *Request, response *Response) {
|
||||
if response.server != nil && response.server.hasOutFilter {
|
||||
response.PhysicalWrite(data)
|
||||
} else {
|
||||
_, _ = response.Write(data)
|
||||
}
|
||||
}
|
||||
func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
|
||||
// SessionId
|
||||
if ws.usedSessionIdKey != "" {
|
||||
sessionId := request.Header.Get(ws.usedSessionIdKey)
|
||||
sessionId := request.Header().Get(ws.usedSessionIdKey)
|
||||
if sessionId == "" && !ws.Config.SessionWithoutCookie {
|
||||
if ck, err := request.Cookie(ws.usedSessionIdKey); err == nil {
|
||||
if ck := request.GetCookie(ws.usedSessionIdKey); ck != nil {
|
||||
sessionId = ck.Value
|
||||
}
|
||||
}
|
||||
@ -430,15 +460,15 @@ func (ws *webServer) handleClientKeys(request *Request, response *Response) {
|
||||
})
|
||||
}
|
||||
}
|
||||
request.Header.Set(discover.HeaderSessionID, sessionId)
|
||||
request.Request.Header.Set(discover.HeaderSessionID, sessionId)
|
||||
response.Header().Set(ws.usedSessionIdKey, sessionId)
|
||||
}
|
||||
|
||||
// DeviceId
|
||||
if ws.usedDeviceIdKey != "" {
|
||||
deviceId := request.Header.Get(ws.usedDeviceIdKey)
|
||||
deviceId := request.Header().Get(ws.usedDeviceIdKey)
|
||||
if deviceId == "" && !ws.Config.DeviceWithoutCookie {
|
||||
if ck, err := request.Cookie(ws.usedDeviceIdKey); err == nil {
|
||||
if ck := request.GetCookie(ws.usedDeviceIdKey); ck != nil {
|
||||
deviceId = ck.Value
|
||||
}
|
||||
}
|
||||
@ -454,7 +484,7 @@ func (ws *webServer) handleClientKeys(request *Request, response *Response) {
|
||||
})
|
||||
}
|
||||
}
|
||||
request.Header.Set(discover.HeaderDeviceID, deviceId)
|
||||
request.Request.Header.Set(discover.HeaderDeviceID, deviceId)
|
||||
response.Header().Set(ws.usedDeviceIdKey, deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
14
js_export.go
14
js_export.go
@ -6,15 +6,15 @@ import (
|
||||
|
||||
func init() {
|
||||
jsmod.Register("service", map[string]any{
|
||||
// 类型占位工厂 (用于 AI 发现类型结构)
|
||||
"newRequest": func() *Request { return &Request{} },
|
||||
"newResponse": func() *Response { return &Response{} },
|
||||
"newWebSocket": func() *WebSocketConn { return &WebSocketConn{} },
|
||||
"newSession": func() *Session { return &Session{} },
|
||||
"newFile": func() *jsUploadFile { return &jsUploadFile{} },
|
||||
// 类型占位工厂 (用于 AI 发现类型结构,生成文档时隐藏)
|
||||
"__exportRequest": func() *Request { return &Request{} },
|
||||
"__exportResponse": func() *Response { return &Response{} },
|
||||
"__exportWebSocket": func() *WebSocketConn { return &WebSocketConn{} },
|
||||
"__exportSession": func() *Session { return &Session{} },
|
||||
"__exportFile": func() *jsUploadFile { return &jsUploadFile{} },
|
||||
|
||||
// 功能函数
|
||||
"upgrade": Upgrade,
|
||||
"Upgrade": Upgrade,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
10
proxy.go
10
proxy.go
@ -88,7 +88,7 @@ func (hc *HostContext) Proxy(authLevel int, path string, to string) *HostContext
|
||||
return hc
|
||||
}
|
||||
|
||||
func (ws *webServer) findProxy(request *Request) (int, *string, *string, string) {
|
||||
func (ws *WebServer) findProxy(request *Request) (int, *string, *string, string) {
|
||||
host := request.Host
|
||||
hostOnly, port, _ := strings.Cut(host, ":")
|
||||
hosts := []string{host}
|
||||
@ -145,7 +145,7 @@ func (ws *webServer) findProxy(request *Request) (int, *string, *string, string)
|
||||
return 0, nil, nil, ""
|
||||
}
|
||||
|
||||
func (ws *webServer) processProxy(request *Request, response *Response, logger *log.Logger) bool {
|
||||
func (ws *WebServer) processProxy(request *Request, response *Response, logger *log.Logger) bool {
|
||||
authLevel, proxyToApp, proxyToPath, foundHost := ws.findProxy(request)
|
||||
|
||||
if proxyToApp == nil || proxyToPath == nil || *proxyToApp == "" || *proxyToPath == "" {
|
||||
@ -186,7 +186,7 @@ func (ws *webServer) processProxy(request *Request, response *Response, logger *
|
||||
return true
|
||||
}
|
||||
|
||||
func (ws *webServer) getHttpClient() *gohttp.Client {
|
||||
func (ws *WebServer) getHttpClient() *gohttp.Client {
|
||||
// 尝试从注入对象获取
|
||||
if obj := ws.GetInject(reflect.TypeOf(&gohttp.Client{})); obj != nil {
|
||||
return obj.(*gohttp.Client)
|
||||
@ -198,7 +198,7 @@ func (ws *webServer) getHttpClient() *gohttp.Client {
|
||||
return gohttp.NewClient(timeout)
|
||||
}
|
||||
|
||||
func (ws *webServer) checkAuthForProxy(authLevel int, request *Request, response *Response, logger *log.Logger) (bool, any) {
|
||||
func (ws *WebServer) checkAuthForProxy(authLevel int, request *Request, response *Response, logger *log.Logger) (bool, any) {
|
||||
ac := ws.webAuthCheckers[authLevel]
|
||||
if ac == nil {
|
||||
ac = ws.webAuthChecker
|
||||
@ -245,7 +245,7 @@ func ReplaceProxies(host string, rules []ProxyRule) {
|
||||
DefaultServer.ReplaceProxies(host, rules)
|
||||
}
|
||||
|
||||
func (ws *webServer) ReplaceProxies(host string, rules []ProxyRule) {
|
||||
func (ws *WebServer) ReplaceProxies(host string, rules []ProxyRule) {
|
||||
newProxies := make([]*proxyType, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
newProxies = append(newProxies, parseProxyRule(r.AuthLevel, r.Path, r.ToApp, r.ToPath, r.To))
|
||||
|
||||
@ -17,14 +17,14 @@ func OnReload(handler func() error) {
|
||||
DefaultServer.OnReload(handler)
|
||||
}
|
||||
|
||||
func (ws *webServer) OnReload(handler func() error) {
|
||||
func (ws *WebServer) OnReload(handler func() error) {
|
||||
globalReloadHook.lock.Lock()
|
||||
defer globalReloadHook.lock.Unlock()
|
||||
globalReloadHook.hooks = append(globalReloadHook.hooks, handler)
|
||||
}
|
||||
|
||||
// triggerReload 触发所有注册的重新加载钩子
|
||||
func (ws *webServer) triggerReload() error {
|
||||
func (ws *WebServer) triggerReload() error {
|
||||
globalReloadHook.lock.RLock()
|
||||
hooks := make([]func() error, len(globalReloadHook.hooks))
|
||||
copy(hooks, globalReloadHook.hooks)
|
||||
|
||||
125
request.go
125
request.go
@ -33,13 +33,120 @@ func (f *UploadFile) Content() ([]byte, error) {
|
||||
return io.ReadAll(src)
|
||||
}
|
||||
|
||||
// Header 包装 http.Header 以提供 JS 友好的方法
|
||||
type Header struct {
|
||||
H http.Header `js:"-"`
|
||||
}
|
||||
|
||||
func (h *Header) Get(key string) string {
|
||||
return h.H.Get(key)
|
||||
}
|
||||
|
||||
func (h *Header) Set(key, value string) {
|
||||
h.H.Set(key, value)
|
||||
}
|
||||
|
||||
func (h *Header) Add(key, value string) {
|
||||
h.H.Add(key, value)
|
||||
}
|
||||
|
||||
func (h *Header) Del(key string) {
|
||||
h.H.Del(key)
|
||||
}
|
||||
|
||||
func (h *Header) Values(key string) []string {
|
||||
return h.H.Values(key)
|
||||
}
|
||||
|
||||
// Request 封装 http.Request
|
||||
type Request struct {
|
||||
*http.Request
|
||||
contextValues map[string]any
|
||||
*http.Request `js:"-"`
|
||||
contextValues map[string]any `js:"-"`
|
||||
Id string
|
||||
}
|
||||
|
||||
// Cookie 简化的 JS 友好 Cookie 结构
|
||||
type Cookie struct {
|
||||
Name string
|
||||
Value string
|
||||
Path string
|
||||
Domain string
|
||||
MaxAge int
|
||||
Secure bool
|
||||
HttpOnly bool
|
||||
}
|
||||
|
||||
func (r *Request) GetCookie(name string) *Cookie {
|
||||
c, err := r.Request.Cookie(name)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Cookie{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Path: c.Path,
|
||||
Domain: c.Domain,
|
||||
MaxAge: c.MaxAge,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Request) GetCookies() []*Cookie {
|
||||
res := make([]*Cookie, 0)
|
||||
for _, c := range r.Request.Cookies() {
|
||||
res = append(res, &Cookie{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Path: c.Path,
|
||||
Domain: c.Domain,
|
||||
MaxAge: c.MaxAge,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// AddCookie 遮蔽原生的 AddCookie
|
||||
func (r *Request) AddCookie(c *Cookie) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
r.Request.AddCookie(&http.Cookie{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Path: c.Path,
|
||||
Domain: c.Domain,
|
||||
MaxAge: c.MaxAge,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
})
|
||||
}
|
||||
|
||||
// CookiesNamed 遮蔽原生的 CookiesNamed
|
||||
func (r *Request) CookiesNamed(name string) []*Cookie {
|
||||
res := make([]*Cookie, 0)
|
||||
for _, c := range r.Request.Cookies() {
|
||||
if c.Name == name {
|
||||
res = append(res, &Cookie{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Path: c.Path,
|
||||
Domain: c.Domain,
|
||||
MaxAge: c.MaxAge,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
})
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *Request) Header() *Header {
|
||||
return &Header{H: r.Request.Header}
|
||||
}
|
||||
|
||||
// NewRequest 创建 Request 包装
|
||||
func NewRequest(httpRequest *http.Request) *Request {
|
||||
return &Request{
|
||||
@ -68,11 +175,11 @@ func (r *Request) Get(key string) any {
|
||||
|
||||
// MakeUrl 根据当前请求构建完整 URL
|
||||
func (r *Request) MakeUrl(path string) string {
|
||||
scheme := r.Header.Get(discover.HeaderScheme)
|
||||
scheme := r.Header().Get(discover.HeaderScheme)
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Header.Get(discover.HeaderHost)
|
||||
host := r.Header().Get(discover.HeaderHost)
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
@ -81,24 +188,24 @@ func (r *Request) MakeUrl(path string) string {
|
||||
|
||||
// DeviceId 获取设备 ID
|
||||
func (r *Request) DeviceId() string {
|
||||
return r.Header.Get(discover.HeaderDeviceID)
|
||||
return r.Header().Get(discover.HeaderDeviceID)
|
||||
}
|
||||
|
||||
// SessionId 获取会话 ID
|
||||
func (r *Request) SessionId() string {
|
||||
return r.Header.Get(discover.HeaderSessionID)
|
||||
return r.Header().Get(discover.HeaderSessionID)
|
||||
}
|
||||
|
||||
// SetUserId 设置用户 ID(传递给下游)
|
||||
func (r *Request) SetUserId(userId string) {
|
||||
r.Header.Set(discover.HeaderUserID, userId)
|
||||
r.Header().Set(discover.HeaderUserID, userId)
|
||||
}
|
||||
|
||||
// ClientIp 获取真实 IP
|
||||
func (r *Request) ClientIp() string {
|
||||
ip := r.Header.Get(discover.HeaderClientIP)
|
||||
ip := r.Header().Get(discover.HeaderClientIP)
|
||||
if ip == "" {
|
||||
ip = r.Header.Get(discover.HeaderForwardedFor)
|
||||
ip = r.Header().Get(discover.HeaderForwardedFor)
|
||||
}
|
||||
if ip == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
|
||||
75
response.go
75
response.go
@ -10,31 +10,48 @@ import (
|
||||
// Response 封装 http.ResponseWriter
|
||||
type Response struct {
|
||||
Id string
|
||||
Writer http.ResponseWriter
|
||||
Writer http.ResponseWriter `js:"-"`
|
||||
Code int
|
||||
body []byte
|
||||
outLen int
|
||||
changed bool
|
||||
headerWritten bool
|
||||
dontLog200 bool
|
||||
dontLogArgs []string
|
||||
ProxyHeader *http.Header
|
||||
body []byte `js:"-"`
|
||||
outLen int `js:"-"`
|
||||
changed bool `js:"-"`
|
||||
headerWritten bool `js:"-"`
|
||||
dontLog200 bool `js:"-"`
|
||||
dontLogArgs []string `js:"-"`
|
||||
ProxyHeader *http.Header `js:"-"`
|
||||
server *WebServer `js:"-"`
|
||||
}
|
||||
|
||||
func (r *Response) SetCookie(cookie *Cookie) {
|
||||
if cookie == nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(r.Writer, &http.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Path: cookie.Path,
|
||||
Domain: cookie.Domain,
|
||||
MaxAge: cookie.MaxAge,
|
||||
Secure: cookie.Secure,
|
||||
HttpOnly: cookie.HttpOnly,
|
||||
})
|
||||
}
|
||||
|
||||
// NewResponse 创建 Response 包装
|
||||
func NewResponse(writer http.ResponseWriter) *Response {
|
||||
func NewResponse(writer http.ResponseWriter, server *WebServer) *Response {
|
||||
return &Response{
|
||||
Writer: writer,
|
||||
Code: http.StatusOK,
|
||||
server: server,
|
||||
}
|
||||
}
|
||||
|
||||
// Header 获取响应头部
|
||||
func (r *Response) Header() http.Header {
|
||||
func (r *Response) Header() *Header {
|
||||
if r.ProxyHeader != nil {
|
||||
return *r.ProxyHeader
|
||||
return &Header{H: *r.ProxyHeader}
|
||||
}
|
||||
return r.Writer.Header()
|
||||
return &Header{H: r.Writer.Header()}
|
||||
}
|
||||
|
||||
// Write 写入响应内容
|
||||
@ -42,9 +59,27 @@ func (r *Response) Write(bytes []byte) (int, error) {
|
||||
r.checkWriteHeader()
|
||||
r.changed = true
|
||||
r.outLen += len(bytes)
|
||||
if r.Code != http.StatusOK && len(r.body) < 4096 {
|
||||
|
||||
// 如果有输出过滤器,我们必须先缓冲,不能直接写入网线,否则会导致重复输出
|
||||
if r.server != nil && r.server.hasOutFilter {
|
||||
r.body = append(r.body, bytes...)
|
||||
return len(bytes), nil
|
||||
}
|
||||
|
||||
// 即使没有过滤器,非 200 状态码也进行缓冲以便日志记录
|
||||
if r.Code != http.StatusOK {
|
||||
r.body = append(r.body, bytes...)
|
||||
}
|
||||
|
||||
if r.ProxyHeader != nil {
|
||||
r.copyProxyHeader()
|
||||
}
|
||||
return r.Writer.Write(bytes)
|
||||
}
|
||||
|
||||
// PhysicalWrite 物理写入网线,绕过过滤器缓冲逻辑
|
||||
func (r *Response) PhysicalWrite(bytes []byte) (int, error) {
|
||||
r.checkWriteHeader()
|
||||
if r.ProxyHeader != nil {
|
||||
r.copyProxyHeader()
|
||||
}
|
||||
@ -100,6 +135,20 @@ func (r *Response) GetStatusCode() int {
|
||||
return r.Code
|
||||
}
|
||||
|
||||
// GetBody 获取响应内容
|
||||
func (r *Response) GetBody() []byte {
|
||||
return r.body
|
||||
}
|
||||
|
||||
// ClearBody 清空响应内容缓冲区 (用于过滤器替换内容)
|
||||
func (r *Response) ClearBody() {
|
||||
r.body = nil
|
||||
r.outLen = 0
|
||||
// 注意:这里我们不重置 headerWritten 和 Code,因为 Header 已经发出去了。
|
||||
// 但是在某些测试环境下(如 httptest.Recorder),我们可以尝试“假装”没写过。
|
||||
// 实际上,生产环境下 Header 发出去就收不回来了,所以注入只能发生在 Body 层面。
|
||||
}
|
||||
|
||||
// DontLog200 标记不记录 200 状态码的日志
|
||||
func (r *Response) DontLog200() {
|
||||
r.dontLog200 = true
|
||||
|
||||
@ -49,7 +49,7 @@ func (hc *HostContext) Rewrite(path string, to string) *HostContext {
|
||||
return hc
|
||||
}
|
||||
|
||||
func (ws *webServer) processRewrite(request *Request, response *Response, logger *log.Logger) bool {
|
||||
func (ws *WebServer) processRewrite(request *Request, response *Response, logger *log.Logger) bool {
|
||||
host := request.Host
|
||||
hostOnly, port, _ := strings.Cut(host, ":")
|
||||
hosts := []string{host}
|
||||
@ -139,7 +139,7 @@ func ReplaceRewrites(host string, rules []RewriteRule) {
|
||||
DefaultServer.ReplaceRewrites(host, rules)
|
||||
}
|
||||
|
||||
func (ws *webServer) ReplaceRewrites(host string, rules []RewriteRule) {
|
||||
func (ws *WebServer) ReplaceRewrites(host string, rules []RewriteRule) {
|
||||
newRewrites := make([]*rewriteType, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
newRewrites = append(newRewrites, parseRewriteRule(r.Path, r.ToPath, r.To))
|
||||
|
||||
114
robustness_test.go
Normal file
114
robustness_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func TestStaticRobustness(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "robustness_test")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 创建复杂的目录结构
|
||||
subDir := filepath.Join(tempDir, "The NPC Awakens", "The Loop", "scene", "M")
|
||||
_ = os.MkdirAll(subDir, 0755)
|
||||
|
||||
fileName := "画面逐渐亮起,铁匠铺的铁锤在无人操作的情况下,机械地敲击着烧红的铁块。_large.webp"
|
||||
testFile := filepath.Join(subDir, fileName)
|
||||
content := []byte("fake webp content")
|
||||
_ = os.WriteFile(testFile, content, 0644)
|
||||
|
||||
// 注册静态目录
|
||||
ws := NewWebServer()
|
||||
ws.Config.App = "test"
|
||||
ws.Static("/img/", tempDir)
|
||||
|
||||
rh := &RouteHandler{ws: ws}
|
||||
|
||||
// 构造编码后的请求路径
|
||||
encodedPath := "/img/" + url.PathEscape("The NPC Awakens/The Loop/scene/M/画面逐渐亮起,铁匠铺的铁锤在无人操作的情况下,机械地敲击着烧红的铁块。_large.webp")
|
||||
|
||||
// 测试静态文件访问
|
||||
req := httptest.NewRequest("GET", encodedPath+"?v=1780317467305", nil)
|
||||
w := httptest.NewRecorder()
|
||||
rh.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200 for complex static file, got %d. Path: %s", w.Code, encodedPath)
|
||||
} else if string(w.Body.Bytes()) != string(content) {
|
||||
t.Errorf("Content mismatch for complex static file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicRobustness(t *testing.T) {
|
||||
ws := NewWebServer()
|
||||
ws.Config.App = "test"
|
||||
|
||||
pathPattern := "/api/scene/{name}"
|
||||
ws.Host("*").GET(pathPattern, func(in struct{ Name string }) string {
|
||||
return "Hello " + in.Name
|
||||
})
|
||||
|
||||
rh := &RouteHandler{ws: ws}
|
||||
|
||||
complexName := "画面逐渐亮起,铁匠铺的铁锤"
|
||||
encodedPath := "/api/scene/" + url.PathEscape(complexName)
|
||||
|
||||
req := httptest.NewRequest("GET", encodedPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
rh.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200 for complex dynamic path, got %d", w.Code)
|
||||
}
|
||||
expectedBody := "Hello " + complexName
|
||||
if w.Body.String() != expectedBody {
|
||||
t.Errorf("Got body: %s, expected: %s", w.Body.String(), expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostMatching(t *testing.T) {
|
||||
ws := NewWebServer()
|
||||
ws.Config.App = "test"
|
||||
|
||||
// 1. 注册只带端口的 Host
|
||||
ws.Host(":8080").GET("/port", func() string { return "port" })
|
||||
// 2. 注册只带域名的 Host
|
||||
ws.Host("localhost").GET("/host", func() string { return "host" })
|
||||
// 3. 注册完整 Host
|
||||
ws.Host("example.com:9000").GET("/full", func() string { return "full" })
|
||||
|
||||
rh := &RouteHandler{ws: ws}
|
||||
|
||||
tests := []struct {
|
||||
requestHost string
|
||||
path string
|
||||
expected string
|
||||
code int
|
||||
}{
|
||||
{"localhost:8080", "/port", "port", http.StatusOK},
|
||||
{"otherhost:8080", "/port", "port", http.StatusOK},
|
||||
{"localhost:9999", "/host", "host", http.StatusOK},
|
||||
{"example.com:9000", "/full", "full", http.StatusOK},
|
||||
{"example.com:8080", "/port", "port", http.StatusOK},
|
||||
{"localhost:8080", "/host", "host", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
req.Host = tt.requestHost
|
||||
w := httptest.NewRecorder()
|
||||
rh.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.code {
|
||||
t.Errorf("Host [%s] Path [%s] expected code %d, got %d", tt.requestHost, tt.path, tt.code, w.Code)
|
||||
}
|
||||
if tt.code == http.StatusOK && w.Body.String() != tt.expected {
|
||||
t.Errorf("Host [%s] Path [%s] expected body %s, got %s", tt.requestHost, tt.path, tt.expected, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
76
server.go
76
server.go
@ -7,6 +7,7 @@ import (
|
||||
"apigo.cc/go/redis"
|
||||
"apigo.cc/go/safe"
|
||||
"apigo.cc/go/starter"
|
||||
"apigo.cc/go/watch"
|
||||
"context"
|
||||
"fmt"
|
||||
"golang.org/x/net/http2"
|
||||
@ -22,7 +23,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type webServer struct {
|
||||
type staticType struct {
|
||||
path string
|
||||
rootPath *string
|
||||
}
|
||||
|
||||
type WebServer struct {
|
||||
Config ServiceConfig
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
@ -66,6 +72,7 @@ type webServer struct {
|
||||
codeStatics map[string]map[string]*string
|
||||
fileStatics map[string]map[string]*string
|
||||
dynamicStatics map[string]map[string]*string
|
||||
hostStatics map[string][]*staticType
|
||||
staticsByHostLock sync.RWMutex
|
||||
|
||||
// 过滤器与拦截器
|
||||
@ -84,16 +91,27 @@ type webServer struct {
|
||||
usedClientAppKey string
|
||||
usedSessionIdKey string
|
||||
sessionIdMaker func() string
|
||||
|
||||
// 停机钩子
|
||||
shutdownHooks []func()
|
||||
shutdownHooksLock sync.Mutex
|
||||
|
||||
// 性能优化:标记是否有输出过滤器
|
||||
hasOutFilter bool
|
||||
|
||||
// Web 开发模式配置
|
||||
webDevEnabled bool
|
||||
webDevConfig watch.Config
|
||||
}
|
||||
|
||||
// DefaultServer 全局单例服务实例
|
||||
var DefaultServer = newWebServer()
|
||||
var DefaultServer = NewWebServer()
|
||||
|
||||
// Config 全局配置对象 (指向 DefaultServer.Config)
|
||||
var Config = &DefaultServer.Config
|
||||
|
||||
func newWebServer() *webServer {
|
||||
ws := &webServer{
|
||||
func NewWebServer() *WebServer {
|
||||
ws := &WebServer{
|
||||
webServices: make(map[string]map[string]*webServiceType),
|
||||
regexWebServices: make(map[string][]*webServiceType),
|
||||
webServicesList: make([]*webServiceType, 0),
|
||||
@ -112,6 +130,7 @@ func newWebServer() *webServer {
|
||||
codeStatics: make(map[string]map[string]*string),
|
||||
fileStatics: make(map[string]map[string]*string),
|
||||
dynamicStatics: make(map[string]map[string]*string),
|
||||
hostStatics: make(map[string][]*staticType),
|
||||
webAuthCheckers: make(map[int]func(int, *log.Logger, *string, map[string]any, *Request, *Response, *WebServiceOptions) (pass bool, object any)),
|
||||
injectObjects: make(map[reflect.Type]any),
|
||||
injectFunctions: make(map[reflect.Type]func() any),
|
||||
@ -125,7 +144,7 @@ func SetDiscovererForTest(d *discover.Discoverer) {
|
||||
}
|
||||
|
||||
// ApplyConfig 将 ServiceConfig 中的路由策略应用到内部的文件级策略中
|
||||
func (ws *webServer) ApplyConfig() {
|
||||
func (ws *WebServer) ApplyConfig() {
|
||||
ws.hostPoliciesLock.Lock()
|
||||
defer ws.hostPoliciesLock.Unlock()
|
||||
|
||||
@ -213,7 +232,7 @@ func (ws *webServer) ApplyConfig() {
|
||||
ws.rebuildStaticsUnderLock("")
|
||||
}
|
||||
|
||||
func (ws *webServer) rebuildProxiesUnderLock(host string) {
|
||||
func (ws *WebServer) rebuildProxiesUnderLock(host string) {
|
||||
combined := make([]*proxyType, 0)
|
||||
combined = append(combined, ws.codeProxies[host]...)
|
||||
combined = append(combined, ws.fileProxies[host]...)
|
||||
@ -225,7 +244,7 @@ func (ws *webServer) rebuildProxiesUnderLock(host string) {
|
||||
ws.hostProxies[host] = combined
|
||||
}
|
||||
|
||||
func (ws *webServer) rebuildRewritesUnderLock(host string) {
|
||||
func (ws *WebServer) rebuildRewritesUnderLock(host string) {
|
||||
combined := make([]*rewriteType, 0)
|
||||
combined = append(combined, ws.codeRewrites[host]...)
|
||||
combined = append(combined, ws.fileRewrites[host]...)
|
||||
@ -237,7 +256,7 @@ func (ws *webServer) rebuildRewritesUnderLock(host string) {
|
||||
ws.hostRewrites[host] = combined
|
||||
}
|
||||
|
||||
func (ws *webServer) rebuildStaticsUnderLock(host string) {
|
||||
func (ws *WebServer) rebuildStaticsUnderLock(host string) {
|
||||
combined := make(map[string]*string)
|
||||
for k, v := range ws.codeStatics[host] {
|
||||
combined[k] = v
|
||||
@ -254,10 +273,20 @@ func (ws *webServer) rebuildStaticsUnderLock(host string) {
|
||||
} else {
|
||||
ws.staticsByHost[host] = combined
|
||||
}
|
||||
|
||||
// 构造有序的静态路由列表 (按路径长度降序排列,实现最长匹配)
|
||||
sorted := make([]*staticType, 0, len(combined))
|
||||
for k, v := range combined {
|
||||
sorted = append(sorted, &staticType{path: k, rootPath: v})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return len(sorted[i].path) > len(sorted[j].path)
|
||||
})
|
||||
ws.hostStatics[host] = sorted
|
||||
}
|
||||
|
||||
// Start 启动服务,实现 starter.Service 接口
|
||||
func (ws *webServer) Start(ctx context.Context, logger *log.Logger) error {
|
||||
func (ws *WebServer) Start(ctx context.Context, logger *log.Logger) error {
|
||||
if logger == nil {
|
||||
logger = log.DefaultLogger
|
||||
}
|
||||
@ -296,6 +325,10 @@ func (ws *webServer) Start(ctx context.Context, logger *log.Logger) error {
|
||||
addr = ":" + addr
|
||||
}
|
||||
|
||||
if ws.webDevEnabled {
|
||||
ws.initWebDev(logger)
|
||||
}
|
||||
|
||||
appName := ws.Config.App
|
||||
if appName == "" {
|
||||
appName = GetDefaultName()
|
||||
@ -409,8 +442,17 @@ func (ws *webServer) Start(ctx context.Context, logger *log.Logger) error {
|
||||
}
|
||||
|
||||
// Stop 停止服务,实现 starter.Service 接口
|
||||
func (ws *webServer) Stop(ctx context.Context) error {
|
||||
func (ws *WebServer) Stop(ctx context.Context) error {
|
||||
ws.running = false
|
||||
|
||||
// 执行停机钩子 (反序)
|
||||
ws.shutdownHooksLock.Lock()
|
||||
for i := len(ws.shutdownHooks) - 1; i >= 0; i-- {
|
||||
ws.shutdownHooks[i]()
|
||||
}
|
||||
ws.shutdownHooks = nil
|
||||
ws.shutdownHooksLock.Unlock()
|
||||
|
||||
if ws.discoverer != nil {
|
||||
ws.discoverer.Stop()
|
||||
}
|
||||
@ -423,7 +465,7 @@ func (ws *webServer) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Status 检查服务健康状态,实现 starter.Service 接口
|
||||
func (ws *webServer) Status() (string, error) {
|
||||
func (ws *WebServer) Status() (string, error) {
|
||||
if ws.server == nil || !ws.running {
|
||||
return "", fmt.Errorf("server is not running")
|
||||
}
|
||||
@ -431,7 +473,7 @@ func (ws *webServer) Status() (string, error) {
|
||||
}
|
||||
|
||||
// Reload 实现配置重新加载,实现 starter.Reloader 接口
|
||||
func (ws *webServer) Reload() error {
|
||||
func (ws *WebServer) Reload() error {
|
||||
logger := ws.logger
|
||||
if logger == nil {
|
||||
logger = log.DefaultLogger
|
||||
@ -448,7 +490,7 @@ func (ws *webServer) Reload() error {
|
||||
|
||||
// AsyncServer 兼容旧版异步服务实例
|
||||
type AsyncServer struct {
|
||||
*webServer
|
||||
*WebServer
|
||||
}
|
||||
|
||||
// Stop 兼容旧版的无参数停止方法
|
||||
@ -459,13 +501,13 @@ func (as *AsyncServer) Stop() {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), stopTimeout)
|
||||
defer cancel()
|
||||
_ = as.webServer.Stop(ctx)
|
||||
_ = as.WebServer.Stop(ctx)
|
||||
}
|
||||
|
||||
// AsyncStart 兼容旧版的异步启动方法
|
||||
func AsyncStart() *AsyncServer {
|
||||
_ = DefaultServer.Start(context.Background(), log.DefaultLogger)
|
||||
return &AsyncServer{webServer: DefaultServer}
|
||||
return &AsyncServer{WebServer: DefaultServer}
|
||||
}
|
||||
|
||||
// Wait 等待服务结束 (兼容旧版,直接阻塞)
|
||||
@ -486,6 +528,8 @@ func Start() {
|
||||
stopTimeout = 5 * time.Second
|
||||
}
|
||||
starter.Register("web-server", DefaultServer, 100, 5*time.Second, stopTimeout)
|
||||
starter.Run()
|
||||
if err := starter.Start(); err == nil {
|
||||
starter.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
188
service.go
188
service.go
@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"apigo.cc/go/log"
|
||||
"apigo.cc/go/watch"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// webServiceType 内部存储的服务元数据
|
||||
@ -58,7 +61,7 @@ func SetClientKeys(deviceIdKey, clientAppKey, sessionIdKey string) {
|
||||
DefaultServer.SetClientKeys(deviceIdKey, clientAppKey, sessionIdKey)
|
||||
}
|
||||
|
||||
func (ws *webServer) SetClientKeys(deviceIdKey, clientAppKey, sessionIdKey string) {
|
||||
func (ws *WebServer) SetClientKeys(deviceIdKey, clientAppKey, sessionIdKey string) {
|
||||
ws.usedDeviceIdKey = deviceIdKey
|
||||
ws.usedClientAppKey = clientAppKey
|
||||
ws.usedSessionIdKey = sessionIdKey
|
||||
@ -69,7 +72,7 @@ func SetSessionIdMaker(maker func() string) {
|
||||
DefaultServer.SetSessionIdMaker(maker)
|
||||
}
|
||||
|
||||
func (ws *webServer) SetSessionIdMaker(maker func() string) {
|
||||
func (ws *WebServer) SetSessionIdMaker(maker func() string) {
|
||||
ws.sessionIdMaker = maker
|
||||
}
|
||||
|
||||
@ -78,7 +81,7 @@ func SetAuthChecker(authChecker func(authLevel int, logger *log.Logger, url *str
|
||||
DefaultServer.SetAuthChecker(authChecker)
|
||||
}
|
||||
|
||||
func (ws *webServer) SetAuthChecker(authChecker func(authLevel int, logger *log.Logger, url *string, in map[string]any, request *Request, response *Response, options *WebServiceOptions) (pass bool, object any)) {
|
||||
func (ws *WebServer) SetAuthChecker(authChecker func(authLevel int, logger *log.Logger, url *string, in map[string]any, request *Request, response *Response, options *WebServiceOptions) (pass bool, object any)) {
|
||||
ws.webAuthChecker = authChecker
|
||||
}
|
||||
|
||||
@ -87,7 +90,7 @@ func AddAuthChecker(authLevels []int, authChecker func(authLevel int, logger *lo
|
||||
DefaultServer.AddAuthChecker(authLevels, authChecker)
|
||||
}
|
||||
|
||||
func (ws *webServer) AddAuthChecker(authLevels []int, authChecker func(authLevel int, logger *log.Logger, url *string, in map[string]any, request *Request, response *Response, options *WebServiceOptions) (pass bool, object any)) {
|
||||
func (ws *WebServer) AddAuthChecker(authLevels []int, authChecker func(authLevel int, logger *log.Logger, url *string, in map[string]any, request *Request, response *Response, options *WebServiceOptions) (pass bool, object any)) {
|
||||
for _, al := range authLevels {
|
||||
ws.webAuthCheckers[al] = authChecker
|
||||
}
|
||||
@ -98,22 +101,36 @@ func SetInFilter(filter func(in *map[string]any, request *Request, response *Res
|
||||
DefaultServer.SetInFilter(filter)
|
||||
}
|
||||
|
||||
func (ws *webServer) SetInFilter(filter func(in *map[string]any, request *Request, response *Response, logger *log.Logger) (out any)) {
|
||||
func (ws *WebServer) SetInFilter(filter func(in *map[string]any, request *Request, response *Response, logger *log.Logger) (out any)) {
|
||||
ws.inFilters = append(ws.inFilters, filter)
|
||||
}
|
||||
|
||||
// AddShutdownHook 增加停机钩子
|
||||
func AddShutdownHook(hook func()) {
|
||||
DefaultServer.AddShutdownHook(hook)
|
||||
}
|
||||
|
||||
func (ws *WebServer) AddShutdownHook(hook func()) {
|
||||
ws.shutdownHooksLock.Lock()
|
||||
defer ws.shutdownHooksLock.Unlock()
|
||||
ws.shutdownHooks = append(ws.shutdownHooks, hook)
|
||||
}
|
||||
|
||||
// SetOutFilter 设置后置过滤器
|
||||
func SetOutFilter(filter func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool)) {
|
||||
DefaultServer.SetOutFilter(filter)
|
||||
}
|
||||
|
||||
func (ws *webServer) SetOutFilter(filter func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool)) {
|
||||
func (ws *WebServer) SetOutFilter(filter func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool)) {
|
||||
ws.webServicesLock.Lock()
|
||||
defer ws.webServicesLock.Unlock()
|
||||
ws.outFilters = append(ws.outFilters, filter)
|
||||
ws.hasOutFilter = true
|
||||
}
|
||||
|
||||
// HostContext 提供流式服务注册能力
|
||||
type HostContext struct {
|
||||
ws *webServer
|
||||
ws *WebServer
|
||||
host string
|
||||
}
|
||||
|
||||
@ -122,7 +139,7 @@ func Host(host string) *HostContext {
|
||||
return DefaultServer.Host(host)
|
||||
}
|
||||
|
||||
func (ws *webServer) Host(host string) *HostContext {
|
||||
func (ws *WebServer) Host(host string) *HostContext {
|
||||
if host == "" {
|
||||
host = "*"
|
||||
}
|
||||
@ -134,7 +151,7 @@ func Register(method, path string, serviceFunc any) *webServiceType {
|
||||
return DefaultServer.Register(method, path, serviceFunc)
|
||||
}
|
||||
|
||||
func (ws *webServer) Register(method, path string, serviceFunc any) *webServiceType {
|
||||
func (ws *WebServer) Register(method, path string, serviceFunc any) *webServiceType {
|
||||
return ws.Host("*").Register(method, path, serviceFunc)
|
||||
}
|
||||
|
||||
@ -143,7 +160,7 @@ func RegisterWebsocket(path string, serviceFunc any) *websocketServiceType {
|
||||
return DefaultServer.RegisterWebsocket(path, serviceFunc)
|
||||
}
|
||||
|
||||
func (ws *webServer) RegisterWebsocket(path string, serviceFunc any) *websocketServiceType {
|
||||
func (ws *WebServer) RegisterWebsocket(path string, serviceFunc any) *websocketServiceType {
|
||||
return ws.Host("*").WebSocket(path, serviceFunc)
|
||||
}
|
||||
|
||||
@ -152,7 +169,7 @@ func Proxy(authLevel int, path string, to string) {
|
||||
DefaultServer.Proxy(authLevel, path, to)
|
||||
}
|
||||
|
||||
func (ws *webServer) Proxy(authLevel int, path string, to string) {
|
||||
func (ws *WebServer) Proxy(authLevel int, path string, to string) {
|
||||
ws.Host("*").Proxy(authLevel, path, to)
|
||||
}
|
||||
|
||||
@ -161,7 +178,7 @@ func Restful(authLevel int, path string, serviceStruct any) {
|
||||
DefaultServer.Restful(authLevel, path, serviceStruct)
|
||||
}
|
||||
|
||||
func (ws *webServer) Restful(authLevel int, path string, serviceStruct any) {
|
||||
func (ws *WebServer) Restful(authLevel int, path string, serviceStruct any) {
|
||||
ws.Host("*").Restful(authLevel, path, serviceStruct)
|
||||
}
|
||||
|
||||
@ -451,7 +468,7 @@ func GetInject(dataType reflect.Type) any {
|
||||
return DefaultServer.GetInject(dataType)
|
||||
}
|
||||
|
||||
func (ws *webServer) GetInject(dataType reflect.Type) any {
|
||||
func (ws *WebServer) GetInject(dataType reflect.Type) any {
|
||||
if obj, exists := ws.injectObjects[dataType]; exists {
|
||||
return obj
|
||||
}
|
||||
@ -471,3 +488,148 @@ func GetInjectT[T any]() T {
|
||||
}
|
||||
return obj.(T)
|
||||
}
|
||||
|
||||
var webDevOnce sync.Once
|
||||
|
||||
// EnableWebDev 开启 Web 开发模式,支持自动刷新
|
||||
func EnableWebDev(config watch.Config) {
|
||||
DefaultServer.webDevEnabled = true
|
||||
DefaultServer.webDevConfig = config
|
||||
}
|
||||
|
||||
func (ws *WebServer) initWebDev(logger *log.Logger) {
|
||||
webDevOnce.Do(func() {
|
||||
logger.Warning("Web Development Mode Enabled. This should NOT be used in production environment.")
|
||||
onWatchConn := map[string]*WebSocketConn{}
|
||||
onWatchLock := sync.Mutex{}
|
||||
|
||||
// 1. 注册 WebSocket 服务
|
||||
ws.RegisterWebsocket("/_watch", func(request *Request, conn *WebSocketConn, logger *log.Logger) {
|
||||
onWatchLock.Lock()
|
||||
onWatchConn[request.Id] = conn
|
||||
onWatchLock.Unlock()
|
||||
|
||||
// 保持连接,处理消息 (如 ping)
|
||||
for {
|
||||
if _, err := conn.ReadString(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onWatchLock.Lock()
|
||||
delete(onWatchConn, request.Id)
|
||||
onWatchLock.Unlock()
|
||||
})
|
||||
|
||||
// 2. 启动文件监听
|
||||
watcher, err := watch.Start(ws.webDevConfig, func(e *watch.Event) {
|
||||
onWatchLock.Lock()
|
||||
defer onWatchLock.Unlock()
|
||||
for _, conn := range onWatchConn {
|
||||
_ = conn.Send("reload")
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Error("failed to start watch for EnableWebDev", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 注册停机钩子
|
||||
ws.AddShutdownHook(func() {
|
||||
watcher.Stop()
|
||||
onWatchLock.Lock()
|
||||
for _, conn := range onWatchConn {
|
||||
_ = conn.Close()
|
||||
}
|
||||
onWatchLock.Unlock()
|
||||
})
|
||||
|
||||
// 4. 注册输出过滤器进行注入
|
||||
ws.SetOutFilter(func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool) {
|
||||
contentType := response.Header().Get("Content-Type")
|
||||
var outStr string
|
||||
|
||||
if out != nil {
|
||||
switch v := out.(type) {
|
||||
case string:
|
||||
outStr = v
|
||||
case []byte:
|
||||
outStr = string(v)
|
||||
}
|
||||
}
|
||||
|
||||
if outStr == "" && response.changed {
|
||||
outStr = string(response.GetBody())
|
||||
}
|
||||
|
||||
if outStr == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
isHtml := strings.HasPrefix(contentType, "text/html")
|
||||
if !isHtml && (contentType == "" || strings.HasPrefix(contentType, "text/plain")) {
|
||||
// 检测内容前 100 字节是否包含 <html
|
||||
checkLen := int(math.Min(float64(len(outStr)), 100))
|
||||
if strings.Contains(strings.ToLower(outStr[0:checkLen]), "<html") {
|
||||
isHtml = true
|
||||
}
|
||||
}
|
||||
|
||||
if isHtml {
|
||||
if strings.Contains(outStr, "let _watchWS = null") {
|
||||
return nil, false
|
||||
}
|
||||
// 注入自动刷新的代码
|
||||
injectCode := `<script>
|
||||
let _watchWS = null
|
||||
let _watchWSConnection = false
|
||||
let _watchWSIsFirst = true
|
||||
function connect() {
|
||||
_watchWSConnection = true
|
||||
let ws = new WebSocket(location.protocol.replace('http', 'ws') + '//' + location.host + '/_watch')
|
||||
ws.onopen = () => {
|
||||
_watchWS = ws
|
||||
_watchWSConnection = false
|
||||
if( !_watchWSIsFirst ) location.reload()
|
||||
_watchWSIsFirst = false
|
||||
}
|
||||
ws.onmessage = () => {
|
||||
location.reload()
|
||||
}
|
||||
ws.onclose = () => {
|
||||
_watchWS = null
|
||||
_watchWSConnection = false
|
||||
}
|
||||
}
|
||||
setInterval(()=>{
|
||||
if(_watchWS!= null){
|
||||
try{
|
||||
_watchWS.send("ping")
|
||||
}catch(err){
|
||||
_watchWS = null
|
||||
_watchWSConnection = false
|
||||
}
|
||||
} else if(!_watchWSConnection){
|
||||
connect()
|
||||
}
|
||||
}, 1000)
|
||||
connect()
|
||||
</script>`
|
||||
// 仅替换最后一个 </html> 避免多个标签时的重复注入
|
||||
lastIndex := strings.LastIndex(outStr, "</html>")
|
||||
if lastIndex != -1 {
|
||||
outStr = outStr[:lastIndex] + injectCode + outStr[lastIndex:]
|
||||
} else {
|
||||
outStr = outStr + injectCode
|
||||
}
|
||||
|
||||
// 无论如何,只要我们提供了新的输出,就清空原始 Body,防止 handler 重复写入
|
||||
response.ClearBody()
|
||||
return []byte(outStr), false
|
||||
}
|
||||
return nil, false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
28
static.go
28
static.go
@ -5,6 +5,7 @@ import (
|
||||
"apigo.cc/go/log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@ -25,7 +26,7 @@ func Static(path, rootPath string) {
|
||||
DefaultServer.Static(path, rootPath)
|
||||
}
|
||||
|
||||
func (ws *webServer) Static(path, rootPath string) {
|
||||
func (ws *WebServer) Static(path, rootPath string) {
|
||||
ws.Host("*").Static(path, rootPath)
|
||||
}
|
||||
|
||||
@ -34,7 +35,7 @@ func StaticByHost(path, rootPath, host string) {
|
||||
DefaultServer.StaticByHost(path, rootPath, host)
|
||||
}
|
||||
|
||||
func (ws *webServer) StaticByHost(path, rootPath, host string) {
|
||||
func (ws *WebServer) StaticByHost(path, rootPath, host string) {
|
||||
if !filepath.IsAbs(rootPath) {
|
||||
if absPath, err := filepath.Abs(rootPath); err == nil {
|
||||
rootPath = absPath
|
||||
@ -56,7 +57,7 @@ func ReplaceStatics(host string, config map[string]string) {
|
||||
DefaultServer.ReplaceStatics(host, config)
|
||||
}
|
||||
|
||||
func (ws *webServer) ReplaceStatics(host string, config map[string]string) {
|
||||
func (ws *WebServer) ReplaceStatics(host string, config map[string]string) {
|
||||
newStatics := make(map[string]*string, len(config))
|
||||
for path, rootPath := range config {
|
||||
rp := rootPath
|
||||
@ -75,31 +76,30 @@ func (ws *webServer) ReplaceStatics(host string, config map[string]string) {
|
||||
ws.rebuildStaticsUnderLock(host)
|
||||
}
|
||||
|
||||
func (ws *webServer) getStaticFilePath(requestPath, host string) string {
|
||||
func (ws *WebServer) getStaticFilePath(requestPath, host string) string {
|
||||
requestPath, _ = url.PathUnescape(requestPath)
|
||||
ws.staticsByHostLock.RLock()
|
||||
defer ws.staticsByHostLock.RUnlock()
|
||||
|
||||
// 优先匹配指定域名的配置
|
||||
if hostConfig, exists := ws.staticsByHost[host]; exists {
|
||||
if filePath := ws.findMatchedPath(hostConfig, requestPath); filePath != "" {
|
||||
if filePath := ws.findMatchedPathSorted(ws.hostStatics[host], requestPath); filePath != "" {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配全局配置
|
||||
return ws.findMatchedPath(ws.statics, requestPath)
|
||||
return ws.findMatchedPathSorted(ws.hostStatics[""], requestPath)
|
||||
}
|
||||
|
||||
func (ws *webServer) findMatchedPath(config map[string]*string, requestPath string) string {
|
||||
for urlPath, rootPath := range config {
|
||||
if strings.HasPrefix(requestPath, urlPath) {
|
||||
return filepath.Join(*rootPath, requestPath[len(urlPath):])
|
||||
func (ws *WebServer) findMatchedPathSorted(config []*staticType, requestPath string) string {
|
||||
for _, rule := range config {
|
||||
if strings.HasPrefix(requestPath, rule.path) {
|
||||
return filepath.Join(*rule.rootPath, requestPath[len(rule.path):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ws *webServer) processStatic(requestPath string, request *Request, response *Response, logger *log.Logger) bool {
|
||||
func (ws *WebServer) processStatic(requestPath string, request *Request, response *Response, logger *log.Logger) bool {
|
||||
filePath := ws.getStaticFilePath(requestPath, request.Host)
|
||||
if filePath == "" {
|
||||
return false
|
||||
@ -132,7 +132,7 @@ func (ws *webServer) processStatic(requestPath string, request *Request, respons
|
||||
}
|
||||
|
||||
// 检查 304
|
||||
if ifModifiedSince := request.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
|
||||
if ifModifiedSince := request.Header().Get("If-Modified-Since"); ifModifiedSince != "" {
|
||||
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {
|
||||
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)) {
|
||||
|
||||
@ -59,7 +59,7 @@ func Upgrade(response *Response, request *Request) (*WebSocketConn, error) {
|
||||
return &WebSocketConn{Conn: conn}, nil
|
||||
}
|
||||
|
||||
func (ws *webServer) doWebsocketService(wsc *websocketServiceType, request *Request, response *Response, logger *log.Logger, object any) {
|
||||
func (ws *WebServer) doWebsocketService(wsc *websocketServiceType, request *Request, response *Response, logger *log.Logger, object any) {
|
||||
wsConn, err := Upgrade(response, request)
|
||||
if err != nil {
|
||||
logger.Error("websocket upgrade failed", "error", err.Error())
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"apigo.cc/go/log"
|
||||
"apigo.cc/go/watch"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -47,3 +51,63 @@ func TestWebSocketService(t *testing.T) {
|
||||
t.Errorf("Reply mismatch: %v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableWebDev(t *testing.T) {
|
||||
// 1. 初始化 EnableWebDev
|
||||
EnableWebDev(watch.Config{
|
||||
Paths: []string{"."},
|
||||
})
|
||||
|
||||
// 必须手动调用 initWebDev 或触发 Start,因为现在的逻辑是延迟初始化的
|
||||
DefaultServer.initWebDev(log.DefaultLogger)
|
||||
|
||||
// 2. 准备一个真实的静态 HTML 文件
|
||||
staticDir := "test_static"
|
||||
_ = os.MkdirAll(staticDir, 0755)
|
||||
htmlFile := filepath.Join(staticDir, "index.html")
|
||||
_ = os.WriteFile(htmlFile, []byte("<html><head></head><body>Static Content</body></html>"), 0644)
|
||||
defer os.RemoveAll(staticDir)
|
||||
|
||||
// 注册静态服务
|
||||
Static("/static/", staticDir)
|
||||
|
||||
handler := &RouteHandler{ws: DefaultServer}
|
||||
|
||||
// 3. 测试静态文件注入
|
||||
req := httptest.NewRequest("GET", "/static/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "let _watchWS = null") {
|
||||
t.Errorf("Static HTML injection failed, code not found in body: %s", body)
|
||||
}
|
||||
|
||||
// 4. 测试普通服务注入
|
||||
Register("GET", "/test-dev", func() string {
|
||||
return "<html><head></head><body>Hello</body></html>"
|
||||
})
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/test-dev", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
body2 := w2.Body.String()
|
||||
if !strings.Contains(body2, "let _watchWS = null") {
|
||||
t.Errorf("Dynamic HTML injection failed")
|
||||
}
|
||||
|
||||
// 5. 验证非 HTML 不注入
|
||||
Register("GET", "/test-json", func() map[string]string {
|
||||
return map[string]string{"foo": "bar"}
|
||||
})
|
||||
req3 := httptest.NewRequest("GET", "/test-json", nil)
|
||||
w3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w3, req3)
|
||||
|
||||
body3 := w3.Body.String()
|
||||
if strings.Contains(body3, "let _watchWS = null") {
|
||||
t.Errorf("JSON should not be injected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user