mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
fix(pages): 收紧部署包与 Agent 同步边界
完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
This commit is contained in:
@@ -224,14 +224,17 @@ func GetPagesProjectLatestHashHandler(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deploymentID, hash, err := pages.GetProjectLatestPackageHash(c.Request.Context(), projectID)
|
||||
metadata, err := pages.GetProjectLatestPackageMetadata(c.Request.Context(), projectID)
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(protocol.PagesProjectLatestHashResponse{
|
||||
ProjectID: projectID,
|
||||
DeploymentID: deploymentID,
|
||||
Hash: hash,
|
||||
DeploymentID: metadata.DeploymentID,
|
||||
Hash: metadata.Hash,
|
||||
PackageSize: metadata.PackageSize,
|
||||
FileCount: metadata.FileCount,
|
||||
TotalSize: metadata.TotalSize,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -58,23 +60,41 @@ func buildPagesRouteSnapshot(
|
||||
}
|
||||
|
||||
pagesProjectID = route.PagesProjectID
|
||||
deployment = buildSnapshotPagesDeployment(project, activeDeployment)
|
||||
deployment, err = buildSnapshotPagesDeployment(project, activeDeployment)
|
||||
if err != nil {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.SiteName, err)
|
||||
}
|
||||
originURL = fmt.Sprintf("openflare-pages://project/%d", project.ID)
|
||||
return originURL, []string{originURL}, pagesProjectID, deployment, nil
|
||||
}
|
||||
|
||||
func buildSnapshotPagesDeployment(project *model.PagesProject, activeDeployment *model.PagesDeployment) *openrestyrender.PagesDeployment {
|
||||
func buildSnapshotPagesDeployment(
|
||||
project *model.PagesProject,
|
||||
activeDeployment *model.PagesDeployment,
|
||||
) (*openrestyrender.PagesDeployment, error) {
|
||||
if project == nil || activeDeployment == nil {
|
||||
return nil
|
||||
return nil, errors.New("pages 项目或部署为空")
|
||||
}
|
||||
rootDir, err := pagesarchive.NormalizeLogicalPath(strings.TrimSpace(project.RootDir), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 根目录不合法: %w", err)
|
||||
}
|
||||
entryFile := strings.TrimSpace(project.EntryFile)
|
||||
if entryFile == "" {
|
||||
entryFile = defaultPagesSnapshotEntryFile
|
||||
}
|
||||
entryFile, err = pagesarchive.NormalizeLogicalPath(entryFile, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 入口文件不合法: %w", err)
|
||||
}
|
||||
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
|
||||
if fallbackPath == "" {
|
||||
fallbackPath = defaultPagesSnapshotFallbackPath
|
||||
}
|
||||
localRoot := openrestyrender.PagesProjectLocalRoot(project.ID)
|
||||
if rootDir != "" {
|
||||
localRoot = path.Join(localRoot, rootDir)
|
||||
}
|
||||
return &openrestyrender.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
ProjectSlug: strings.TrimSpace(project.Slug),
|
||||
@@ -90,6 +110,6 @@ func buildSnapshotPagesDeployment(project *model.PagesProject, activeDeployment
|
||||
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
|
||||
// Root is project-scoped so Agents can swap active packages without
|
||||
// re-publishing main config (nginx root stays stable).
|
||||
LocalRoot: openrestyrender.PagesProjectLocalRoot(project.ID),
|
||||
}
|
||||
LocalRoot: localRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
|
||||
Enabled: true,
|
||||
SPAFallbackEnabled: true,
|
||||
SPAFallbackPath: "/index.html",
|
||||
RootDir: "public/site",
|
||||
EntryFile: "index.html",
|
||||
}
|
||||
require.NoError(t, conn.Create(project).Error)
|
||||
@@ -64,7 +65,7 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
|
||||
require.NotNil(t, snapshotRoute.PagesDeployment)
|
||||
assert.Equal(t, deployment.ID, snapshotRoute.PagesDeployment.DeploymentID)
|
||||
assert.Equal(t, deployment.Checksum, snapshotRoute.PagesDeployment.Checksum)
|
||||
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/projects/1/current", snapshotRoute.PagesDeployment.LocalRoot)
|
||||
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/projects/1/current/public/site", snapshotRoute.PagesDeployment.LocalRoot)
|
||||
|
||||
_, err = renderSnapshotConfig(bundle.SnapshotJSON, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -78,6 +79,17 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
|
||||
require.NotNil(t, decoded.Routes[0].PagesDeployment)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotPagesDeploymentRejectsUnsafeStoredPaths(t *testing.T) {
|
||||
deployment := &model.PagesDeployment{ID: 1, ProjectID: 1, Checksum: "checksum"}
|
||||
for _, project := range []*model.PagesProject{
|
||||
{ID: 1, RootDir: "../escape", EntryFile: "index.html"},
|
||||
{ID: 1, RootDir: "public", EntryFile: "/index.html"},
|
||||
} {
|
||||
_, err := buildSnapshotPagesDeployment(project, deployment)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireDB(t *testing.T, ctx context.Context) *gorm.DB {
|
||||
t.Helper()
|
||||
conn := db.DB(ctx)
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const (
|
||||
pagesURLDownloadTimeout = 10 * time.Minute
|
||||
pagesURLMaxRedirects = 5
|
||||
pagesMagicSniffBytes = 16
|
||||
pagesMagicSniffBytes = 512
|
||||
pagesURLDialTimeout = 30 * time.Second
|
||||
pagesURLTLSHandshake = 15 * time.Second
|
||||
pagesBrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
@@ -34,4 +34,6 @@ const (
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
)
|
||||
|
||||
@@ -15,13 +15,17 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,11 +35,14 @@ const (
|
||||
defaultPagesMaxHistoryCount = 20
|
||||
defaultPagesEntryFile = "index.html"
|
||||
defaultPagesFallbackPath = "/index.html"
|
||||
pagesDeploymentUploadType = "openflare_pages_deployment"
|
||||
pagesIngestMarkerKey = "pages_ingest_marker"
|
||||
pagesIngestMarkerV2 = "pages_deployment_v2"
|
||||
pagesProjectIDMetadataKey = "pages_project_id"
|
||||
pagesMaxPathLength = 512
|
||||
bytesPerMiB = 1024 * 1024
|
||||
pagesExtractedSizeMultiplier = 4
|
||||
pagesMinExtractedSizeBytes = 100 * bytesPerMiB
|
||||
pagesRowLockStrength = "UPDATE"
|
||||
)
|
||||
|
||||
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
|
||||
@@ -115,30 +122,14 @@ func normalizePagesSlug(raw string) string {
|
||||
|
||||
func validateAndNormalizePagesRootDir(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 根目录长度不能超过 512")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||
return "", errors.New("pages 根目录包含不支持的字符")
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 根目录不合法: %w", err)
|
||||
}
|
||||
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
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizePagesFallbackPath(raw string) (string, error) {
|
||||
@@ -186,12 +177,19 @@ func normalizeStoredPagesFallbackPath(value string) string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePagesEntryFile(raw string) string {
|
||||
value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw)))
|
||||
if value == "." || value == "/" {
|
||||
return defaultPagesEntryFile
|
||||
func validateAndNormalizePagesEntryFile(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = defaultPagesEntryFile
|
||||
}
|
||||
return strings.TrimPrefix(value, "/")
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 入口文件长度不能超过 512")
|
||||
}
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 入口文件不合法: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes int64) (string, string, int64, pagesarchive.Format, error) {
|
||||
@@ -262,7 +260,7 @@ func ingestPagesDeploymentPackage(
|
||||
ctx context.Context,
|
||||
localPath string,
|
||||
checksum string,
|
||||
projectSlug string,
|
||||
projectID uint,
|
||||
fileName string,
|
||||
format pagesarchive.Format,
|
||||
) (upload.IngestResult, error) {
|
||||
@@ -275,28 +273,27 @@ func ingestPagesDeploymentPackage(
|
||||
MimeType: pagesarchive.MIMEType(format),
|
||||
Extension: extension,
|
||||
Hash: checksum,
|
||||
Type: pagesDeploymentUploadType,
|
||||
Type: upload.ReservedPagesDeploymentType,
|
||||
AccessMode: &accessMode,
|
||||
SkipExtensionCheck: true,
|
||||
Policy: upload.PolicyDedupNewRecord,
|
||||
Metadata: model.UploadMetadata{
|
||||
Extra: map[string]any{
|
||||
"project_slug": projectSlug,
|
||||
"format": string(format),
|
||||
pagesIngestMarkerKey: pagesIngestMarkerV2,
|
||||
pagesProjectIDMetadataKey: strconv.FormatUint(uint64(projectID), 10),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func removeDeploymentArtifact(ctx context.Context, deployment *model.PagesDeployment) {
|
||||
func removeDeploymentArtifact(ctx context.Context, projectID uint, deployment *model.PagesDeployment) {
|
||||
if deployment == nil {
|
||||
return
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := upload.Remove(ctx, deployment.UploadID); err != nil {
|
||||
// Soft-delete / storage cleanup failure must not undo DB prune; log for ops.
|
||||
if err := removePagesUploadIfUnreferenced(ctx, projectID, deployment.UploadID); err != nil {
|
||||
logger.WarnF(ctx,
|
||||
"[Pages] remove deployment artifact failed: deployment_id=%d upload_id=%d error=%v",
|
||||
deployment.ID, deployment.UploadID, err,
|
||||
@@ -304,10 +301,58 @@ func removeDeploymentArtifact(ctx context.Context, deployment *model.PagesDeploy
|
||||
}
|
||||
}
|
||||
|
||||
// removePagesUploadIfUnreferenced soft-deletes a reserved Pages upload only
|
||||
// after locking its project (when present), locking the upload, and rechecking
|
||||
// deployment references in the same transaction.
|
||||
func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, uploadID uint64) error {
|
||||
if uploadID == 0 {
|
||||
return nil
|
||||
}
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if projectID != 0 {
|
||||
var project model.PagesProject
|
||||
projectErr := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error
|
||||
if projectErr != nil && !errors.Is(projectErr, gorm.ErrRecordNotFound) {
|
||||
return projectErr
|
||||
}
|
||||
}
|
||||
|
||||
var uploadRecord model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id = ?", uploadID).
|
||||
First(&uploadRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if uploadRecord.Type != upload.ReservedPagesDeploymentType {
|
||||
return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type)
|
||||
}
|
||||
|
||||
var references int64
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("upload_id = ?", uploadID).
|
||||
Count(&references).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if references > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := upload.RemoveLockedTx(tx, &uploadRecord)
|
||||
return err
|
||||
})
|
||||
// Always invalidate after transaction completion, including idempotent no-op,
|
||||
// so a prior post-commit cache interruption can heal on retry.
|
||||
upload.InvalidateUploadMetaCache(ctx, uploadID)
|
||||
return err
|
||||
}
|
||||
|
||||
func inspectPagesPackage(packagePath string, format pagesarchive.Format, rootDir string, entryFile string, limits pagesLimits) (*deploymentManifest, error) {
|
||||
archiveManifest, err := pagesarchive.InspectFile(packagePath, format, pagesarchive.InspectOptions{
|
||||
RootDir: rootDir,
|
||||
EntryFile: entryFile,
|
||||
RootDir: rootDir,
|
||||
EntryFile: entryFile,
|
||||
VerifySizes: true,
|
||||
Limits: pagesarchive.Limits{
|
||||
MaxFiles: limits.MaxFiles,
|
||||
MaxFileBytes: limits.ExtractedBytes,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DeploymentPackage is a streamable Pages deployment artifact for agent download.
|
||||
@@ -137,28 +139,40 @@ func CreateProject(ctx context.Context, input Input) (*View, error) {
|
||||
|
||||
// UpdateProject 更新 Pages 项目。
|
||||
func UpdateProject(ctx context.Context, id uint, input Input) (*View, error) {
|
||||
project, err := model.GetPagesProjectByID(ctx, id)
|
||||
var project *model.PagesProject
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&existing, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updated := existing
|
||||
var err error
|
||||
project, err = buildProject(&updated, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (existing.RootDir != project.RootDir || existing.EntryFile != project.EntryFile) &&
|
||||
existing.ActiveDeploymentID != nil && *existing.ActiveDeploymentID != 0 {
|
||||
if err := ensureDeploymentEntry(tx, *existing.ActiveDeploymentID, project.RootDir, project.EntryFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&existing).Updates(map[string]any{
|
||||
"name": project.Name,
|
||||
"slug": project.Slug,
|
||||
"description": project.Description,
|
||||
"enabled": project.Enabled,
|
||||
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
||||
"spa_fallback_path": project.SPAFallbackPath,
|
||||
"api_proxy_enabled": project.APIProxyEnabled,
|
||||
"api_proxy_path": project.APIProxyPath,
|
||||
"api_proxy_pass": project.APIProxyPass,
|
||||
"api_proxy_rewrite": project.APIProxyRewrite,
|
||||
"root_dir": project.RootDir,
|
||||
"entry_file": project.EntryFile,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project, err = buildProject(project, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.DB(ctx).Model(project).Updates(map[string]any{
|
||||
"name": project.Name,
|
||||
"slug": project.Slug,
|
||||
"description": project.Description,
|
||||
"enabled": project.Enabled,
|
||||
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
||||
"spa_fallback_path": project.SPAFallbackPath,
|
||||
"api_proxy_enabled": project.APIProxyEnabled,
|
||||
"api_proxy_path": project.APIProxyPath,
|
||||
"api_proxy_pass": project.APIProxyPass,
|
||||
"api_proxy_rewrite": project.APIProxyRewrite,
|
||||
"root_dir": project.RootDir,
|
||||
"entry_file": project.EntryFile,
|
||||
}).Error; err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New(errPagesSlugExists)
|
||||
}
|
||||
@@ -167,24 +181,49 @@ func UpdateProject(ctx context.Context, id uint, input Input) (*View, error) {
|
||||
return buildProjectView(ctx, project)
|
||||
}
|
||||
|
||||
func ensureDeploymentEntry(conn *gorm.DB, deploymentID uint, rootDir, entryFile string) error {
|
||||
targetPath := entryFile
|
||||
if rootDir != "" {
|
||||
targetPath = path.Join(rootDir, entryFile)
|
||||
}
|
||||
var count int64
|
||||
if err := conn.Model(&model.PagesDeploymentFile{}).
|
||||
Where("deployment_id = ? AND path = ?", deploymentID, targetPath).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("%s: %s", errPagesEntryFileMissing, targetPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject 删除 Pages 项目。
|
||||
func DeleteProject(ctx context.Context, id uint) error {
|
||||
project, err := model.GetPagesProjectByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
routeCount, err := model.CountProxyRoutesByPagesProjectID(ctx, project.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if routeCount > 0 {
|
||||
return errors.New(errPagesDeleteReferenced)
|
||||
}
|
||||
deployments, err := model.ListPagesDeployments(ctx, project.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var deployments []model.PagesDeployment
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedProject model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedProject, project.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.Migrator().HasTable(&model.ProxyRoute{}) {
|
||||
var routeCount int64
|
||||
if err := tx.Model(&model.ProxyRoute{}).
|
||||
Where("pages_project_id = ?", project.ID).
|
||||
Count(&routeCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if routeCount > 0 {
|
||||
return errors.New(errPagesDeleteReferenced)
|
||||
}
|
||||
}
|
||||
if err := tx.Where("project_id = ?", project.ID).Find(&deployments).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where(
|
||||
"deployment_id IN (?)",
|
||||
tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID),
|
||||
@@ -197,11 +236,15 @@ func DeleteProject(ctx context.Context, id uint) error {
|
||||
if err := tx.Delete(project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range deployments {
|
||||
removeDeploymentArtifact(ctx, &deployments[index])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range deployments {
|
||||
removeDeploymentArtifact(ctx, project.ID, &deployments[index])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProjectDeployments 列出项目的全部部署。
|
||||
@@ -298,7 +341,10 @@ func createDeploymentFromTempPackage(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile := normalizePagesEntryFile(project.EntryFile)
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(project.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifest, err := inspectPagesPackage(tempPath, format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -307,7 +353,7 @@ func createDeploymentFromTempPackage(
|
||||
ctx,
|
||||
tempPath,
|
||||
checksum,
|
||||
project.Slug,
|
||||
project.ID,
|
||||
fileName,
|
||||
format,
|
||||
)
|
||||
@@ -317,11 +363,20 @@ func createDeploymentFromTempPackage(
|
||||
ingestCommitted := false
|
||||
defer func() {
|
||||
if !ingestCommitted && ingestResult.Created {
|
||||
_, _ = upload.Remove(ctx, ingestResult.Upload.ID)
|
||||
if removeErr := removePagesUploadIfUnreferenced(ctx, project.ID, ingestResult.Upload.ID); removeErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[Pages] compensate deployment upload failed: project_id=%d upload_id=%d error=%v",
|
||||
project.ID, ingestResult.Upload.ID, removeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
deployment := &model.PagesDeployment{}
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var lockedProject model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedProject, project.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var maxNumber int
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", project.ID).
|
||||
@@ -357,7 +412,7 @@ func createDeploymentFromTempPackage(
|
||||
}
|
||||
ingestCommitted = true
|
||||
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, project.ID, limits.HistoryCount); pruneErr != nil {
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, project.ID, limits.HistoryCount, deployment.ID); pruneErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[Pages] prune deployment history failed: project_id=%d keep=%d error=%v",
|
||||
project.ID, limits.HistoryCount, pruneErr,
|
||||
@@ -381,7 +436,7 @@ func createDeploymentFromTempPackage(
|
||||
// Concurrency: DB row deletes run in a single transaction after a consistent read
|
||||
// of project + deployments. Concurrent uploads may briefly exceed keepCount; the
|
||||
// next successful prune brings the project back within the limit (eventual).
|
||||
func pruneProjectDeploymentHistory(ctx context.Context, projectID uint, keepCount int) error {
|
||||
func pruneProjectDeploymentHistory(ctx context.Context, projectID uint, keepCount int, preserveCandidateID uint) error {
|
||||
if keepCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -390,7 +445,7 @@ func pruneProjectDeploymentHistory(ctx context.Context, projectID uint, keepCoun
|
||||
// that inserted another deployment between our list and delete.
|
||||
var lastErr error
|
||||
for pass := 0; pass < 2; pass++ {
|
||||
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, projectID, keepCount)
|
||||
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, projectID, keepCount, preserveCandidateID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
break
|
||||
@@ -404,73 +459,79 @@ func pruneProjectDeploymentHistory(ctx context.Context, projectID uint, keepCoun
|
||||
|
||||
// pruneProjectDeploymentHistoryOnce performs one list → select → delete cycle.
|
||||
// Returns the number of deployments deleted from the database.
|
||||
func pruneProjectDeploymentHistoryOnce(ctx context.Context, projectID uint, keepCount int) (int, error) {
|
||||
project, err := model.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load pages project: %w", err)
|
||||
}
|
||||
deployments, err := model.ListPagesDeployments(ctx, projectID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list pages deployments: %w", err)
|
||||
}
|
||||
if len(deployments) <= keepCount {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var activeID uint
|
||||
if project.ActiveDeploymentID != nil {
|
||||
activeID = *project.ActiveDeploymentID
|
||||
}
|
||||
toDelete := selectDeploymentsToPrune(deployments, activeID, keepCount)
|
||||
if len(toDelete) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Delete metadata in one transaction so partial prune does not leave
|
||||
// orphan file-list rows without a parent deployment.
|
||||
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
func pruneProjectDeploymentHistoryOnce(ctx context.Context, projectID uint, keepCount int, preserveCandidateID uint) (int, error) {
|
||||
var deletedDeployments []model.PagesDeployment
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return fmt.Errorf("load pages project: %w", err)
|
||||
}
|
||||
var deployments []model.PagesDeployment
|
||||
if err := tx.Where("project_id = ?", projectID).Order("id desc").Find(&deployments).Error; err != nil {
|
||||
return fmt.Errorf("list pages deployments: %w", err)
|
||||
}
|
||||
var activeID uint
|
||||
if project.ActiveDeploymentID != nil {
|
||||
activeID = *project.ActiveDeploymentID
|
||||
}
|
||||
// Preserve mode is signaled by a non-zero candidate ID, but the lock-time
|
||||
// newest non-active deployment wins. This prevents concurrent upload A/B
|
||||
// prune passes from deleting each other's newer candidate.
|
||||
resolvedCandidateID := resolveLatestCandidateID(deployments, activeID, preserveCandidateID != 0)
|
||||
toDelete := selectDeploymentsToPrune(deployments, activeID, resolvedCandidateID, keepCount)
|
||||
for index := range toDelete {
|
||||
deployment := toDelete[index]
|
||||
// Never delete the active deployment even if project pointer raced.
|
||||
if activeID != 0 && deployment.ID == activeID {
|
||||
continue
|
||||
}
|
||||
if project.ActiveDeploymentID != nil && deployment.ID == *project.ActiveDeploymentID {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||
return fmt.Errorf("delete deployment files id=%d: %w", deployment.ID, err)
|
||||
}
|
||||
if err := tx.Where("id = ? AND project_id = ?", deployment.ID, projectID).
|
||||
Delete(&model.PagesDeployment{}).Error; err != nil {
|
||||
return fmt.Errorf("delete deployment id=%d: %w", deployment.ID, err)
|
||||
result := tx.Where("id = ? AND project_id = ?", deployment.ID, projectID).
|
||||
Delete(&model.PagesDeployment{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("delete deployment id=%d: %w", deployment.ID, result.Error)
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
deletedDeployments = append(deletedDeployments, deployment)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Artifacts are best-effort outside the transaction (object storage I/O).
|
||||
for index := range toDelete {
|
||||
deployment := toDelete[index]
|
||||
if activeID != 0 && deployment.ID == activeID {
|
||||
continue
|
||||
}
|
||||
removeDeploymentArtifact(ctx, &deployment)
|
||||
for index := range deletedDeployments {
|
||||
deployment := deletedDeployments[index]
|
||||
removeDeploymentArtifact(ctx, projectID, &deployment)
|
||||
}
|
||||
|
||||
logger.InfoF(ctx,
|
||||
"[Pages] pruned deployment history: project_id=%d keep=%d deleted=%d",
|
||||
projectID, keepCount, len(toDelete),
|
||||
projectID, keepCount, len(deletedDeployments),
|
||||
)
|
||||
return len(toDelete), nil
|
||||
return len(deletedDeployments), nil
|
||||
}
|
||||
|
||||
func resolveLatestCandidateID(deployments []model.PagesDeployment, activeID uint, preserve bool) uint {
|
||||
if !preserve {
|
||||
return 0
|
||||
}
|
||||
for _, deployment := range deployments {
|
||||
if deployment.ID != activeID {
|
||||
return deployment.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// selectDeploymentsToPrune returns deployments that should be removed under the
|
||||
// "at most keepCount, always keep active, fill with newest" policy.
|
||||
// deployments must be ordered newest-first (id desc).
|
||||
func selectDeploymentsToPrune(deployments []model.PagesDeployment, activeID uint, keepCount int) []model.PagesDeployment {
|
||||
func selectDeploymentsToPrune(
|
||||
deployments []model.PagesDeployment,
|
||||
activeID uint,
|
||||
preserveCandidateID uint,
|
||||
keepCount int,
|
||||
) []model.PagesDeployment {
|
||||
if keepCount <= 0 || len(deployments) <= keepCount {
|
||||
return nil
|
||||
}
|
||||
@@ -486,6 +547,17 @@ func selectDeploymentsToPrune(deployments []model.PagesDeployment, activeID uint
|
||||
}
|
||||
}
|
||||
}
|
||||
// A freshly uploaded manual candidate is temporarily protected in addition
|
||||
// to the active deployment. This intentionally permits two rows when the
|
||||
// configured history limit is one.
|
||||
if preserveCandidateID != 0 {
|
||||
for _, deployment := range deployments {
|
||||
if deployment.ID == preserveCandidateID {
|
||||
keepIDs[preserveCandidateID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Fill remaining slots from newest to oldest.
|
||||
for _, deployment := range deployments {
|
||||
if len(keepIDs) >= keepCount {
|
||||
@@ -521,26 +593,69 @@ func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint)
|
||||
if deployment.ProjectID != project.ID {
|
||||
return nil, errors.New(errPagesDeploymentMismatch)
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
if err = ensureDeploymentUploadRecord(ctx, deployment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
if err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var deployment model.PagesDeployment
|
||||
if err := tx.First(&deployment, deploymentID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if deployment.ProjectID != project.ID {
|
||||
return errors.New(errPagesDeploymentMismatch)
|
||||
}
|
||||
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(project.EntryFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDeploymentEntry(tx, deployment.ID, rootDir, entryFile); err != nil {
|
||||
return err
|
||||
}
|
||||
var uploadRecord model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id = ?", deployment.UploadID).
|
||||
First(&uploadRecord).Error; err != nil {
|
||||
return errors.New(errPagesPackageUploadMissing)
|
||||
}
|
||||
if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != upload.ReservedPagesDeploymentType {
|
||||
return errors.New(errPagesPackageUploadMissing)
|
||||
}
|
||||
if err := tx.Model(&model.PagesDeployment{}).
|
||||
Where("project_id = ?", project.ID).
|
||||
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(deployment).Updates(map[string]any{
|
||||
if err := tx.Model(&deployment).Updates(map[string]any{
|
||||
"status": model.PagesDeploymentStatusActive,
|
||||
"activated_at": &now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(project).Updates(map[string]any{
|
||||
return tx.Model(&project).Updates(map[string]any{
|
||||
"active_deployment_id": deployment.ID,
|
||||
}).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetProject(ctx, project.ID)
|
||||
limits := resolvePagesLimits(ctx)
|
||||
if pruneErr := pruneProjectDeploymentHistory(ctx, projectID, limits.HistoryCount, 0); pruneErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[Pages] strict prune after activation failed: project_id=%d keep=%d error=%v",
|
||||
projectID, limits.HistoryCount, pruneErr,
|
||||
)
|
||||
}
|
||||
return GetProject(ctx, projectID)
|
||||
}
|
||||
|
||||
// GetDeploymentPackageHash returns the upload SHA-256 hash of the deployment package.
|
||||
@@ -728,22 +843,97 @@ func hydrateLegacyDeploymentUpload(
|
||||
ctx,
|
||||
artifactPath,
|
||||
deployment.Checksum,
|
||||
project.Slug,
|
||||
project.ID,
|
||||
fmt.Sprintf("pages-deployment-%d.zip", deployment.ID),
|
||||
pagesarchive.FormatZip,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.DB(ctx).Model(deployment).Updates(map[string]any{
|
||||
"upload_id": ingestResult.Upload.ID,
|
||||
"artifact_path": "",
|
||||
}).Error; err != nil {
|
||||
winnerUploadID, err := attachLegacyDeploymentUpload(
|
||||
ctx,
|
||||
project.ID,
|
||||
deployment.ID,
|
||||
ingestResult.Upload.ID,
|
||||
)
|
||||
if ingestResult.Created && (err != nil || winnerUploadID != ingestResult.Upload.ID) {
|
||||
if removeErr := removePagesUploadIfUnreferenced(ctx, project.ID, ingestResult.Upload.ID); removeErr != nil {
|
||||
logger.ErrorF(ctx,
|
||||
"[Pages] compensate legacy deployment upload failed: project_id=%d upload_id=%d error=%v",
|
||||
project.ID, ingestResult.Upload.ID, removeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deployment.UploadID = ingestResult.Upload.ID
|
||||
winner, err := upload.GetActiveUpload(ctx, winnerUploadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deployment.UploadID = winnerUploadID
|
||||
deployment.ArtifactPath = ""
|
||||
return &ingestResult.Upload, nil
|
||||
return &winner, nil
|
||||
}
|
||||
|
||||
func attachLegacyDeploymentUpload(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
deploymentID uint,
|
||||
uploadID uint64,
|
||||
) (uint64, error) {
|
||||
winnerUploadID := uint64(0)
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
winnerUploadID, err = attachLegacyDeploymentUploadTx(tx, projectID, deploymentID, uploadID)
|
||||
return err
|
||||
})
|
||||
return winnerUploadID, err
|
||||
}
|
||||
|
||||
func attachLegacyDeploymentUploadTx(
|
||||
tx *gorm.DB,
|
||||
projectID uint,
|
||||
deploymentID uint,
|
||||
uploadID uint64,
|
||||
) (uint64, error) {
|
||||
var lockedProject model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedProject, projectID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var lockedDeployment model.PagesDeployment
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&lockedDeployment, deploymentID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if lockedDeployment.ProjectID != lockedProject.ID {
|
||||
return 0, errors.New(errPagesDeploymentMismatch)
|
||||
}
|
||||
if lockedDeployment.UploadID != 0 {
|
||||
return lockedDeployment.UploadID, nil
|
||||
}
|
||||
|
||||
var uploadRecord model.Upload
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).
|
||||
Where("id = ?", uploadID).
|
||||
First(&uploadRecord).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if uploadRecord.Status != model.UploadStatusUsed || uploadRecord.Type != upload.ReservedPagesDeploymentType {
|
||||
return 0, errors.New(errPagesPackageUploadMissing)
|
||||
}
|
||||
result := tx.Model(&model.PagesDeployment{}).
|
||||
Where("id = ? AND project_id = ? AND upload_id = 0", lockedDeployment.ID, lockedProject.ID).
|
||||
Updates(map[string]any{
|
||||
"upload_id": uploadRecord.ID,
|
||||
"artifact_path": "",
|
||||
})
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return 0, errors.New(errPagesPackageUploadMissing)
|
||||
}
|
||||
return uploadRecord.ID, nil
|
||||
}
|
||||
|
||||
// ensureDeploymentInActiveSnapshot allows download of a specific deployment when
|
||||
@@ -842,30 +1032,34 @@ func parseSnapshotRoutes(snapshotJSON string) ([]snapshotRouteRef, error) {
|
||||
|
||||
// DeleteDeployment 删除 Pages 部署。
|
||||
func DeleteDeployment(ctx context.Context, projectID uint, deploymentID uint) error {
|
||||
project, err := model.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deployment.ProjectID != project.ID {
|
||||
return errors.New(errPagesDeploymentMismatch)
|
||||
}
|
||||
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == deployment.ID {
|
||||
return errors.New(errPagesDeleteActiveDeploy)
|
||||
}
|
||||
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||
var removed model.PagesDeployment
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.PagesProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: pagesRowLockStrength}).First(&project, projectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(deployment).Error; err != nil {
|
||||
if err := tx.First(&removed, deploymentID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if removed.ProjectID != project.ID {
|
||||
return errors.New(errPagesDeploymentMismatch)
|
||||
}
|
||||
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == removed.ID {
|
||||
return errors.New(errPagesDeleteActiveDeploy)
|
||||
}
|
||||
if err := tx.Where("deployment_id = ?", removed.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&removed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
removeDeploymentArtifact(ctx, deployment)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removeDeploymentArtifact(ctx, projectID, &removed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildProject(existing *model.PagesProject, input Input) (*model.PagesProject, error) {
|
||||
@@ -923,7 +1117,11 @@ func buildProject(existing *model.PagesProject, input Input) (*model.PagesProjec
|
||||
return nil, err
|
||||
}
|
||||
existing.RootDir = rootDir
|
||||
existing.EntryFile = normalizePagesEntryFile(input.EntryFile)
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(input.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.EntryFile = entryFile
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
@@ -163,6 +164,89 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "回退路径")
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsUnsafeContentPaths(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
rootDirs := []string{"/public", "public/../dist", "C:/public", `public\\dist`, "./public", "public\x00dist"}
|
||||
for index, rootDir := range rootDirs {
|
||||
_, err := CreateProject(ctx, Input{
|
||||
Name: fmt.Sprintf("Unsafe Root %d", index),
|
||||
Slug: fmt.Sprintf("unsafe-root-%d", index),
|
||||
RootDir: rootDir,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.Error(t, err, rootDir)
|
||||
}
|
||||
|
||||
entryFiles := []string{"/index.html", "../index.html", "C:/index.html", `public\\index.html`, "./index.html", "index.html;bad"}
|
||||
for index, entryFile := range entryFiles {
|
||||
_, err := CreateProject(ctx, Input{
|
||||
Name: fmt.Sprintf("Unsafe Entry %d", index),
|
||||
Slug: fmt.Sprintf("unsafe-entry-%d", index),
|
||||
EntryFile: entryFile,
|
||||
})
|
||||
require.Error(t, err, entryFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProjectValidatesActiveDeploymentEntry(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Content Root",
|
||||
Slug: "content-root",
|
||||
Enabled: true,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "root",
|
||||
"dist/index.html": "dist",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
staleCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "stale.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "stale",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := UpdateProject(ctx, project.ID, Input{
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Enabled: true,
|
||||
RootDir: "dist",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dist", updated.RootDir)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, staleCandidate.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errPagesEntryFileMissing)
|
||||
|
||||
_, err = UpdateProject(ctx, project.ID, Input{
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Enabled: true,
|
||||
RootDir: "missing",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), errPagesEntryFileMissing)
|
||||
|
||||
stored, err := model.GetPagesProjectByID(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dist", stored.RootDir)
|
||||
assert.Equal(t, "index.html", stored.EntryFile)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentAcceptsZeroByteFiles(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
@@ -213,6 +297,13 @@ func TestUploadDeploymentStoresPackageInUploadFramework(t *testing.T) {
|
||||
var uploadCount int64
|
||||
require.NoError(t, db.DB(ctx).Model(&model.Upload{}).Count(&uploadCount).Error)
|
||||
assert.Equal(t, int64(1), uploadCount)
|
||||
var uploadRecord model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&uploadRecord, storedDeployment.UploadID).Error)
|
||||
assert.Equal(t, upload.ReservedPagesDeploymentType, uploadRecord.Type)
|
||||
assert.Equal(t, pagesIngestMarkerV2, uploadRecord.Metadata.Extra[pagesIngestMarkerKey])
|
||||
assert.Equal(t, fmt.Sprint(project.ID), uploadRecord.Metadata.Extra[pagesProjectIDMetadataKey])
|
||||
assert.NotContains(t, uploadRecord.Metadata.Extra, "project_slug")
|
||||
assert.NotContains(t, uploadRecord.Metadata.Extra, "format")
|
||||
}
|
||||
|
||||
func TestOpenDeploymentPackageHydratesLegacyArtifactPath(t *testing.T) {
|
||||
@@ -448,34 +539,42 @@ func TestSelectDeploymentsToPruneKeepsActiveAndNewest(t *testing.T) {
|
||||
{ID: 2, ProjectID: 1},
|
||||
{ID: 1, ProjectID: 1},
|
||||
}
|
||||
toDelete := selectDeploymentsToPrune(deployments, 1, 2)
|
||||
toDelete := selectDeploymentsToPrune(deployments, 1, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(3), toDelete[0].ID)
|
||||
assert.Equal(t, uint(2), toDelete[1].ID)
|
||||
|
||||
// active is newest; keep=2 → keep {4,3}, prune {2,1}
|
||||
toDelete = selectDeploymentsToPrune(deployments, 4, 2)
|
||||
toDelete = selectDeploymentsToPrune(deployments, 4, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(2), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
|
||||
// no active; keep=2 → keep {4,3}
|
||||
toDelete = selectDeploymentsToPrune(deployments, 0, 2)
|
||||
toDelete = selectDeploymentsToPrune(deployments, 0, 0, 2)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(2), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
|
||||
// keep=1 with active → only active, prune the rest
|
||||
toDelete = selectDeploymentsToPrune(deployments, 2, 1)
|
||||
toDelete = selectDeploymentsToPrune(deployments, 2, 0, 1)
|
||||
require.Len(t, toDelete, 3)
|
||||
for _, item := range toDelete {
|
||||
assert.NotEqual(t, uint(2), item.ID)
|
||||
}
|
||||
|
||||
// already within limit
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments[:2], 4, 2))
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments[:2], 4, 0, 2))
|
||||
// unlimited
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments, 1, 0))
|
||||
assert.Nil(t, selectDeploymentsToPrune(deployments, 1, 0, 0))
|
||||
|
||||
// history=1 temporarily preserves active plus the freshly uploaded candidate.
|
||||
toDelete = selectDeploymentsToPrune(deployments, 2, 4, 1)
|
||||
require.Len(t, toDelete, 2)
|
||||
assert.Equal(t, uint(3), toDelete[0].ID)
|
||||
assert.Equal(t, uint(1), toDelete[1].ID)
|
||||
assert.Equal(t, uint(4), resolveLatestCandidateID(deployments, 2, true))
|
||||
assert.Zero(t, resolveLatestCandidateID(deployments, 2, false))
|
||||
}
|
||||
|
||||
func TestPruneProjectDeploymentHistory(t *testing.T) {
|
||||
@@ -540,6 +639,131 @@ func TestPruneProjectDeploymentHistory(t *testing.T) {
|
||||
assert.True(t, hasLatest, "newest deployment must fill remaining slot")
|
||||
}
|
||||
|
||||
func TestHistoryCountOnePreservesFreshCandidateUntilActivation(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, db.DB(ctx).Model(&model.SystemConfig{}).
|
||||
Where("key = ?", model.ConfigKeyPagesMaxHistoryCount).
|
||||
Update("value", "1").Error)
|
||||
require.NoError(t, repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyPagesMaxHistoryCount))
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Single History", Slug: "single-history", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
active, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v1.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v1",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, active.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v2.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v2",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
deployments, err := model.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
|
||||
newCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v3.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v3",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
deployments, err = model.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
kept := map[uint]bool{}
|
||||
for _, deployment := range deployments {
|
||||
kept[deployment.ID] = true
|
||||
}
|
||||
assert.True(t, kept[active.ID])
|
||||
assert.True(t, kept[newCandidate.ID])
|
||||
assert.False(t, kept[oldCandidate.ID])
|
||||
var removedUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&removedUpload, oldCandidate.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, removedUpload.Status)
|
||||
|
||||
_, err = ActivateDeployment(ctx, project.ID, newCandidate.ID)
|
||||
require.NoError(t, err)
|
||||
deployments, err = model.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, newCandidate.ID, deployments[0].ID)
|
||||
}
|
||||
|
||||
func TestPruneUsesLockTimeNewestCandidateInsteadOfStaleCaller(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Concurrent Candidate", Slug: "concurrent-candidate", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
active, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v1.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v1",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
_, err = ActivateDeployment(ctx, project.ID, active.ID)
|
||||
require.NoError(t, err)
|
||||
staleCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v2.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v2",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
newCandidate, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "v3.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "v3",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, project.ID, 1, staleCandidate.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, deleted)
|
||||
deployments, err := model.ListPagesDeployments(ctx, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 2)
|
||||
kept := map[uint]bool{}
|
||||
for _, deployment := range deployments {
|
||||
kept[deployment.ID] = true
|
||||
}
|
||||
assert.True(t, kept[active.ID])
|
||||
assert.True(t, kept[newCandidate.ID])
|
||||
assert.False(t, kept[staleCandidate.ID])
|
||||
}
|
||||
|
||||
func TestDeleteDeploymentAndProjectSoftDeleteUnreferencedArtifacts(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{Name: "Delete Artifacts", Slug: "delete-artifacts", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
first, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "first.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "first",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
second, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "second.zip", testPagesZip(t, map[string]string{
|
||||
"index.html": "second",
|
||||
})), "user:1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, DeleteDeployment(ctx, project.ID, second.ID))
|
||||
var secondUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&secondUpload, second.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, secondUpload.Status)
|
||||
|
||||
require.NoError(t, DeleteProject(ctx, project.ID))
|
||||
var firstUpload model.Upload
|
||||
require.NoError(t, db.DB(ctx).First(&firstUpload, first.UploadID).Error)
|
||||
assert.Equal(t, model.UploadStatusDeleted, firstUpload.Status)
|
||||
_, err = model.GetPagesProjectByID(ctx, project.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func testPagesTarGz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
// ProjectLatestPackageMetadata describes the active package limits published to Agents.
|
||||
type ProjectLatestPackageMetadata struct {
|
||||
DeploymentID uint
|
||||
Hash string
|
||||
PackageSize int64
|
||||
FileCount int
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// GetProjectLatestPackageMetadata returns one coherent metadata snapshot for a
|
||||
// project's currently active deployment.
|
||||
func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*ProjectLatestPackageMetadata, error) {
|
||||
deployment, err := resolveProjectActiveDeploymentForAgent(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deployment, err = model.GetPagesDeploymentByID(ctx, deployment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if deployment.UploadID == 0 {
|
||||
return nil, errors.New(errPagesDeploymentNotFound)
|
||||
}
|
||||
|
||||
uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
}
|
||||
hash := strings.TrimSpace(uploadRecord.Hash)
|
||||
if hash == "" {
|
||||
hash = strings.TrimSpace(deployment.Checksum)
|
||||
}
|
||||
if hash == "" {
|
||||
return nil, errors.New(errPagesDeploymentHashMissing)
|
||||
}
|
||||
|
||||
return &ProjectLatestPackageMetadata{
|
||||
DeploymentID: deployment.ID,
|
||||
Hash: hash,
|
||||
PackageSize: uploadRecord.FileSize,
|
||||
FileCount: deployment.FileCount,
|
||||
TotalSize: deployment.TotalSize,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
func TestGetProjectLatestPackageMetadataPreservesZeroTotalSize(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
ctx := context.Background()
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Empty Files",
|
||||
Slug: "empty-files",
|
||||
Enabled: true,
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
packageBytes := testPagesZip(t, map[string]string{
|
||||
"index.html": "",
|
||||
".gitkeep": "",
|
||||
})
|
||||
deployment, err := UploadDeployment(
|
||||
ctx,
|
||||
project.ID,
|
||||
testPagesMultipartFile(t, "empty-files.zip", packageBytes),
|
||||
"test",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDeployment() error = %v", err)
|
||||
}
|
||||
if _, err := ActivateDeployment(ctx, project.ID, deployment.ID); err != nil {
|
||||
t.Fatalf("ActivateDeployment() error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v-package-metadata",
|
||||
SnapshotJSON: fmt.Sprintf(
|
||||
`{"routes":[{"upstream_type":"pages","pages_project_id":%d}]}`,
|
||||
project.ID,
|
||||
),
|
||||
SupportFilesJSON: "[]",
|
||||
Checksum: "package-metadata-config",
|
||||
IsActive: true,
|
||||
CreatedBy: "test",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create active ConfigVersion error = %v", err)
|
||||
}
|
||||
|
||||
got, err := GetProjectLatestPackageMetadata(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectLatestPackageMetadata(%d) error = %v", project.ID, err)
|
||||
}
|
||||
wantHashBytes := sha256.Sum256(packageBytes)
|
||||
wantHash := hex.EncodeToString(wantHashBytes[:])
|
||||
if got.DeploymentID != deployment.ID || got.Hash != wantHash {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d) identity = (%d, %q), want (%d, %q)",
|
||||
project.ID, got.DeploymentID, got.Hash, deployment.ID, wantHash)
|
||||
}
|
||||
if got.PackageSize != int64(len(packageBytes)) {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d).PackageSize = %d, want %d",
|
||||
project.ID, got.PackageSize, len(packageBytes))
|
||||
}
|
||||
if got.FileCount != 2 || got.TotalSize != 0 {
|
||||
t.Errorf("GetProjectLatestPackageMetadata(%d) content = (%d files, %d bytes), want (2 files, 0 bytes)",
|
||||
project.ID, got.FileCount, got.TotalSize)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -102,7 +103,10 @@ func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessa
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
deployment := buildLivePagesDeployment(project, activeDeployment)
|
||||
deployment, err := buildLivePagesDeployment(project, activeDeployment)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
projectIDCopy := project.ID
|
||||
originURL := fmt.Sprintf("openflare-pages://project/%d", project.ID)
|
||||
|
||||
@@ -181,15 +185,26 @@ func loadActivePagesProject(ctx context.Context, projectID uint, siteName string
|
||||
return project, activeDeployment, nil
|
||||
}
|
||||
|
||||
func buildLivePagesDeployment(project *model.PagesProject, active *model.PagesDeployment) *openrestyrender.PagesDeployment {
|
||||
entryFile := strings.TrimSpace(project.EntryFile)
|
||||
if entryFile == "" {
|
||||
entryFile = defaultPagesEntryFile
|
||||
func buildLivePagesDeployment(
|
||||
project *model.PagesProject,
|
||||
active *model.PagesDeployment,
|
||||
) (*openrestyrender.PagesDeployment, error) {
|
||||
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryFile, err := validateAndNormalizePagesEntryFile(project.EntryFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
|
||||
if fallbackPath == "" {
|
||||
fallbackPath = defaultPagesFallbackPath
|
||||
}
|
||||
localRoot := openrestyrender.PagesProjectLocalRoot(project.ID)
|
||||
if rootDir != "" {
|
||||
localRoot = path.Join(localRoot, rootDir)
|
||||
}
|
||||
return &openrestyrender.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
ProjectSlug: strings.TrimSpace(project.Slug),
|
||||
@@ -203,8 +218,8 @@ func buildLivePagesDeployment(project *model.PagesProject, active *model.PagesDe
|
||||
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
|
||||
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
|
||||
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
|
||||
LocalRoot: openrestyrender.PagesProjectLocalRoot(project.ID),
|
||||
}
|
||||
LocalRoot: localRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rawJSONString(raw json.RawMessage) (string, bool) {
|
||||
|
||||
@@ -20,9 +20,11 @@ func TestRebindSnapshotPagesToCurrentActive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
project, err := CreateProject(ctx, Input{
|
||||
Name: "Rebind Site",
|
||||
Slug: "rebind-site",
|
||||
Enabled: true,
|
||||
Name: "Rebind Site",
|
||||
Slug: "rebind-site",
|
||||
Enabled: true,
|
||||
RootDir: "public/site",
|
||||
EntryFile: "index.html",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -84,4 +86,5 @@ func TestRebindSnapshotPagesToCurrentActive(t *testing.T) {
|
||||
deployment := route["pages_deployment"].(map[string]any)
|
||||
assert.EqualValues(t, active.ID, deployment["deployment_id"])
|
||||
assert.Equal(t, "new-checksum", deployment["checksum"])
|
||||
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/projects/1/current/public/site", deployment["local_root"])
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -33,6 +36,15 @@ func deploymentIDParam(c *gin.Context) (uint, bool) {
|
||||
return uint(id64), true
|
||||
}
|
||||
|
||||
func currentPagesActor(c *gin.Context) (string, bool) {
|
||||
user, ok := oauth.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
if !ok || user == nil || user.ID == 0 {
|
||||
response.AbortUnauthorized(c, errPagesActorMissing)
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("user:%d", user.ID), true
|
||||
}
|
||||
|
||||
// ListProjectsHandler 列出全部 Pages 项目。
|
||||
// @Summary 列出 Pages 项目
|
||||
// @Description 返回全部 OpenFlare Pages 项目,需要管理员权限
|
||||
@@ -214,7 +226,11 @@ func UploadDeploymentHandler(c *gin.Context) {
|
||||
response.AbortBadRequest(c, errPagesPackageMissing)
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeployment(c.Request.Context(), id, file, "")
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeployment(c.Request.Context(), id, file, actor)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -247,7 +263,11 @@ func UploadDeploymentFromURLHandler(c *gin.Context) {
|
||||
response.AbortBadRequest(c, errPagesPackageURLRequired)
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeploymentFromURL(c.Request.Context(), id, req.URL, "")
|
||||
actor, ok := currentPagesActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployment, err := UploadDeploymentFromURL(c.Request.Context(), id, req.URL, actor)
|
||||
if handleLogicError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUploadDeploymentHandlerRecordsCurrentUserActor(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
project, err := CreateProject(t.Context(), Input{Name: "Actor Upload", Slug: "actor-upload", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "ok"})
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
part, err := writer.CreateFormFile("package", "site.zip")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write(packageBytes)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/d/pages/1/deployments/upload", &requestBody)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.FormatUint(uint64(project.ID), 10)}}
|
||||
oauth.SetToContext(c, oauth.UserObjKey, &model.User{ID: 42})
|
||||
|
||||
UploadDeploymentHandler(c)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
deployments, err := model.ListPagesDeployments(t.Context(), project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, "user:42", deployments[0].CreatedBy)
|
||||
}
|
||||
|
||||
func TestUploadDeploymentFromURLHandlerRecordsCurrentUserActor(t *testing.T) {
|
||||
cleanup := setupPagesTestDB(t)
|
||||
defer cleanup()
|
||||
_, disableStorage := setupPagesStorageMock(t)
|
||||
defer disableStorage()
|
||||
|
||||
project, err := CreateProject(t.Context(), Input{Name: "Actor URL", Slug: "actor-url", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "ok"})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="site.zip"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(packageBytes)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body, err := json.Marshal(UploadFromURLInput{URL: server.URL + "/site.zip"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/d/pages/1/deployments/upload-from-url", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(project.ID)}}
|
||||
oauth.SetToContext(c, oauth.UserObjKey, &model.User{ID: 77})
|
||||
|
||||
UploadDeploymentFromURLHandler(c)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
deployments, err := model.ListPagesDeployments(t.Context(), project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, deployments, 1)
|
||||
assert.Equal(t, "user:77", deployments[0].CreatedBy)
|
||||
}
|
||||
|
||||
func TestCurrentPagesActorRejectsMissingUser(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
actor, ok := currentPagesActor(c)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, actor)
|
||||
assert.True(t, c.IsAborted())
|
||||
}
|
||||
@@ -6,12 +6,14 @@ package proxy_route
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// CustomHeaderInput 自定义响应头。
|
||||
@@ -122,6 +124,9 @@ func CreateProxyRoute(ctx context.Context, input Input) (*View, error) {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockPagesProjectsForRouteMutation(tx, 0, route); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(route).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,11 +146,15 @@ func UpdateProxyRoute(ctx context.Context, id uint, input Input) (*View, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousPagesProjectID := pagesProjectIDForRoute(route)
|
||||
route, _, err = buildProxyRoute(ctx, route, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockPagesProjectsForRouteMutation(tx, previousPagesProjectID, route); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateProxyRouteRecord(tx, route); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,6 +168,58 @@ func UpdateProxyRoute(ctx context.Context, id uint, input Input) (*View, error)
|
||||
return buildProxyRouteView(ctx, route)
|
||||
}
|
||||
|
||||
func pagesProjectIDForRoute(route *model.ProxyRoute) uint {
|
||||
if route == nil || route.UpstreamType != proxyRouteUpstreamTypePages || route.PagesProjectID == nil {
|
||||
return 0
|
||||
}
|
||||
return *route.PagesProjectID
|
||||
}
|
||||
|
||||
func lockPagesProjectsForRouteMutation(tx *gorm.DB, previousProjectID uint, route *model.ProxyRoute) error {
|
||||
nextProjectID := pagesProjectIDForRoute(route)
|
||||
var projectIDs []uint
|
||||
if previousProjectID != 0 {
|
||||
projectIDs = append(projectIDs, previousProjectID)
|
||||
}
|
||||
if nextProjectID != 0 && nextProjectID != previousProjectID {
|
||||
projectIDs = append(projectIDs, nextProjectID)
|
||||
}
|
||||
sort.Slice(projectIDs, func(i int, j int) bool { return projectIDs[i] < projectIDs[j] })
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
var project model.PagesProject
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, projectID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) && projectID != nextProjectID {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(errProxyRoutePagesNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if projectID == nextProjectID {
|
||||
if err := validateLockedPagesRouteProject(&project); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLockedPagesRouteProject(project *model.PagesProject) error {
|
||||
if project == nil {
|
||||
return errors.New(errProxyRoutePagesNotFound)
|
||||
}
|
||||
if !project.Enabled {
|
||||
return errors.New(errProxyRoutePagesDisabled)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||
return errors.New(errProxyRoutePagesNoDeploy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProxyRoute 删除代理规则。
|
||||
func DeleteProxyRoute(ctx context.Context, id uint) error {
|
||||
if _, err := model.GetProxyRouteByID(ctx, id); err != nil {
|
||||
|
||||
@@ -19,7 +19,14 @@ func setupProxyRouteTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqliteDB.AutoMigrate(&model.ProxyRoute{}, &model.Origin{}, &model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}))
|
||||
require.NoError(t, sqliteDB.AutoMigrate(
|
||||
&model.ProxyRoute{},
|
||||
&model.Origin{},
|
||||
&model.Zone{},
|
||||
&model.ZoneDomain{},
|
||||
&model.TLSCertificate{},
|
||||
&model.PagesProject{},
|
||||
))
|
||||
db.SetDB(sqliteDB)
|
||||
return func() { db.SetDB(nil) }
|
||||
}
|
||||
@@ -81,6 +88,52 @@ func TestCreateProxyRouteHTTPSRequiresCoveringCertificate(t *testing.T) {
|
||||
require.EqualError(t, err, errProxyRouteCertRequired)
|
||||
}
|
||||
|
||||
func TestPagesRouteLocksAndRevalidatesTargetProject(t *testing.T) {
|
||||
cleanup := setupProxyRouteTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
domain := createZoneDomain(t, ctx, "pages.example.com", nil)
|
||||
activeDeploymentID := uint(99)
|
||||
project := &model.PagesProject{
|
||||
Name: "Pages Site",
|
||||
Slug: "pages-site",
|
||||
Enabled: true,
|
||||
ActiveDeploymentID: &activeDeploymentID,
|
||||
}
|
||||
require.NoError(t, db.DB(ctx).Create(project).Error)
|
||||
|
||||
view, err := CreateProxyRoute(ctx, Input{
|
||||
SiteName: "pages",
|
||||
ZoneDomainIDs: []uint{domain.ID},
|
||||
UpstreamType: proxyRouteUpstreamTypePages,
|
||||
PagesProjectID: &project.ID,
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, view.PagesProjectID)
|
||||
assert.Equal(t, project.ID, *view.PagesProjectID)
|
||||
|
||||
require.NoError(t, db.DB(ctx).Delete(&model.PagesProject{}, project.ID).Error)
|
||||
route := &model.ProxyRoute{UpstreamType: proxyRouteUpstreamTypePages, PagesProjectID: &project.ID}
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return lockPagesProjectsForRouteMutation(tx, 0, route)
|
||||
})
|
||||
require.EqualError(t, err, errProxyRoutePagesNotFound)
|
||||
}
|
||||
|
||||
func TestRouteCanMoveAwayFromAlreadyMissingPagesProject(t *testing.T) {
|
||||
cleanup := setupProxyRouteTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
missingProjectID := uint(404)
|
||||
route := &model.ProxyRoute{UpstreamType: "direct"}
|
||||
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return lockPagesProjectsForRouteMutation(tx, missingProjectID, route)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizeCachePolicyDefaultsAndLegacy(t *testing.T) {
|
||||
assert.Equal(t, "", normalizeCachePolicy(false, "static"))
|
||||
// Empty/url on write = legacy all (compat); UI sends static explicitly for new default.
|
||||
|
||||
Reference in New Issue
Block a user