diff --git a/openflare_server/controller/pages.go b/openflare_server/controller/pages.go index c65553b5..16888979 100644 --- a/openflare_server/controller/pages.go +++ b/openflare_server/controller/pages.go @@ -93,7 +93,13 @@ func UploadPagesDeployment(c *gin.Context) { respondBadRequest(c, "缺少 Pages 部署包") return } - deployment, err := service.UploadPagesDeployment(id, file, c.PostForm("entry_file"), c.GetString("username")) + deployment, err := service.UploadPagesDeployment( + id, + file, + c.PostForm("root_dir"), + c.PostForm("entry_file"), + c.GetString("username"), + ) if err != nil { respondFailure(c, err.Error()) return diff --git a/openflare_server/model/goose/goose_202606040001_add_pages_deployment_root_dir.go b/openflare_server/model/goose/goose_202606040001_add_pages_deployment_root_dir.go new file mode 100644 index 00000000..c844d546 --- /dev/null +++ b/openflare_server/model/goose/goose_202606040001_add_pages_deployment_root_dir.go @@ -0,0 +1,32 @@ +package goose + +import ( + "fmt" + + presslygoose "github.com/pressly/goose/v3" + "gorm.io/gorm" +) + +const versionPagesDeploymentRootDir int64 = 202606040001 + +// migration202606040001 adds RootDir field to Pages deployments. +func migration202606040001(backend string, ctx Context) *presslygoose.Migration { + return newGORMMigration( + versionPagesDeploymentRootDir, + "202606040001_add_pages_deployment_root_dir.go", + backend, + ctx, + migratePagesDeploymentRootDir, + ) +} + +func migratePagesDeploymentRootDir(ctx Context, db *gorm.DB, backend string) error { + if err := ctx.ApplyCurrentSchema(db, backend); err != nil { + return err + } + // Verify that the column exists + if !db.Migrator().HasColumn("pages_deployments", "root_dir") { + return fmt.Errorf("column pages_deployments.root_dir is missing") + } + return nil +} diff --git a/openflare_server/model/goose/migrations.go b/openflare_server/model/goose/migrations.go index ea20ef00..de78b289 100644 --- a/openflare_server/model/goose/migrations.go +++ b/openflare_server/model/goose/migrations.go @@ -45,6 +45,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration migration202606030002(backend, ctx), migration202606030003(backend, ctx), migration202606030004(backend, ctx), + migration202606040001(backend, ctx), } } diff --git a/openflare_server/model/pages.go b/openflare_server/model/pages.go index 58e9a770..75fb2731 100644 --- a/openflare_server/model/pages.go +++ b/openflare_server/model/pages.go @@ -33,6 +33,7 @@ type PagesDeployment struct { ArtifactPath string `json:"artifact_path" gorm:"size:2048;not null"` FileCount int `json:"file_count" gorm:"not null;default:0"` TotalSize int64 `json:"total_size" gorm:"not null;default:0"` + RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"` EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"` CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"` CreatedAt time.Time `json:"created_at"` diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 17f3a7e4..0bfe93a3 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -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 } diff --git a/openflare_server/service/pages.go b/openflare_server/service/pages.go index c842dec4..f5673e58 100644 --- a/openflare_server/service/pages.go +++ b/openflare_server/service/pages.go @@ -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 } diff --git a/openflare_server/service/pages_test.go b/openflare_server/service/pages_test.go index 1b3bfd51..d2b32fa4 100644 --- a/openflare_server/service/pages_test.go +++ b/openflare_server/service/pages_test.go @@ -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": "