mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 06:56:36 +08:00
[优化] 增加根目录选项
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user