feat(pages): configurable limits, multi-format packages, and dual versioning

Make Pages package size and history retention system-configurable, support
zip/tar.gz/tar.xz/tar.bz2/tar/7z uploads, prune history with clear keep-N
semantics, and rebind agent config to the live active Pages deployment so
main-config rollback never depends on pruned packages.
This commit is contained in:
ryan
2026-07-17 17:16:48 +08:00
parent 368df3f76b
commit a0fcf9f627
32 changed files with 2286 additions and 370 deletions
+14 -135
View File
@@ -2,8 +2,6 @@
package sync
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -11,21 +9,19 @@ import (
"errors"
"fmt"
"io"
"math"
"os"
"path"
"path/filepath"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
)
const (
pagesMaxExtractedFileBytes = 100 * 1024 * 1024
pagesDirPerm = 0o755
pagesFilePerm = 0o644
pagesManifestFilePerm = 0o644
pagesDirPerm = 0o755
pagesFilePerm = 0o644
pagesManifestFilePerm = 0o644
)
type pagesSourceDocument struct {
@@ -223,89 +219,26 @@ func referencedPagesDeployments(config *protocol.ActiveConfigResponse) ([]pagesD
return result, nil
}
func findCommonRootPrefix(files []*zip.File) (string, error) {
var firstFilePath string
hasMultipleFiles := false
for _, item := range files {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
normalizedPath := filepath.ToSlash(relativePath)
if firstFilePath == "" {
firstFilePath = normalizedPath
} else {
hasMultipleFiles = true
}
}
if firstFilePath == "" {
return "", nil
}
parts := strings.Split(firstFilePath, "/")
if len(parts) <= 1 {
return "", nil
}
commonPrefix := parts[0] + "/"
if hasMultipleFiles {
for _, item := range files {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
normalizedPath := filepath.ToSlash(relativePath)
if !strings.HasPrefix(normalizedPath, commonPrefix) {
return "", nil
}
}
}
return commonPrefix, nil
}
func extractPagesPackage(packageBytes []byte, releaseDir string, deployment pagesDeploymentSource) error {
tmpDir := releaseDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, pagesDirPerm); err != nil {
return err
}
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
format, err := pagesarchive.DetectFormat("", packageBytes)
if err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("open Pages zip: %w", err)
return fmt.Errorf("detect Pages package format: %w", err)
}
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
// Control plane already inspected and accepted this package. Agent only
// verifies download integrity (checksum) and performs local-safe extract
// (path escape / symlink guards). Size and file-count limits are not re-applied.
if err := pagesarchive.ExtractBytes(packageBytes, format, tmpDir, pagesarchive.ExtractOptions{
StripCommonRoot: true,
EnforceLimits: false,
}); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
for _, item := range reader.File {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
if skip {
continue
}
if commonPrefix != "" {
slashPath := filepath.ToSlash(relativePath)
if strings.HasPrefix(slashPath, commonPrefix) {
relativePath = filepath.FromSlash(strings.TrimPrefix(slashPath, commonPrefix))
}
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("pages package contains unsupported symlink: %s", relativePath)
}
if err := extractPagesFile(item, filepath.Join(tmpDir, relativePath)); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
return fmt.Errorf("extract Pages package: %w", err)
}
if err := writePagesMarker(tmpDir, deployment); err != nil {
_ = os.RemoveAll(tmpDir)
@@ -315,42 +248,6 @@ func extractPagesPackage(packageBytes []byte, releaseDir string, deployment page
return os.Rename(tmpDir, releaseDir)
}
func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) {
if declaredSize > pagesMaxExtractedFileBytes || declaredSize > uint64(math.MaxInt64) {
return 0, errors.New("pages file size out of bounds")
}
if declaredSize > 0 {
return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above
}
limited := io.LimitReader(src, pagesMaxExtractedFileBytes+1)
written, err := io.Copy(dst, limited)
if written > pagesMaxExtractedFileBytes {
return written, errors.New("pages file size out of bounds")
}
return written, err
}
func extractPagesFile(item *zip.File, targetPath string) error {
if err := os.MkdirAll(filepath.Dir(targetPath), pagesDirPerm); err != nil {
return err
}
source, err := item.Open()
if err != nil {
return err
}
defer func() { _ = source.Close() }()
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, pagesFilePerm) //nolint:gosec // targetPath is under managed PagesDir from validated zip entry
if err != nil {
return err
}
defer func() { _ = target.Close() }()
_, err = copyPagesZipEntryContent(target, source, item.UncompressedSize64)
if err != nil {
return fmt.Errorf("%s: %w", item.Name, err)
}
return nil
}
func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error {
currentDir := pagesCurrentDir(baseDir, deploymentID)
previousDir := currentDir + ".previous"
@@ -440,24 +337,6 @@ func copyPagesDir(sourceDir string, targetDir string) error {
})
}
func normalizePagesArchivePath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" || strings.HasSuffix(name, "/") {
return "", true, nil
}
if strings.HasPrefix(name, "/") {
return "", false, fmt.Errorf("pages package contains absolute path: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("pages package path escapes deployment root: %s", raw)
}
return filepath.FromSlash(cleaned), false, nil
}
func markerMatches(dir string, deployment pagesDeploymentSource) bool {
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json")) //nolint:gosec // dir is managed PagesDir
if err != nil {
+1 -1
View File
@@ -525,7 +525,7 @@ func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
service.SetPagesDir(t.TempDir())
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
if err == nil || !strings.Contains(err.Error(), "escapes deployment root") {
if err == nil || (!strings.Contains(err.Error(), "escapes deployment root") && !strings.Contains(err.Error(), "escapes directory")) {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
if len(manager.applyRouteContents) != 0 {
+12 -1
View File
@@ -9,6 +9,7 @@ import (
"errors"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
"gorm.io/gorm"
@@ -38,10 +39,20 @@ func getActiveConfigForAgent(ctx context.Context) (*ConfigResponse, error) {
}
}
// Main config version history is independent of Pages deployment history.
// Agents always receive pages routes bound to each project's current active
// deployment so config rollback never depends on pruned packages.
sourceJSON := version.SnapshotJSON
if rebound, rebindErr := pages.RebindSnapshotPagesToCurrentActive(ctx, version.SnapshotJSON); rebindErr != nil {
return nil, rebindErr
} else if strings.TrimSpace(rebound) != "" {
sourceJSON = rebound
}
return &ConfigResponse{
Version: version.Version,
Checksum: version.Checksum,
SourceConfigJSON: version.SnapshotJSON,
SourceConfigJSON: sourceJSON,
SupportFiles: sourceSupportFiles(supportFiles),
CreatedAt: version.CreatedAt,
}, nil
@@ -59,6 +59,9 @@ func validateOptionWithState(ctx context.Context, option model.OpenFlareOption,
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
if err := validatePagesOption(option.Key, option.Value); err != nil {
return err
}
return validateUptimeKumaOption(ctx, option.Key, option.Value, state)
}
@@ -109,6 +112,23 @@ func validateAgentOption(key, value string) error {
return nil
}
func validatePagesOption(key, value string) error {
trimmed := strings.TrimSpace(value)
switch key {
case model.ConfigKeyPagesMaxPackageSizeMB:
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 1 || intValue > 2048 {
return fmt.Errorf("%s 必须为 1~2048 的整数(MiB)", key)
}
case model.ConfigKeyPagesMaxHistoryCount:
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数(0 表示不限制)", key)
}
}
return nil
}
func validateUptimeKumaOption(ctx context.Context, key, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
+6 -2
View File
@@ -14,9 +14,13 @@ const (
errPagesDeploymentMismatch = "pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件"
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
errPagesPackageEmpty = "pages 部署包不能为空"
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+164 -167
View File
@@ -4,14 +4,12 @@
package pages
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"mime/multipart"
"os"
"path"
@@ -22,17 +20,22 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"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"
)
const (
pagesMaxDeploymentFiles = 1000
pagesMaxDeploymentBytes = 100 * 1024 * 1024
defaultPagesEntryFile = "index.html"
defaultPagesFallbackPath = "/index.html"
pagesDeploymentUploadType = "openflare_pages_deployment"
mimeTypeApplicationZip = "application/zip"
pagesMaxPathLength = 512
bytesPerKiB = 1024
pagesMaxDeploymentFiles = 1000
defaultPagesMaxPackageSizeMB = 100
maxPagesMaxPackageSizeMB = 2048
defaultPagesMaxHistoryCount = 20
defaultPagesEntryFile = "index.html"
defaultPagesFallbackPath = "/index.html"
pagesDeploymentUploadType = "openflare_pages_deployment"
pagesMaxPathLength = 512
bytesPerMiB = 1024 * 1024
pagesExtractedSizeMultiplier = 4
pagesMinExtractedSizeBytes = 100 * bytesPerMiB
)
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
@@ -42,6 +45,14 @@ type deploymentManifest struct {
FileCount int
TotalSize int64
EntryFile string
Format pagesarchive.Format
}
type pagesLimits struct {
PackageBytes int64
ExtractedBytes int64
MaxFiles int
HistoryCount int
}
func isUniqueConstraintError(err error) bool {
@@ -51,6 +62,38 @@ func isUniqueConstraintError(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func resolvePagesLimits(ctx context.Context) pagesLimits {
packageMB := defaultPagesMaxPackageSizeMB
if value, err := repository.GetIntByKey(ctx, model.ConfigKeyPagesMaxPackageSizeMB); err == nil && value > 0 {
packageMB = value
}
if packageMB > maxPagesMaxPackageSizeMB {
packageMB = maxPagesMaxPackageSizeMB
}
historyCount := defaultPagesMaxHistoryCount
if value, err := repository.GetIntByKey(ctx, model.ConfigKeyPagesMaxHistoryCount); err == nil {
if value < 0 {
historyCount = 0
} else {
historyCount = value
}
}
packageBytes := int64(packageMB) * bytesPerMiB
extractedBytes := packageBytes * pagesExtractedSizeMultiplier
if extractedBytes < pagesMinExtractedSizeBytes {
extractedBytes = pagesMinExtractedSizeBytes
}
return pagesLimits{
PackageBytes: packageBytes,
ExtractedBytes: extractedBytes,
MaxFiles: pagesMaxDeploymentFiles,
HistoryCount: historyCount,
}
}
func normalizePagesSlug(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
var builder strings.Builder
@@ -76,7 +119,7 @@ func validateAndNormalizePagesRootDir(raw string) (string, error) {
return "", nil
}
if len(value) > pagesMaxPathLength {
return "", errors.New("pages 根目录长度不能超过 512") // error 消息首字母小写
return "", errors.New("pages 根目录长度不能超过 512")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("pages 根目录包含不支持的字符")
@@ -151,29 +194,50 @@ func normalizePagesEntryFile(raw string) string {
return strings.TrimPrefix(value, "/")
}
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, int64, error) {
func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes int64) (string, string, int64, pagesarchive.Format, error) {
format, ok := pagesarchive.DetectFormatFromName(fileHeader.Filename)
if !ok {
return "", "", 0, "", errors.New(errPagesPackageUnsupported)
}
file, err := fileHeader.Open()
if err != nil {
return "", "", 0, err
return "", "", 0, "", err
}
defer func() { _ = file.Close() }()
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
temp, err := os.CreateTemp("", "openflare-pages-*."+safeTempSuffix(format))
if err != nil {
return "", "", 0, err
return "", "", 0, "", err
}
defer func() { _ = temp.Close() }()
hash := sha256.New()
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
limited := io.LimitReader(file, maxPackageBytes+1)
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
if err != nil {
_ = os.Remove(temp.Name())
return "", "", 0, err
return "", "", 0, "", err
}
if written > pagesMaxDeploymentBytes {
if written > maxPackageBytes {
_ = os.Remove(temp.Name())
return "", "", 0, fmt.Errorf("pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/bytesPerKiB/bytesPerKiB)
return "", "", 0, "", fmt.Errorf("pages 部署包不能超过 %d MiB", maxPackageBytes/bytesPerMiB)
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, format, nil
}
func safeTempSuffix(format pagesarchive.Format) string {
switch format {
case pagesarchive.FormatTarGz:
return "tar.gz"
case pagesarchive.FormatTarXz:
return "tar.xz"
case pagesarchive.FormatTarBz2:
return "tar.bz2"
case pagesarchive.FormatSevenZip:
return "7z"
case pagesarchive.FormatTar:
return "tar"
default:
return "zip"
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, nil
}
func pagesLegacyRelativeCandidates(project *model.PagesProject, deployment *model.PagesDeployment) []string {
@@ -185,6 +249,7 @@ func pagesLegacyRelativeCandidates(project *model.PagesProject, deployment *mode
if slug == "" || checksum == "" {
return nil
}
// Legacy artifacts were always stored as .zip.
fileName := checksum + ".zip"
return []string{
filepath.Join("artifacts", slug, fileName),
@@ -199,14 +264,16 @@ func ingestPagesDeploymentPackage(
checksum string,
projectSlug string,
fileName string,
format pagesarchive.Format,
) (upload.IngestResult, error) {
systemUser := repository.GetSystemUser(ctx)
accessMode := 0
extension := pagesarchive.NormalizeNameExtension(fileName, format)
return upload.IngestFromLocalPath(ctx, localPath, upload.IngestRequest{
UserID: systemUser.ID,
FileName: fileName,
MimeType: mimeTypeApplicationZip,
Extension: "zip",
MimeType: pagesarchive.MIMEType(format),
Extension: extension,
Hash: checksum,
Type: pagesDeploymentUploadType,
AccessMode: &accessMode,
@@ -215,6 +282,7 @@ func ingestPagesDeploymentPackage(
Metadata: model.UploadMetadata{
Extra: map[string]any{
"project_slug": projectSlug,
"format": string(format),
},
},
})
@@ -224,167 +292,96 @@ func removeDeploymentArtifact(ctx context.Context, deployment *model.PagesDeploy
if deployment == nil {
return
}
if deployment.UploadID > 0 {
_, _ = upload.Remove(ctx, deployment.UploadID)
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.
logger.WarnF(ctx,
"[Pages] remove deployment artifact failed: deployment_id=%d upload_id=%d error=%v",
deployment.ID, deployment.UploadID, err,
)
}
}
func findCommonRootPrefix(files []*zip.File) (string, error) {
var firstFilePath string
hasMultipleFiles := false
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if firstFilePath == "" {
firstFilePath = normalizedPath
} else {
hasMultipleFiles = true
}
}
if firstFilePath == "" {
return "", nil
}
parts := strings.Split(firstFilePath, "/")
if len(parts) <= 1 {
return "", nil
}
commonPrefix := parts[0] + "/"
if hasMultipleFiles {
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if !strings.HasPrefix(normalizedPath, commonPrefix) {
return "", nil
}
}
}
return commonPrefix, nil
}
func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*deploymentManifest, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return nil, errors.New(errPagesPackageInvalidZip)
}
defer func() { _ = reader.Close() }()
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
return nil, err
}
manifest := &deploymentManifest{
Files: []model.PagesDeploymentFile{},
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,
Limits: pagesarchive.Limits{
MaxFiles: limits.MaxFiles,
MaxFileBytes: limits.ExtractedBytes,
MaxTotalBytes: limits.ExtractedBytes,
},
})
if err != nil {
return nil, mapPagesArchiveError(err)
}
targetEntryPath := entryFile
if rootDir != "" {
targetEntryPath = path.Join(rootDir, entryFile)
manifest := &deploymentManifest{
Files: make([]model.PagesDeploymentFile, 0, len(archiveManifest.Files)),
FileCount: archiveManifest.FileCount,
TotalSize: archiveManifest.TotalSize,
EntryFile: entryFile,
Format: format,
}
entrySeen := false
for _, item := range reader.File {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return nil, err
}
if skip {
continue
}
if commonPrefix != "" {
normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix)
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("pages 部署包不支持符号链接: %s", normalizedPath)
}
if item.UncompressedSize64 > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("pages 文件过大: %s", normalizedPath)
}
manifest.FileCount++
if manifest.FileCount > pagesMaxDeploymentFiles {
return nil, fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
}
checksum, fileSize, err := checksumZipFile(item)
if err != nil {
return nil, fmt.Errorf("%s: %w", normalizedPath, err)
}
manifest.TotalSize += fileSize
if manifest.TotalSize > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/bytesPerKiB/bytesPerKiB)
}
if normalizedPath == targetEntryPath {
entrySeen = true
}
for _, file := range archiveManifest.Files {
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
Path: normalizedPath,
Size: fileSize,
Checksum: checksum,
Path: file.Path,
Size: file.Size,
Checksum: file.Checksum,
})
}
if manifest.FileCount == 0 {
return nil, errors.New(errPagesPackageEmpty)
}
if !entrySeen {
return nil, fmt.Errorf("pages 部署包缺少入口文件 %s", targetEntryPath)
}
return manifest, nil
}
func normalizePagesZipPath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" {
return "", true, nil
func mapPagesArchiveError(err error) error {
if err == nil {
return nil
}
if strings.HasSuffix(name, "/") {
return "", true, nil
message := err.Error()
switch {
case strings.Contains(message, "unsupported pages package format"):
return errors.New(errPagesPackageUnsupported)
case strings.Contains(message, "open zip"), strings.Contains(message, "open gzip"),
strings.Contains(message, "open xz"), strings.Contains(message, "open 7z"),
strings.Contains(message, "read tar"):
return errors.New(errPagesPackageInvalid)
case strings.Contains(message, "empty"):
return errors.New(errPagesPackageEmpty)
case strings.Contains(message, "missing entry file"):
return err
case strings.Contains(message, "file count exceeds"):
return fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
case strings.Contains(message, "extracted size exceeds"):
return errors.New(errPagesPackageExtractedTooLarge)
case strings.Contains(message, "file too large"), strings.Contains(message, "size out of bounds"):
return errors.New(errPagesPackageFileTooLarge)
case strings.Contains(message, "symlink"):
return err
case strings.Contains(message, "absolute path"), strings.Contains(message, "escapes directory"):
return err
default:
return err
}
if strings.HasPrefix(name, "/") || path.IsAbs(name) {
return "", false, fmt.Errorf("pages 部署包不能包含绝对路径: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("pages 部署包路径不能逃逸目录: %s", raw)
}
return cleaned, false, nil
}
func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) {
if declaredSize > pagesMaxDeploymentBytes || declaredSize > uint64(math.MaxInt64) {
return 0, errors.New("pages file size out of bounds")
func packageDownloadName(deploymentID uint, fileName string, contentType string) string {
if format, ok := pagesarchive.DetectFormatFromName(fileName); ok {
return fmt.Sprintf("pages-deployment-%d.%s", deploymentID, pagesarchive.Extension(format))
}
if declaredSize > 0 {
return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above
// Fall back by content type.
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "application/gzip", "application/x-gzip":
return fmt.Sprintf("pages-deployment-%d.tar.gz", deploymentID)
case "application/x-xz":
return fmt.Sprintf("pages-deployment-%d.tar.xz", deploymentID)
case "application/x-bzip2":
return fmt.Sprintf("pages-deployment-%d.tar.bz2", deploymentID)
case "application/x-7z-compressed":
return fmt.Sprintf("pages-deployment-%d.7z", deploymentID)
case "application/x-tar":
return fmt.Sprintf("pages-deployment-%d.tar", deploymentID)
default:
return fmt.Sprintf("pages-deployment-%d.zip", deploymentID)
}
limited := io.LimitReader(src, pagesMaxDeploymentBytes+1)
written, err := io.Copy(dst, limited)
if written > pagesMaxDeploymentBytes {
return written, errors.New("pages file size out of bounds")
}
return written, err
}
func checksumZipFile(item *zip.File) (string, int64, error) {
file, err := item.Open()
if err != nil {
return "", 0, err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
written, err := copyPagesZipEntryContent(hash, file, item.UncompressedSize64)
if err != nil {
return "", written, err
}
return hex.EncodeToString(hash.Sum(nil)), written, nil
}
+206 -10
View File
@@ -12,13 +12,14 @@ import (
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"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/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
"gorm.io/gorm"
)
@@ -251,20 +252,18 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
if fileHeader == nil {
return nil, errors.New(errPagesPackageMissing)
}
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New(errPagesPackageNotZip)
}
limits := resolvePagesLimits(ctx)
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
if err != nil {
return nil, err
}
entryFile := normalizePagesEntryFile(project.EntryFile)
tempPath, checksum, _, err := persistPagesUploadTemp(fileHeader)
tempPath, checksum, _, format, err := persistPagesUploadTemp(fileHeader, limits.PackageBytes)
if err != nil {
return nil, err
}
defer func() { _ = os.Remove(tempPath) }()
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
manifest, err := inspectPagesPackage(tempPath, format, rootDir, entryFile, limits)
if err != nil {
return nil, err
}
@@ -274,6 +273,7 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
checksum,
project.Slug,
fileHeader.Filename,
format,
)
if err != nil {
return nil, err
@@ -320,10 +320,160 @@ func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart
return nil, err
}
ingestCommitted = true
// History prune must not fail the already-committed upload. Log and continue;
// a later upload (or a retry pass inside prune) will re-attempt cleanup.
if pruneErr := pruneProjectDeploymentHistory(ctx, project.ID, limits.HistoryCount); pruneErr != nil {
logger.ErrorF(ctx,
"[Pages] prune deployment history failed: project_id=%d keep=%d error=%v",
project.ID, limits.HistoryCount, pruneErr,
)
}
view := buildDeploymentView(deployment)
return &view, nil
}
// pruneProjectDeploymentHistory enforces the retention policy for one project.
//
// Policy (keepCount > 0):
// - At most keepCount deployment rows remain for the project.
// - The current active deployment is always retained (if any).
// - Remaining slots are filled by newest deployments first (id desc).
// - All other non-kept deployments are deleted with their file lists and artifacts.
//
// keepCount <= 0 means unlimited history.
//
// 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 {
if keepCount <= 0 {
return nil
}
// Two passes: first pass after upload, second pass heals a concurrent race
// that inserted another deployment between our list and delete.
var lastErr error
for pass := 0; pass < 2; pass++ {
deleted, err := pruneProjectDeploymentHistoryOnce(ctx, projectID, keepCount)
if err != nil {
lastErr = err
break
}
if deleted == 0 {
break
}
}
return lastErr
}
// 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 {
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)
}
}
return nil
}); 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)
}
logger.InfoF(ctx,
"[Pages] pruned deployment history: project_id=%d keep=%d deleted=%d",
projectID, keepCount, len(toDelete),
)
return len(toDelete), nil
}
// 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 {
if keepCount <= 0 || len(deployments) <= keepCount {
return nil
}
keepIDs := make(map[uint]struct{}, keepCount)
// 1) Active is always retained and occupies one slot when present.
if activeID != 0 {
// Only count active if it still exists in the list.
for _, deployment := range deployments {
if deployment.ID == activeID {
keepIDs[activeID] = struct{}{}
break
}
}
}
// 2) Fill remaining slots from newest to oldest.
for _, deployment := range deployments {
if len(keepIDs) >= keepCount {
break
}
keepIDs[deployment.ID] = struct{}{}
}
toDelete := make([]model.PagesDeployment, 0, len(deployments)-len(keepIDs))
for _, deployment := range deployments {
if _, keep := keepIDs[deployment.ID]; keep {
continue
}
// Safety: never mark active for deletion.
if activeID != 0 && deployment.ID == activeID {
continue
}
toDelete = append(toDelete, deployment)
}
return toDelete
}
// ActivateDeployment 激活 Pages 部署。
func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, projectID)
@@ -422,10 +572,14 @@ func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deplo
}
contentType := opened.ContentType
if contentType == "" {
contentType = mimeTypeApplicationZip
contentType = opened.Upload.MimeType
}
if contentType == "" {
contentType = "application/octet-stream"
}
fileName := packageDownloadName(deploymentID, opened.Upload.FileName, contentType)
return DeploymentPackage{
FileName: fmt.Sprintf("pages-deployment-%d.zip", deploymentID),
FileName: fileName,
ContentType: contentType,
ContentLength: opened.ContentLength,
Body: opened.Body,
@@ -478,6 +632,7 @@ func hydrateLegacyDeploymentUpload(
deployment.Checksum,
project.Slug,
fmt.Sprintf("pages-deployment-%d.zip", deployment.ID),
pagesarchive.FormatZip,
)
if err != nil {
return nil, err
@@ -493,7 +648,26 @@ func hydrateLegacyDeploymentUpload(
return &ingestResult.Upload, nil
}
// ensureDeploymentInActiveSnapshot allows Agent package download when the
// deployment is the project's current active deployment and that project is
// used by at least one pages route in the active main config.
//
// Main config versions pin historical pages_deployment ids for audit only.
// Runtime download always follows the live active Pages deployment (dual
// version control); rolling back main config must not require old packages.
func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) error {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return err
}
project, err := model.GetPagesProjectByID(ctx, deployment.ProjectID)
if err != nil {
return err
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID {
return errors.New(errPagesPackageNotInActiveConfig)
}
version, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -506,10 +680,30 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
return err
}
for _, route := range routes {
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
if !strings.EqualFold(strings.TrimSpace(route.UpstreamType), "pages") {
continue
}
if route.PagesDeployment.DeploymentID == deploymentID {
if route.PagesProjectID != nil && *route.PagesProjectID == project.ID {
return nil
}
if route.PagesDeployment == nil {
continue
}
if route.PagesDeployment.ProjectID == project.ID {
return nil
}
// Frozen snapshot may only carry deployment_id; resolve project via that row.
if route.PagesDeployment.DeploymentID == 0 {
continue
}
if route.PagesDeployment.DeploymentID == deployment.ID {
return nil
}
snapDeployment, snapErr := model.GetPagesDeploymentByID(ctx, route.PagesDeployment.DeploymentID)
if snapErr != nil {
continue
}
if snapDeployment.ProjectID == project.ID {
return nil
}
}
@@ -518,10 +712,12 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
type snapshotPagesDeployment struct {
DeploymentID uint `json:"deployment_id"`
ProjectID uint `json:"project_id"`
}
type snapshotRouteRef struct {
UpstreamType string `json:"upstream_type"`
PagesProjectID *uint `json:"pages_project_id"`
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment"`
}
@@ -4,8 +4,10 @@
package pages
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
@@ -17,6 +19,7 @@ import (
"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/internal/storage"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -40,6 +43,7 @@ func setupPagesTestDB(t *testing.T) func() {
&model.PagesDeployment{},
&model.PagesDeploymentFile{},
&model.ConfigVersion{},
&model.SystemConfig{},
))
require.NoError(t, sqliteDB.Create(&model.User{
ID: 999,
@@ -48,8 +52,25 @@ func setupPagesTestDB(t *testing.T) func() {
Nickname: "系统",
IsActive: true,
}).Error)
require.NoError(t, sqliteDB.Create([]model.SystemConfig{
{
Key: model.ConfigKeyPagesMaxPackageSizeMB,
Value: "100",
Type: "business",
Description: "Pages 部署包上传大小上限(MiB)",
},
{
Key: model.ConfigKeyPagesMaxHistoryCount,
Value: "0", // unlimited for existing tests
Type: "business",
Description: "Pages 每个项目最大历史部署保留数(0 表示不限制)",
},
}).Error)
db.SetDB(sqliteDB)
// Clear process-global system config RAM cache so tests do not see stale values.
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxPackageSizeMB)
_ = repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyPagesMaxHistoryCount)
return func() {
db.SetDB(nil)
}
@@ -339,6 +360,149 @@ func testPagesZip(t *testing.T, files map[string]string) []byte {
return buffer.Bytes()
}
func TestUploadDeploymentAcceptsTarGz(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
_, disableStorage := setupPagesStorageMock(t)
defer disableStorage()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "TarGz Site",
Slug: "tar-gz-site",
Enabled: true,
})
require.NoError(t, err)
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.tar.gz", testPagesTarGz(t, map[string]string{
"index.html": "tar-ok",
"app.js": "1",
})), "root")
require.NoError(t, err)
assert.Equal(t, 2, deployment.FileCount)
assert.NotZero(t, deployment.UploadID)
}
func TestSelectDeploymentsToPruneKeepsActiveAndNewest(t *testing.T) {
// ids 4(newest) ... 1(oldest); active is oldest id=1; keep=2 → keep {1,4}, prune {3,2}
deployments := []model.PagesDeployment{
{ID: 4, ProjectID: 1},
{ID: 3, ProjectID: 1},
{ID: 2, ProjectID: 1},
{ID: 1, ProjectID: 1},
}
toDelete := selectDeploymentsToPrune(deployments, 1, 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)
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)
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)
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))
// unlimited
assert.Nil(t, selectDeploymentsToPrune(deployments, 1, 0))
}
func TestPruneProjectDeploymentHistory(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", "2").Error)
require.NoError(t, repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyPagesMaxHistoryCount))
project, err := CreateProject(ctx, Input{
Name: "History Site",
Slug: "history-site",
Enabled: true,
})
require.NoError(t, err)
var ids []uint
for i := 0; i < 3; i++ {
deployment, uploadErr := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": fmt.Sprintf("v%d", i),
})), "root")
require.NoError(t, uploadErr)
ids = append(ids, deployment.ID)
}
// After 3 uploads with keep=2 and no active: only 2 newest remain.
deployments, err := model.ListPagesDeployments(ctx, project.ID)
require.NoError(t, err)
require.Len(t, deployments, 2)
assert.Equal(t, ids[2], deployments[0].ID)
assert.Equal(t, ids[1], deployments[1].ID)
// Activate the older of the remaining two, then upload again.
_, err = ActivateDeployment(ctx, project.ID, ids[1])
require.NoError(t, err)
latest, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "v-latest",
})), "root")
require.NoError(t, err)
deployments, err = model.ListPagesDeployments(ctx, project.ID)
require.NoError(t, err)
require.Len(t, deployments, 2, "must be at most N=2, not active+N newest")
storedProject, err := model.GetPagesProjectByID(ctx, project.ID)
require.NoError(t, err)
require.NotNil(t, storedProject.ActiveDeploymentID)
assert.Equal(t, ids[1], *storedProject.ActiveDeploymentID)
kept := map[uint]struct{}{}
for _, item := range deployments {
kept[item.ID] = struct{}{}
}
_, hasActive := kept[ids[1]]
_, hasLatest := kept[latest.ID]
assert.True(t, hasActive, "active deployment must be retained")
assert.True(t, hasLatest, "newest deployment must fill remaining slot")
}
func testPagesTarGz(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
gzWriter := gzip.NewWriter(&buffer)
tarWriter := tar.NewWriter(gzWriter)
for name, content := range files {
require.NoError(t, tarWriter.WriteHeader(&tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(content)),
}))
_, err := tarWriter.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, tarWriter.Close())
require.NoError(t, gzWriter.Close())
return buffer.Bytes()
}
func testPagesMultipartFile(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
t.Helper()
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
"gorm.io/gorm"
)
// RebindSnapshotPagesToCurrentActive rewrites pages_deployment fields so every
// pages route points at the project's current active deployment.
//
// Main config versions and Pages deployments are independent. Rolling back a
// main config version must not require old Pages packages; Agents always follow
// the live active deployment for each referenced project.
//
// Returns the original JSON unchanged when there are no pages routes. Does not
// mutate stored config_versions rows. Non-pages route fields are preserved.
func RebindSnapshotPagesToCurrentActive(ctx context.Context, snapshotJSON string) (string, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
return snapshotJSON, nil
}
if strings.HasPrefix(text, "[") {
var routes []map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &routes); err != nil {
return "", fmt.Errorf("parse pages snapshot routes: %w", err)
}
changed, err := rebindPagesRouteMaps(ctx, routes)
if err != nil {
return "", err
}
if !changed {
return snapshotJSON, nil
}
encoded, err := json.Marshal(routes)
if err != nil {
return "", err
}
return string(encoded), nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(text), &raw); err != nil {
return "", fmt.Errorf("parse pages snapshot document: %w", err)
}
routesRaw, ok := raw["routes"]
if !ok || len(routesRaw) == 0 {
return snapshotJSON, nil
}
var routes []map[string]json.RawMessage
if err := json.Unmarshal(routesRaw, &routes); err != nil {
return "", fmt.Errorf("parse pages snapshot routes: %w", err)
}
changed, err := rebindPagesRouteMaps(ctx, routes)
if err != nil {
return "", err
}
if !changed {
return snapshotJSON, nil
}
encodedRoutes, err := json.Marshal(routes)
if err != nil {
return "", err
}
raw["routes"] = encodedRoutes
encoded, err := json.Marshal(raw)
if err != nil {
return "", err
}
return string(encoded), nil
}
func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessage) (bool, error) {
changed := false
for index := range routes {
route := routes[index]
if route == nil {
continue
}
upstreamType, _ := rawJSONString(route["upstream_type"])
if !strings.EqualFold(strings.TrimSpace(upstreamType), "pages") {
continue
}
siteName, _ := rawJSONString(route["site_name"])
projectID, err := resolveProjectIDFromRouteMap(route)
if err != nil {
if siteName == "" {
siteName = "pages"
}
return false, fmt.Errorf("路由 %s %w", siteName, err)
}
project, activeDeployment, err := loadActivePagesProject(ctx, projectID, siteName)
if err != nil {
return false, err
}
deployment := buildLivePagesDeployment(project, activeDeployment)
projectIDCopy := project.ID
originURL := fmt.Sprintf("openflare-pages://project/%d", project.ID)
if err := putJSON(route, "pages_project_id", projectIDCopy); err != nil {
return false, err
}
if err := putJSON(route, "pages_deployment", deployment); err != nil {
return false, err
}
if err := putJSON(route, "origin_url", originURL); err != nil {
return false, err
}
if err := putJSON(route, "upstreams", []string{originURL}); err != nil {
return false, err
}
routes[index] = route
changed = true
}
return changed, nil
}
const jsonNullLiteral = "null"
func isPresentJSON(raw json.RawMessage) bool {
return len(raw) > 0 && string(raw) != jsonNullLiteral
}
func resolveProjectIDFromRouteMap(route map[string]json.RawMessage) (uint, error) {
if raw, ok := route["pages_project_id"]; ok && isPresentJSON(raw) {
var projectID uint
if err := json.Unmarshal(raw, &projectID); err == nil && projectID != 0 {
return projectID, nil
}
}
if raw, ok := route["pages_deployment"]; ok && isPresentJSON(raw) {
var deployment struct {
ProjectID uint `json:"project_id"`
}
if err := json.Unmarshal(raw, &deployment); err == nil && deployment.ProjectID != 0 {
return deployment.ProjectID, nil
}
}
return 0, fmt.Errorf("pages 配置无效: 缺少 pages_project_id")
}
func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) {
if siteName == "" {
siteName = "pages"
}
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
if errorsIsNotFound(err) {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", siteName)
}
return nil, nil, err
}
if !project.Enabled {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", siteName)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", siteName)
}
activeDeployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err != nil {
if errorsIsNotFound(err) {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", siteName)
}
return nil, nil, err
}
if activeDeployment.ProjectID != project.ID {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", siteName)
}
if strings.TrimSpace(activeDeployment.Checksum) == "" {
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", siteName)
}
return project, activeDeployment, nil
}
func buildLivePagesDeployment(project *model.PagesProject, active *model.PagesDeployment) *openrestyrender.PagesDeployment {
entryFile := strings.TrimSpace(project.EntryFile)
if entryFile == "" {
entryFile = defaultPagesEntryFile
}
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
if fallbackPath == "" {
fallbackPath = defaultPagesFallbackPath
}
return &openrestyrender.PagesDeployment{
ProjectID: project.ID,
ProjectSlug: strings.TrimSpace(project.Slug),
DeploymentID: active.ID,
DeploymentNumber: active.DeploymentNumber,
Checksum: strings.TrimSpace(active.Checksum),
EntryFile: entryFile,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: fallbackPath,
APIProxyEnabled: project.APIProxyEnabled,
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
LocalRoot: fmt.Sprintf(
"%s/deployments/%d/current",
openrestyrender.PagesDirPlaceholder,
active.ID,
),
}
}
func rawJSONString(raw json.RawMessage) (string, bool) {
if !isPresentJSON(raw) {
return "", false
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return "", false
}
return value, true
}
func putJSON(route map[string]json.RawMessage, key string, value any) error {
encoded, err := json.Marshal(value)
if err != nil {
return err
}
route[key] = encoded
return nil
}
func errorsIsNotFound(err error) bool {
return err != nil && (err == gorm.ErrRecordNotFound || strings.Contains(strings.ToLower(err.Error()), "record not found"))
}
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"encoding/json"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRebindSnapshotPagesToCurrentActive(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Rebind Site",
Slug: "rebind-site",
Enabled: true,
})
require.NoError(t, err)
old := &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: 1,
Checksum: "old-checksum",
Status: model.PagesDeploymentStatusUploaded,
FileCount: 1,
}
require.NoError(t, db.DB(ctx).Create(old).Error)
active := &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: 2,
Checksum: "new-checksum",
Status: model.PagesDeploymentStatusActive,
FileCount: 1,
}
require.NoError(t, db.DB(ctx).Create(active).Error)
require.NoError(t, db.DB(ctx).Model(&model.PagesProject{}).
Where("id = ?", project.ID).
Update("active_deployment_id", active.ID).Error)
// Frozen snapshot still points at the old deployment (simulates old main config).
frozen := map[string]any{
"routes": []map[string]any{
{
"site_name": "rebind",
"origin_url": "openflare-pages://project/1",
"enabled": true,
"upstream_type": "pages",
"pages_project_id": project.ID,
"pages_deployment": map[string]any{
"project_id": project.ID,
"deployment_id": old.ID,
"checksum": "old-checksum",
"local_root": "__OPENFLARE_PAGES_DIR__/deployments/1/current",
},
"extra_keep_me": "yes",
},
},
"waf": map[string]any{"rule_groups": []any{}},
}
frozenJSON, err := json.Marshal(frozen)
require.NoError(t, err)
reboundJSON, err := RebindSnapshotPagesToCurrentActive(ctx, string(frozenJSON))
require.NoError(t, err)
var rebound map[string]any
require.NoError(t, json.Unmarshal([]byte(reboundJSON), &rebound))
_, hasWAF := rebound["waf"]
assert.True(t, hasWAF)
routes := rebound["routes"].([]any)
require.Len(t, routes, 1)
route := routes[0].(map[string]any)
assert.Equal(t, "yes", route["extra_keep_me"])
deployment := route["pages_deployment"].(map[string]any)
assert.EqualValues(t, active.ID, deployment["deployment_id"])
assert.Equal(t, "new-checksum", deployment["checksum"])
}
@@ -0,0 +1,9 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('pages_max_package_size_mb', '100', 'business', 0, 'Pages 部署包上传大小上限(MiB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('pages_max_history_count', '20', 'business', 0, 'Pages 每个项目最大历史部署保留数(0 表示不限制)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('pages_max_package_size_mb', 'pages_max_history_count');
@@ -0,0 +1,9 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('pages_max_package_size_mb', '100', 'business', 0, 'Pages 部署包上传大小上限(MiB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('pages_max_history_count', '20', 'business', 0, 'Pages 每个项目最大历史部署保留数(0 表示不限制)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('pages_max_package_size_mb', 'pages_max_history_count');
+4
View File
@@ -50,6 +50,10 @@ const (
ConfigKeyDatabaseAutoCleanupEnabled = "database_auto_cleanup_enabled" // 数据库自动清理开关
ConfigKeyDatabaseAutoCleanupRetentionDays = "database_auto_cleanup_retention_days" // 数据库保留天数
// Pages 静态托管配置
ConfigKeyPagesMaxPackageSizeMB = "pages_max_package_size_mb" // Pages 部署包上传大小上限(MiB)
ConfigKeyPagesMaxHistoryCount = "pages_max_history_count" // Pages 每个项目最大历史部署保留数(0 表示不限制)
// UptimeKuma 集成配置
ConfigKeyUptimeKumaEnabled = "uptime_kuma_enabled" // UptimeKuma 集成开关
ConfigKeyUptimeKumaURL = "uptime_kuma_url" // UptimeKuma URL
+12
View File
@@ -295,6 +295,18 @@ func getSeedConfigsPart2() []model.SystemConfig {
Type: configTypeBusiness,
Description: "FRPS 内置 Web 界面端口",
},
{
Key: model.ConfigKeyPagesMaxPackageSizeMB,
Value: "100",
Type: configTypeBusiness,
Description: "Pages 部署包上传大小上限(MiB)",
},
{
Key: model.ConfigKeyPagesMaxHistoryCount,
Value: "20",
Type: configTypeBusiness,
Description: "Pages 每个项目最大历史部署保留数(0 表示不限制)",
},
}
}