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": "

Hello Pages

", "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": "

App Root

", + "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) + } +} diff --git a/openflare_server/web/features/pages/api/pages.ts b/openflare_server/web/features/pages/api/pages.ts index fc29c7ca..2f63700a 100644 --- a/openflare_server/web/features/pages/api/pages.ts +++ b/openflare_server/web/features/pages/api/pages.ts @@ -42,12 +42,14 @@ export function getPagesDeployments(projectId: number) { export function uploadPagesDeployment( projectId: number, file: File, + rootDir = '', entryFile = 'index.html', onProgress?: (percent: number) => void, ) { if (!onProgress) { const formData = new FormData(); formData.append('package', file); + formData.append('root_dir', rootDir); formData.append('entry_file', entryFile); return apiRequest( `/pages/${projectId}/deployments/upload`, @@ -61,6 +63,7 @@ export function uploadPagesDeployment( return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('package', file); + formData.append('root_dir', rootDir); formData.append('entry_file', entryFile); const xhr = new XMLHttpRequest(); diff --git a/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx index 3a567c40..dc213379 100644 --- a/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx +++ b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx @@ -33,12 +33,14 @@ export function PagesDeploymentUploadModal({ }: PagesDeploymentUploadModalProps) { const queryClient = useQueryClient(); const [file, setFile] = useState(null); + const [rootDir, setRootDir] = useState(''); const [entryFile, setEntryFile] = useState('index.html'); const [uploadProgress, setUploadProgress] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const resetForm = () => { setFile(null); + setRootDir(''); setEntryFile('index.html'); setUploadProgress(null); setErrorMessage(null); @@ -60,6 +62,7 @@ export function PagesDeploymentUploadModal({ const deployment = await uploadPagesDeployment( projectId, file, + rootDir, entryFile, (percent) => { setUploadProgress(percent); @@ -148,7 +151,20 @@ export function PagesDeploymentUploadModal({ onChange={(event) => setFile(event.target.files?.[0] ?? null)} /> - + + setRootDir(event.target.value)} + placeholder="例如: build" + /> + + setEntryFile(event.target.value)} diff --git a/openflare_server/web/features/pages/types.ts b/openflare_server/web/features/pages/types.ts index 4ed08135..1f366ef8 100644 --- a/openflare_server/web/features/pages/types.ts +++ b/openflare_server/web/features/pages/types.ts @@ -6,6 +6,7 @@ export interface PagesDeployment { status: 'uploaded' | 'active'; file_count: number; total_size: number; + root_dir?: string; entry_file: string; created_by: string; created_at: string;