[优化] 增加根目录选项

This commit is contained in:
ryan
2026-06-04 09:56:03 +08:00
parent e117e314d9
commit 6e85f1158b
10 changed files with 186 additions and 13 deletions
+7 -1
View File
@@ -7,6 +7,7 @@ import (
"openflare/common"
"openflare/model"
openrestyrender "openflare/utils/render/openresty"
"path"
"sort"
"strconv"
"strings"
@@ -610,6 +611,11 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er
if deployment.ProjectID != project.ID {
return nil, errors.New("Pages 激活部署不属于当前项目")
}
localRoot := fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID)
cleanedRootDir := strings.TrimSpace(deployment.RootDir)
if cleanedRootDir != "" {
localRoot = path.Join(localRoot, cleanedRootDir)
}
return &snapshotPagesDeployment{
ProjectID: project.ID,
ProjectSlug: project.Slug,
@@ -623,7 +629,7 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er
APIProxyPath: project.APIProxyPath,
APIProxyPass: project.APIProxyPass,
APIProxyRewrite: project.APIProxyRewrite,
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
LocalRoot: localRoot,
}, nil
}
+44 -5
View File
@@ -70,6 +70,7 @@ type PagesDeploymentView struct {
Status string `json:"status"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
@@ -229,7 +230,7 @@ func ListPagesDeploymentFiles(deploymentID uint) ([]*PagesDeploymentFileView, er
return views, nil
}
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, entryFile string, createdBy string) (*PagesDeploymentView, error) {
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, rootDir string, entryFile string, createdBy string) (*PagesDeploymentView, error) {
project, err := model.GetPagesProjectByID(projectID)
if err != nil {
return nil, err
@@ -240,13 +241,17 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New("Pages 部署包必须是 .zip 文件")
}
rootDir, err = validateAndNormalizePagesRootDir(rootDir)
if err != nil {
return nil, err
}
entryFile = normalizePagesEntryFile(entryFile)
tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
if err != nil {
return nil, err
}
defer os.Remove(tempPath)
manifest, err := inspectPagesZip(tempPath, entryFile)
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
if err != nil {
return nil, err
}
@@ -277,6 +282,7 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
ArtifactPath: artifactPath,
FileCount: manifest.FileCount,
TotalSize: manifest.TotalSize,
RootDir: rootDir,
EntryFile: manifest.EntryFile,
CreatedBy: strings.TrimSpace(createdBy),
}
@@ -300,6 +306,34 @@ func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, ent
return buildPagesDeploymentView(deployment), nil
}
func validateAndNormalizePagesRootDir(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
if len(value) > 512 {
return "", errors.New("Pages 根目录长度不能超过 512")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("Pages 根目录包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("Pages 根目录不能包含空白或控制字符")
}
}
cleaned := path.Clean(filepath.ToSlash(value))
if cleaned == "." || cleaned == "/" {
return "", nil
}
for _, segment := range strings.Split(cleaned, "/") {
if segment == "." || segment == ".." {
return "", errors.New("Pages 根目录不能包含 . 或 .. 路径段")
}
}
return strings.TrimPrefix(cleaned, "/"), nil
}
func ActivatePagesDeployment(projectID uint, deploymentID uint) (*PagesProjectView, error) {
project, err := model.GetPagesProjectByID(projectID)
if err != nil {
@@ -498,6 +532,7 @@ func buildPagesDeploymentView(deployment *model.PagesDeployment) *PagesDeploymen
Status: deployment.Status,
FileCount: deployment.FileCount,
TotalSize: deployment.TotalSize,
RootDir: deployment.RootDir,
EntryFile: deployment.EntryFile,
CreatedBy: deployment.CreatedBy,
CreatedAt: deployment.CreatedAt,
@@ -644,7 +679,7 @@ func findCommonRootPrefix(files []*zip.File) (string, error) {
return commonPrefix, nil
}
func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest, error) {
func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*pagesDeploymentManifest, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return nil, errors.New("Pages 部署包不是有效 zip 文件")
@@ -660,6 +695,10 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
Files: []model.PagesDeploymentFile{},
EntryFile: entryFile,
}
targetEntryPath := entryFile
if rootDir != "" {
targetEntryPath = path.Join(rootDir, entryFile)
}
entrySeen := false
for _, item := range reader.File {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
@@ -692,7 +731,7 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
if err != nil {
return nil, err
}
if normalizedPath == entryFile {
if normalizedPath == targetEntryPath {
entrySeen = true
}
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
@@ -705,7 +744,7 @@ func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest
return nil, errors.New("Pages 部署包不能为空")
}
if !entrySeen {
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", entryFile)
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", targetEntryPath)
}
return manifest, nil
}
+73 -5
View File
@@ -3,6 +3,7 @@ package service
import (
"archive/zip"
"bytes"
"fmt"
"mime/multipart"
"net/http/httptest"
"openflare/model"
@@ -28,7 +29,7 @@ func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) {
"assets/app.js": "console.log('pages')",
"assets/style.css": "body{color:#111}",
}))
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
@@ -109,7 +110,7 @@ func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) {
_, err = UploadPagesDeployment(project.ID, multipartFileHeader(t, "bad.zip", testPagesZip(t, map[string]string{
"../escape.html": "bad",
"index.html": "ok",
})), "index.html", "root")
})), "", "index.html", "root")
if err == nil || !strings.Contains(err.Error(), "逃逸目录") {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
@@ -145,7 +146,7 @@ func TestPagesDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
}
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "ok",
})), "index.html", "root")
})), "", "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
@@ -228,7 +229,7 @@ func TestDeletePagesDeploymentRejectsActiveDeployment(t *testing.T) {
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "index.html", "root")
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "", "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
@@ -260,7 +261,7 @@ func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
"Speed-Test-source/index.html": "<h1>Hello Pages</h1>",
"Speed-Test-source/assets/app.js": "console.log('pages')",
}))
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment with folder failed: %v", err)
}
@@ -325,3 +326,70 @@ func TestPagesProjectAPIProxyValidation(t *testing.T) {
t.Fatalf("unexpected project state: %+v", project)
}
}
func TestUploadPagesDeploymentWithRootDir(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{
Name: "App Site",
Slug: "app-site",
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
// 1. Upload a zip with files inside a subfolder, specifying "build" as rootDir.
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"build/index.html": "<h1>App Root</h1>",
"build/static/bundle.js": "console.log('app')",
"README.md": "README info",
}))
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "build", "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment with rootDir failed: %v", err)
}
if deployment.FileCount != 3 {
t.Fatalf("expected 3 files, got %d", deployment.FileCount)
}
if deployment.RootDir != "build" {
t.Fatalf("expected RootDir to be 'build', got %q", deployment.RootDir)
}
if deployment.EntryFile != "index.html" {
t.Fatalf("expected EntryFile to be 'index.html', got %q", deployment.EntryFile)
}
// 2. Try uploading with wrong entry file relative to root directory, should fail
_, err = UploadPagesDeployment(project.ID, uploadHeader, "build", "missing.html", "root")
if err == nil || !strings.Contains(err.Error(), "缺少入口文件") {
t.Fatalf("expected failure for missing entry file, got %v", err)
}
// 3. Test config snapshot LocalRoot path rendering
project, err = ActivatePagesDeployment(project.ID, deployment.ID)
if err != nil {
t.Fatalf("ActivatePagesDeployment failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
Enabled: true,
UpstreamType: "pages",
PagesProjectID: &project.ID,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
// Verify LocalRoot contains the rootDir
expectedLocalRoot := fmt.Sprintf("deployments/%d/current/build", deployment.ID)
if !strings.Contains(result.Version.SnapshotJSON, expectedLocalRoot) {
t.Fatalf("expected snapshot JSON to include %q, got %s", expectedLocalRoot, result.Version.SnapshotJSON)
}
if !strings.Contains(result.Version.RenderedConfig, "current/build") {
t.Fatalf("expected rendered config to point to current/build, got:\n%s", result.Version.RenderedConfig)
}
}