[优化] 增加根目录选项

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
@@ -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
@@ -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
}
@@ -45,6 +45,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration
migration202606030002(backend, ctx),
migration202606030003(backend, ctx),
migration202606030004(backend, ctx),
migration202606040001(backend, ctx),
}
}
+1
View File
@@ -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"`
+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)
}
}
@@ -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<PagesDeployment>(
`/pages/${projectId}/deployments/upload`,
@@ -61,6 +63,7 @@ export function uploadPagesDeployment(
return new Promise<PagesDeployment>((resolve, reject) => {
const formData = new FormData();
formData.append('package', file);
formData.append('root_dir', rootDir);
formData.append('entry_file', entryFile);
const xhr = new XMLHttpRequest();
@@ -33,12 +33,14 @@ export function PagesDeploymentUploadModal({
}: PagesDeploymentUploadModalProps) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [rootDir, setRootDir] = useState('');
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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)}
/>
</ResourceField>
<ResourceField label="入口文件">
<ResourceField
label="根目录 (可选)"
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
>
<ResourceInput
value={rootDir}
onChange={(event) => setRootDir(event.target.value)}
placeholder="例如: build"
/>
</ResourceField>
<ResourceField
label="入口文件"
hint="基于根目录下的入口文件路径。"
>
<ResourceInput
value={entryFile}
onChange={(event) => setEntryFile(event.target.value)}
@@ -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;