主题系统从带数据库/事务/repo 层/远程下载/配置管理/主题市场的重型架构, 彻底简化为纯文件系统操作: 后端: - 主题数据全部存放在 data/themes/ 目录,不经过数据库 - 删除主题配置功能(managed/raw/redirect schema、配置存取 API、 ThemeConfiguration model、theme_configurations 表、repo 层) - 删除远程下载(URL 安装、GitHub Release 更新、SSRF 防护、SHA-256) - 删除版本范围检查(semver 解析/比较/匹配、pikaVersion 校验) - 删除主题市场 - ThemeService 不再持有 db,合并为 theme.go + theme_util.go 两个文件 - 主题类型从 models 包移到 service 包(非数据库模型) - 保留:ZIP 上传解压(含目录穿越防护)、目录扫描列表、启用/删除、 覆盖安装、默认主题回退、启动恢复、入口 HTML 渲染缓存 前端 portal(官方默认主题): - 从 47 个碎片文件精简到 15 个,按标准目录组织 - 删除 theme-sdk 包,API 客户端和类型内联回 portal,完全自包含 - 按职责拆分:types.ts / api.ts / constants.ts / lib/utils.ts / contexts/ColorMode.tsx / hooks/index.ts / components.tsx / pages/ - 每个页面一个文件,图表和子组件内联,fork 二开只需看一个文件
128 lines
4.4 KiB
Go
128 lines
4.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func TestAdminPageRoutingBoundary(t *testing.T) {
|
|
for _, path := range []string{"/admin", "/admin/login", "/admin/github/callback", "/admin/oidc/callback", "/admin/agents"} {
|
|
if !isAdminPage(path) {
|
|
t.Errorf("expected admin route: %s", path)
|
|
}
|
|
}
|
|
for _, path := range []string{"/", "/login", "/github/callback", "/oidc/callback", "/servers/id", "/monitors/id", "/administrator", "/api/admin/themes"} {
|
|
if isAdminPage(path) {
|
|
t.Errorf("public/API route classified as admin page: %s", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeSPARejectsAPIWebSocketAssetsAndNonGET(t *testing.T) {
|
|
handler := &WebHandler{}
|
|
for _, test := range []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/api/unknown"},
|
|
{http.MethodGet, "/ws/unknown"},
|
|
{http.MethodPost, "/servers/1"},
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(test.method, test.path, nil)
|
|
context := echo.New().NewContext(request, recorder)
|
|
err := handler.ServeSPA(context)
|
|
httpError, ok := err.(*echo.HTTPError)
|
|
if !ok || httpError.Code != http.StatusNotFound {
|
|
t.Errorf("%s %s should return 404, got %v", test.method, test.path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveStaticPathAndHeaders(t *testing.T) {
|
|
root := t.TempDir()
|
|
asset := filepath.Join(root, "assets", "app.js")
|
|
if err := os.MkdirAll(filepath.Dir(asset), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(asset, []byte("console.log('ok')"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resolved, ok := resolveStaticPath(root, "assets/app.js"); !ok || resolved == "" {
|
|
t.Fatal("valid static asset was rejected")
|
|
}
|
|
for _, path := range []string{"", "../secret", "assets\\app.js", "/../../secret"} {
|
|
if _, ok := resolveStaticPath(root, path); ok {
|
|
t.Errorf("unsafe path accepted: %q", path)
|
|
}
|
|
}
|
|
|
|
if runtime.GOOS != "windows" {
|
|
outside := filepath.Join(t.TempDir(), "secret.js")
|
|
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "link.js")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := resolveStaticPath(root, "link.js"); ok {
|
|
t.Fatal("symlink asset was accepted")
|
|
}
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil)
|
|
context := echo.New().NewContext(request, recorder)
|
|
if err := serveStaticFile(context, root, "assets/app.js", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" ||
|
|
recorder.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" ||
|
|
recorder.Header().Get("Content-Type") != "text/javascript; charset=utf-8" {
|
|
t.Fatalf("unexpected static headers: %#v", recorder.Header())
|
|
}
|
|
}
|
|
|
|
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
|
|
webRoot := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(webRoot, "admin", "assets", "app.js"), []byte("admin"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PIKA_WEB_DIR", webRoot)
|
|
|
|
e := echo.New()
|
|
handler := &WebHandler{}
|
|
e.GET("/admin/assets/*", handler.ServeAdminAsset)
|
|
e.GET("/*", handler.ServeSPA)
|
|
|
|
assetRecorder := httptest.NewRecorder()
|
|
e.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil))
|
|
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
|
|
t.Fatalf("wildcard asset route failed: status=%d body=%q", assetRecorder.Code, assetRecorder.Body.String())
|
|
}
|
|
|
|
adminRecorder := httptest.NewRecorder()
|
|
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
|
|
if adminRecorder.Code != http.StatusOK || adminRecorder.Body.String() != "<html>admin</html>" {
|
|
t.Fatalf("admin SPA route failed: status=%d body=%q", adminRecorder.Code, adminRecorder.Body.String())
|
|
}
|
|
|
|
apiRecorder := httptest.NewRecorder()
|
|
e.ServeHTTP(apiRecorder, httptest.NewRequest(http.MethodGet, "/api/unknown", nil))
|
|
if apiRecorder.Code != http.StatusNotFound || apiRecorder.Header().Get("Content-Type") == "text/html; charset=utf-8" {
|
|
t.Fatalf("unknown API entered SPA fallback: status=%d type=%q", apiRecorder.Code, apiRecorder.Header().Get("Content-Type"))
|
|
}
|
|
}
|