feat: enhance Makefile commands and update README for improved development workflow
refactor: separate API and SPA handling in server routes and add static file serving options test: add tests for SPA handling and static file serving feat: implement embedded SPA support for production and development environments
This commit is contained in:
@@ -3,8 +3,6 @@ package bootstrap
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,9 +10,13 @@ import (
|
||||
_ "cs-agent/internal/ai/runtime"
|
||||
"cs-agent/internal/middleware"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/ginx"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/services"
|
||||
webspa "cs-agent/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
|
||||
_ "cs-agent/internal/services/wx_callback_handlers"
|
||||
)
|
||||
@@ -29,8 +31,25 @@ func NewServer() (*gin.Engine, error) {
|
||||
|
||||
addRouter(app)
|
||||
|
||||
app.StaticFS(cfg.Storage.Local.BaseURL, http.Dir(cfg.Storage.Local.Root))
|
||||
registerDashboardStatic(app, "web/out")
|
||||
notFoundPrefixes := []string{"/api/"}
|
||||
if baseURL := strings.TrimRight(cfg.Storage.Local.BaseURL, "/"); baseURL != "" {
|
||||
notFoundPrefixes = append(notFoundPrefixes, baseURL+"/")
|
||||
}
|
||||
app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root))
|
||||
ginx.HandleSPA(app, ginx.SPAOptions{
|
||||
Root: "./web/out",
|
||||
EmbeddedFS: webspa.SPA,
|
||||
EmbeddedRoot: "out",
|
||||
DirOptions: ginx.DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: notFoundPrefixes,
|
||||
NotFoundHandler: func(ctx *gin.Context) {
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found"))
|
||||
},
|
||||
})
|
||||
|
||||
return app, nil
|
||||
}
|
||||
@@ -134,26 +153,3 @@ func addRouter(app *gin.Engine) {
|
||||
thirdGroup := app.Group("/api/third")
|
||||
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
|
||||
}
|
||||
|
||||
func registerDashboardStatic(app *gin.Engine, root string) {
|
||||
app.NoRoute(func(ctx *gin.Context) {
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, "/api/") {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
|
||||
return
|
||||
}
|
||||
requestPath := filepath.Clean(strings.TrimPrefix(ctx.Request.URL.Path, "/"))
|
||||
if strings.HasPrefix(requestPath, "..") {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if requestPath == "." {
|
||||
requestPath = "index.html"
|
||||
}
|
||||
fullPath := filepath.Join(root, requestPath)
|
||||
if stat, err := os.Stat(fullPath); err == nil && !stat.IsDir() {
|
||||
ctx.File(fullPath)
|
||||
return
|
||||
}
|
||||
ctx.File(filepath.Join(root, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/pkg/config"
|
||||
@@ -43,3 +45,40 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
contentType string
|
||||
}{
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
|
||||
{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
|
||||
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type noDirFS struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
type DirOptions struct {
|
||||
ShowList bool
|
||||
SPA bool
|
||||
IndexName string
|
||||
}
|
||||
|
||||
type SPAOptions struct {
|
||||
Root string
|
||||
EmbeddedFS fs.FS
|
||||
EmbeddedRoot string
|
||||
DirOptions DirOptions
|
||||
NotFoundPrefixes []string
|
||||
NotFoundHandler gin.HandlerFunc
|
||||
}
|
||||
|
||||
func StaticFiles(root string) http.FileSystem {
|
||||
return noDirFS{fs: http.Dir(root)}
|
||||
}
|
||||
|
||||
func (n noDirFS) Open(name string) (http.File, error) {
|
||||
file, err := n.fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func HandleSPA(engine *gin.Engine, options SPAOptions) gin.HandlerFunc {
|
||||
handler := NewSPAHandler(options.Root, options.EmbeddedFS, options.EmbeddedRoot, options.DirOptions)
|
||||
engine.GET("/", handler)
|
||||
engine.HEAD("/", handler)
|
||||
engine.NoRoute(func(ctx *gin.Context) {
|
||||
for _, prefix := range options.NotFoundPrefixes {
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, prefix) {
|
||||
if options.NotFoundHandler != nil {
|
||||
options.NotFoundHandler(ctx)
|
||||
return
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
handler(ctx)
|
||||
})
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewSPAHandler(root string, embeddedFS fs.FS, embeddedRoot string, options DirOptions) gin.HandlerFunc {
|
||||
if _, err := os.Stat(path.Join(root, options.IndexName)); err == nil {
|
||||
return DirHandler(http.Dir(root), options)
|
||||
}
|
||||
|
||||
spaFS, err := fs.Sub(embeddedFS, embeddedRoot)
|
||||
if err != nil {
|
||||
slog.Error("failed to load embedded SPA files", slog.Any("err", err))
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return DirHandler(http.FS(spaFS), options)
|
||||
}
|
||||
|
||||
func DirHandler(fileSystem http.FileSystem, options DirOptions) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
name := path.Clean(ctx.Request.URL.Path)
|
||||
if name == "." || name == "/" {
|
||||
name = "/" + options.IndexName
|
||||
}
|
||||
|
||||
file, err := fileSystem.Open(name)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
if options.IndexName != "" {
|
||||
indexName := path.Join(name, options.IndexName)
|
||||
indexFile, err := fileSystem.Open(indexName)
|
||||
if err == nil {
|
||||
defer indexFile.Close()
|
||||
indexInfo, statErr := indexFile.Stat()
|
||||
if statErr != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !indexInfo.IsDir() {
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !options.ShowList {
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
serveFile(ctx, name, file, info)
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if options.SPA && options.IndexName != "" {
|
||||
indexName := "/" + options.IndexName
|
||||
indexFile, err := fileSystem.Open(indexName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer indexFile.Close()
|
||||
indexInfo, err := indexFile.Stat()
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func serveFile(ctx *gin.Context, name string, file http.File, info os.FileInfo) {
|
||||
http.ServeContent(ctx.Writer, ctx.Request, name, info.ModTime(), file)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDirHandlerWithSPA(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "manifest"), []byte("manifest content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/assets/not-exists.css", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/images/not-exists.png", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/fonts/not-exists", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/manifest", wantStatus: http.StatusOK, wantBody: "manifest content"},
|
||||
{path: "/dashboard", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
}
|
||||
handler := DirHandler(http.Dir(root), DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
|
||||
handler(ctx)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d; body=%q", tt.path, rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesDoesNotOpenDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileSystem := StaticFiles(root)
|
||||
if file, err := fileSystem.Open("/file.txt"); err != nil {
|
||||
t.Fatalf("Open(file.txt) err=%v", err)
|
||||
} else {
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
if file, err := fileSystem.Open("/"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("Open(/) succeeded, want error")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("Open(/) err=%v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSPAKeepsNotFoundPrefixes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
HandleSPA(engine, SPAOptions{
|
||||
Root: root,
|
||||
DirOptions: DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: []string{"/api/"},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/dashboard", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user