merge: replace legacy openflare-server with Wavelet rename

This commit is contained in:
ryan
2026-06-19 11:30:05 +08:00
1303 changed files with 40520 additions and 157754 deletions
@@ -0,0 +1,27 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
const (
errPagesProjectNotFound = "Pages 项目不存在"
errPagesSlugExists = "Pages 项目标识已存在"
errPagesNameRequired = "Pages 项目名称不能为空"
errPagesSlugInvalid = "Pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "Pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "Pages 部署不存在"
errPagesDeploymentMismatch = "Pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageNotZip = "Pages 部署包必须是 .zip 文件"
errPagesPackageInvalidZip = "Pages 部署包不是有效 zip 文件"
errPagesPackageEmpty = "Pages 部署包不能为空"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空"
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL"
errPagesPackagePathEmpty = "Pages 部署包路径为空"
errPagesPackageUploadMissing = "Pages 部署包上传记录不存在"
errPagesPackageNotInActiveConfig = "Pages 部署尚未进入激活配置"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
)
@@ -0,0 +1,370 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
const (
pagesMaxDeploymentFiles = 1000
pagesMaxDeploymentBytes = 100 * 1024 * 1024
defaultPagesEntryFile = "index.html"
defaultPagesFallbackPath = "/index.html"
pagesDeploymentUploadType = "openflare_pages_deployment"
)
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
type deploymentManifest struct {
Files []model.PagesDeploymentFile
FileCount int
TotalSize int64
EntryFile string
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
func normalizePagesSlug(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
var builder strings.Builder
lastDash := false
for _, r := range value {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if valid {
builder.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
return strings.Trim(builder.String(), "-")
}
func validateAndNormalizePagesRootDir(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
if len(value) > 512 {
return "", errors.New("Pages 根目录长度不能超过 512")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("Pages 根目录包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("Pages 根目录不能包含空白或控制字符")
}
}
cleaned := path.Clean(filepath.ToSlash(value))
if cleaned == "." || cleaned == "/" {
return "", nil
}
for _, segment := range strings.Split(cleaned, "/") {
if segment == "." || segment == ".." {
return "", errors.New("Pages 根目录不能包含 . 或 .. 路径段")
}
}
return strings.TrimPrefix(cleaned, "/"), nil
}
func normalizePagesFallbackPath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
value = defaultPagesFallbackPath
}
if len(value) > 512 {
return "", errors.New("SPA fallback 回退路径长度不能超过 512")
}
if !strings.HasPrefix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须以 / 开头")
}
if value == "/" || strings.HasSuffix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("SPA fallback 回退路径包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符")
}
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." {
return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段")
}
}
cleaned := path.Clean(value)
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径不合法")
}
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
return cleaned, nil
}
func normalizeStoredPagesFallbackPath(value string) string {
normalized, err := normalizePagesFallbackPath(value)
if err != nil {
return defaultPagesFallbackPath
}
return normalized
}
func normalizePagesEntryFile(raw string) string {
value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw)))
if value == "." || value == "/" {
return defaultPagesEntryFile
}
return strings.TrimPrefix(value, "/")
}
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, int64, error) {
file, err := fileHeader.Open()
if err != nil {
return "", "", 0, err
}
defer file.Close()
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
if err != nil {
return "", "", 0, err
}
defer temp.Close()
hash := sha256.New()
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
if err != nil {
_ = os.Remove(temp.Name())
return "", "", 0, err
}
if written > pagesMaxDeploymentBytes {
_ = os.Remove(temp.Name())
return "", "", 0, fmt.Errorf("Pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, nil
}
func ingestPagesDeploymentPackage(
ctx context.Context,
tempPath string,
checksum string,
size int64,
projectSlug string,
fileName string,
) (upload.IngestResult, error) {
file, err := os.Open(tempPath)
if err != nil {
return upload.IngestResult{}, err
}
defer file.Close()
systemUser := repository.GetSystemUser(ctx)
accessMode := 0
return upload.Ingest(ctx, upload.IngestRequest{
UserID: systemUser.ID,
Reader: file,
Size: size,
FileName: fileName,
MimeType: "application/zip",
Extension: "zip",
Hash: checksum,
Type: pagesDeploymentUploadType,
AccessMode: &accessMode,
SkipExtensionCheck: true,
Policy: upload.PolicyDedupNewRecord,
Metadata: model.UploadMetadata{
Extra: map[string]any{
"project_slug": projectSlug,
},
},
})
}
func removeDeploymentArtifact(ctx context.Context, deployment *model.PagesDeployment) {
if deployment == nil {
return
}
if deployment.UploadID > 0 {
if _, err := upload.Remove(ctx, deployment.UploadID); err != nil {
return
}
return
}
if strings.TrimSpace(deployment.ArtifactPath) != "" {
_ = os.Remove(deployment.ArtifactPath)
}
}
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 reader.Close()
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
return nil, err
}
manifest := &deploymentManifest{
Files: []model.PagesDeploymentFile{},
EntryFile: entryFile,
}
targetEntryPath := entryFile
if rootDir != "" {
targetEntryPath = path.Join(rootDir, entryFile)
}
entrySeen := false
for _, item := range reader.File {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
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)
}
manifest.TotalSize += int64(item.UncompressedSize64)
if manifest.TotalSize > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("Pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
checksum, err := checksumZipFile(item)
if err != nil {
return nil, err
}
if normalizedPath == targetEntryPath {
entrySeen = true
}
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
Path: normalizedPath,
Size: int64(item.UncompressedSize64),
Checksum: 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
}
if strings.HasSuffix(name, "/") {
return "", true, nil
}
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 checksumZipFile(item *zip.File) (string, error) {
file, err := item.Open()
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err = io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -0,0 +1,593 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage"
"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"
"gorm.io/gorm"
)
// Input Pages 项目创建/更新请求。
type Input struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
APIProxyEnabled bool `json:"api_proxy_enabled"`
APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
}
// DeploymentView Pages 部署视图。
type DeploymentView struct {
ID uint `json:"id"`
ProjectID uint `json:"project_id"`
DeploymentNumber int `json:"deployment_number"`
Checksum string `json:"checksum"`
Status string `json:"status"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
}
// DeploymentFileView Pages 部署文件视图。
type DeploymentFileView struct {
ID uint `json:"id"`
DeploymentID uint `json:"deployment_id"`
Path string `json:"path"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
CreatedAt time.Time `json:"created_at"`
}
// View Pages 项目视图。
type View struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
APIProxyEnabled bool `json:"api_proxy_enabled"`
APIProxyPath string `json:"api_proxy_path"`
APIProxyPass string `json:"api_proxy_pass"`
APIProxyRewrite string `json:"api_proxy_rewrite"`
RootDir string `json:"root_dir"`
EntryFile string `json:"entry_file"`
ActiveDeploymentID *uint `json:"active_deployment_id"`
ActiveDeployment *DeploymentView `json:"active_deployment,omitempty"`
DeploymentCount int64 `json:"deployment_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListProjects 列出全部 Pages 项目。
func ListProjects(ctx context.Context) ([]View, error) {
projects, err := model.ListPagesProjects(ctx)
if err != nil {
return nil, err
}
views := make([]View, 0, len(projects))
for _, project := range projects {
view, err := buildProjectView(ctx, &project)
if err != nil {
return nil, err
}
views = append(views, *view)
}
return views, nil
}
// GetProject 获取 Pages 项目详情。
func GetProject(ctx context.Context, id uint) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, id)
if err != nil {
return nil, err
}
return buildProjectView(ctx, project)
}
// CreateProject 创建 Pages 项目。
func CreateProject(ctx context.Context, input Input) (*View, error) {
project, err := buildProject(nil, input)
if err != nil {
return nil, err
}
if err = model.CreatePagesProjectRecord(ctx, project); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errPagesSlugExists)
}
return nil, err
}
return buildProjectView(ctx, project)
}
// UpdateProject 更新 Pages 项目。
func UpdateProject(ctx context.Context, id uint, input Input) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, id)
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)
}
return nil, err
}
return buildProjectView(ctx, project)
}
// 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 {
if err := tx.Where(
"deployment_id IN (?)",
tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID),
).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", project.ID).Delete(&model.PagesDeployment{}).Error; err != nil {
return err
}
if err := tx.Delete(project).Error; err != nil {
return err
}
for index := range deployments {
removeDeploymentArtifact(ctx, &deployments[index])
}
return nil
})
}
// ListProjectDeployments 列出项目的全部部署。
func ListProjectDeployments(ctx context.Context, projectID uint) ([]DeploymentView, error) {
if _, err := model.GetPagesProjectByID(ctx, projectID); err != nil {
return nil, err
}
deployments, err := model.ListPagesDeployments(ctx, projectID)
if err != nil {
return nil, err
}
views := make([]DeploymentView, 0, len(deployments))
for _, deployment := range deployments {
views = append(views, buildDeploymentView(&deployment))
}
return views, nil
}
// ListDeploymentFiles 列出部署文件清单。
func ListDeploymentFiles(ctx context.Context, deploymentID uint) ([]DeploymentFileView, error) {
if _, err := model.GetPagesDeploymentByID(ctx, deploymentID); err != nil {
return nil, err
}
files, err := model.ListPagesDeploymentFiles(ctx, deploymentID)
if err != nil {
return nil, err
}
views := make([]DeploymentFileView, 0, len(files))
for _, file := range files {
views = append(views, DeploymentFileView{
ID: file.ID,
DeploymentID: file.DeploymentID,
Path: file.Path,
Size: file.Size,
Checksum: file.Checksum,
CreatedAt: file.CreatedAt,
})
}
return views, nil
}
// UploadDeployment 上传 Pages 部署包。
func UploadDeployment(ctx context.Context, projectID uint, fileHeader *multipart.FileHeader, createdBy string) (*DeploymentView, error) {
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
if fileHeader == nil {
return nil, errors.New(errPagesPackageMissing)
}
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New(errPagesPackageNotZip)
}
rootDir, err := validateAndNormalizePagesRootDir(project.RootDir)
if err != nil {
return nil, err
}
entryFile := normalizePagesEntryFile(project.EntryFile)
tempPath, checksum, packageSize, err := persistPagesUploadTemp(fileHeader)
if err != nil {
return nil, err
}
defer os.Remove(tempPath)
manifest, err := inspectPagesZip(tempPath, rootDir, entryFile)
if err != nil {
return nil, err
}
ingestResult, err := ingestPagesDeploymentPackage(
ctx,
tempPath,
checksum,
packageSize,
project.Slug,
fileHeader.Filename,
)
if err != nil {
return nil, err
}
ingestCommitted := false
defer func() {
if !ingestCommitted && ingestResult.Created {
_, _ = upload.Remove(ctx, ingestResult.Upload.ID)
}
}()
deployment := &model.PagesDeployment{}
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var maxNumber int
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", project.ID).
Select("COALESCE(MAX(deployment_number), 0)").
Scan(&maxNumber).Error; err != nil {
return err
}
deployment = &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: checksum,
Status: model.PagesDeploymentStatusUploaded,
UploadID: ingestResult.Upload.ID,
FileCount: manifest.FileCount,
TotalSize: manifest.TotalSize,
CreatedBy: strings.TrimSpace(createdBy),
}
if err := tx.Create(deployment).Error; err != nil {
return err
}
for index := range manifest.Files {
manifest.Files[index].DeploymentID = deployment.ID
}
if len(manifest.Files) > 0 {
if err := tx.Create(&manifest.Files).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
ingestCommitted = true
view := buildDeploymentView(deployment)
return &view, nil
}
// ActivateDeployment 激活 Pages 部署。
func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint) (*View, error) {
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return nil, err
}
if deployment.ProjectID != project.ID {
return nil, errors.New(errPagesDeploymentMismatch)
}
now := time.Now()
if err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
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{
"status": model.PagesDeploymentStatusActive,
"activated_at": &now,
}).Error; err != nil {
return err
}
return tx.Model(project).Updates(map[string]any{
"active_deployment_id": deployment.ID,
}).Error
}); err != nil {
return nil, err
}
return GetProject(ctx, project.ID)
}
// OpenDeploymentPackage opens the deployment artifact from the upload storage framework.
func OpenDeploymentPackage(ctx context.Context, deploymentID uint) (*storage.Object, string, error) {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return nil, "", err
}
if err = ensureDeploymentInActiveSnapshot(ctx, deployment.ID); err != nil {
return nil, "", err
}
fileName := fmt.Sprintf("pages-deployment-%d.zip", deployment.ID)
if deployment.UploadID > 0 {
uploadRecord, err := repository.GetActiveUploadByID(ctx, deployment.UploadID)
if err != nil {
return nil, "", errors.New(errPagesPackageUploadMissing)
}
obj, err := uploadstorage.OpenStoredObject(ctx, &uploadRecord)
if err != nil {
return nil, "", fmt.Errorf("Pages 部署包不存在: %w", err)
}
if obj.ContentType == "" {
obj.ContentType = "application/zip"
}
return obj, fileName, nil
}
if strings.TrimSpace(deployment.ArtifactPath) == "" {
return nil, "", errors.New(errPagesPackagePathEmpty)
}
file, err := os.Open(deployment.ArtifactPath)
if err != nil {
return nil, "", fmt.Errorf("Pages 部署包不存在: %w", err)
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, "", fmt.Errorf("Pages 部署包不存在: %w", err)
}
return &storage.Object{
Body: file,
ContentLength: info.Size(),
ContentType: "application/zip",
}, fileName, nil
}
func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) error {
version, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errPagesPackageNotInActiveConfig)
}
return err
}
routes, err := parseSnapshotRoutes(version.SnapshotJSON)
if err != nil {
return err
}
for _, route := range routes {
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
continue
}
if route.PagesDeployment.DeploymentID == deploymentID {
return nil
}
}
return errors.New(errPagesPackageNotInActiveConfig)
}
type snapshotPagesDeployment struct {
DeploymentID uint `json:"deployment_id"`
}
type snapshotRouteRef struct {
UpstreamType string `json:"upstream_type"`
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment"`
}
func parseSnapshotRoutes(snapshotJSON string) ([]snapshotRouteRef, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
return []snapshotRouteRef{}, nil
}
if strings.HasPrefix(text, "[") {
var routes []snapshotRouteRef
if err := json.Unmarshal([]byte(text), &routes); err != nil {
return nil, errors.New(errPagesInvalidSnapshotFormat)
}
return routes, nil
}
var snapshot struct {
Routes []snapshotRouteRef `json:"routes"`
}
if err := json.Unmarshal([]byte(text), &snapshot); err != nil {
return nil, errors.New(errPagesInvalidSnapshotFormat)
}
return snapshot.Routes, nil
}
// 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 {
return err
}
if err := tx.Delete(deployment).Error; err != nil {
return err
}
removeDeploymentArtifact(ctx, deployment)
return nil
})
}
func buildProject(existing *model.PagesProject, input Input) (*model.PagesProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New(errPagesNameRequired)
}
slug := normalizePagesSlug(input.Slug)
if slug == "" {
slug = normalizePagesSlug(name)
}
if !pagesSlugPattern.MatchString(slug) {
return nil, errors.New(errPagesSlugInvalid)
}
if existing == nil {
existing = &model.PagesProject{}
}
existing.Name = name
existing.Slug = slug
existing.Description = strings.TrimSpace(input.Description)
existing.Enabled = input.Enabled
existing.SPAFallbackEnabled = input.SPAFallbackEnabled
fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath)
if err != nil {
return nil, err
}
existing.SPAFallbackPath = fallbackPath
existing.APIProxyEnabled = input.APIProxyEnabled
apiProxyPath := strings.TrimSpace(input.APIProxyPath)
apiProxyPass := strings.TrimSpace(input.APIProxyPass)
apiProxyRewrite := strings.TrimSpace(input.APIProxyRewrite)
if existing.APIProxyEnabled {
if apiProxyPath == "" {
return nil, errors.New(errPagesAPIProxyPathRequired)
}
if !strings.HasPrefix(apiProxyPath, "/") {
return nil, errors.New(errPagesAPIProxyPathPrefix)
}
if apiProxyPass == "" {
return nil, errors.New(errPagesAPIProxyPassRequired)
}
parsedURL, err := url.Parse(apiProxyPass)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, errors.New(errPagesAPIProxyPassInvalid)
}
}
existing.APIProxyPath = apiProxyPath
existing.APIProxyPass = apiProxyPass
existing.APIProxyRewrite = apiProxyRewrite
rootDir, err := validateAndNormalizePagesRootDir(input.RootDir)
if err != nil {
return nil, err
}
existing.RootDir = rootDir
existing.EntryFile = normalizePagesEntryFile(input.EntryFile)
return existing, nil
}
func buildProjectView(ctx context.Context, project *model.PagesProject) (*View, error) {
if project == nil {
return nil, errors.New(errPagesProjectNotFound)
}
view := &View{
ID: project.ID,
Name: project.Name,
Slug: project.Slug,
Description: project.Description,
Enabled: project.Enabled,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
APIProxyEnabled: project.APIProxyEnabled,
APIProxyPath: project.APIProxyPath,
APIProxyPass: project.APIProxyPass,
APIProxyRewrite: project.APIProxyRewrite,
RootDir: project.RootDir,
EntryFile: project.EntryFile,
ActiveDeploymentID: project.ActiveDeploymentID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
}
count, err := model.CountPagesDeploymentsByProjectID(ctx, project.ID)
if err != nil {
return nil, err
}
view.DeploymentCount = count
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID != 0 {
deployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err == nil {
active := buildDeploymentView(deployment)
view.ActiveDeployment = &active
}
}
return view, nil
}
func buildDeploymentView(deployment *model.PagesDeployment) DeploymentView {
if deployment == nil {
return DeploymentView{}
}
return DeploymentView{
ID: deployment.ID,
ProjectID: deployment.ProjectID,
DeploymentNumber: deployment.DeploymentNumber,
Checksum: deployment.Checksum,
Status: deployment.Status,
FileCount: deployment.FileCount,
TotalSize: deployment.TotalSize,
CreatedBy: deployment.CreatedBy,
CreatedAt: deployment.CreatedAt,
ActivatedAt: deployment.ActivatedAt,
}
}
@@ -0,0 +1,257 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http/httptest"
"os"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupPagesTestDB(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.User{},
&model.Upload{},
&model.UploadStat{},
&model.TaskExecution{},
&model.PagesProject{},
&model.PagesDeployment{},
&model.PagesDeploymentFile{},
&model.ConfigVersion{},
))
require.NoError(t, sqliteDB.Create(&model.User{
ID: 999,
Username: "system",
Password: "*",
Nickname: "系统",
IsActive: true,
}).Error)
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func setupPagesStorageMock(t *testing.T) (restore func(), disable func()) {
t.Helper()
mockFiles := make(map[string][]byte)
restore = storage.MockStorage(
func(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
data, err := io.ReadAll(body)
if err != nil {
return err
}
mockFiles[key] = data
return nil
},
func(_ context.Context, key string) (*storage.Object, error) {
data, ok := mockFiles[key]
if !ok {
return nil, os.ErrNotExist
}
return &storage.Object{
Body: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "application/zip",
}, nil
},
func(_ context.Context, key string) error {
delete(mockFiles, key)
return nil
},
)
storage.IsEnabledFunc = func() bool { return true }
storage.ResetCache()
disable = func() {
storage.IsEnabledFunc = func() bool { return false }
storage.ResetCache()
restore()
}
return restore, disable
}
func TestCreateProject(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Marketing Site",
Slug: "marketing-site",
Description: "public site",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html",
EntryFile: "index.html",
})
require.NoError(t, err)
assert.NotZero(t, project.ID)
assert.Equal(t, "Marketing Site", project.Name)
assert.Equal(t, "marketing-site", project.Slug)
assert.Equal(t, "public site", project.Description)
assert.True(t, project.Enabled)
assert.True(t, project.SPAFallbackEnabled)
assert.Equal(t, "/index.html", project.SPAFallbackPath)
assert.Equal(t, "index.html", project.EntryFile)
assert.Equal(t, int64(0), project.DeploymentCount)
_, err = CreateProject(ctx, Input{
Name: "Duplicate Slug",
Slug: "marketing-site",
})
require.Error(t, err)
assert.Equal(t, errPagesSlugExists, err.Error())
}
func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
_, err := CreateProject(ctx, Input{
Name: "Unsafe Fallback",
Slug: "unsafe-fallback",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html; proxy_pass http://evil",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "回退路径")
}
func TestUploadDeploymentStoresPackageInUploadFramework(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
_, disableStorage := setupPagesStorageMock(t)
defer disableStorage()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Upload Framework Site",
Slug: "upload-framework-site",
Enabled: true,
})
require.NoError(t, err)
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "ok",
})), "root")
require.NoError(t, err)
storedDeployment, err := model.GetPagesDeploymentByID(ctx, deployment.ID)
require.NoError(t, err)
assert.NotZero(t, storedDeployment.UploadID)
assert.Empty(t, storedDeployment.ArtifactPath)
var uploadCount int64
require.NoError(t, db.DB(ctx).Model(&model.Upload{}).Count(&uploadCount).Error)
assert.Equal(t, int64(1), uploadCount)
}
func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
_, disableStorage := setupPagesStorageMock(t)
defer disableStorage()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Published Site",
Slug: "published-site",
Enabled: true,
})
require.NoError(t, err)
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "ok",
})), "root")
require.NoError(t, err)
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
require.NoError(t, err)
_, _, err = OpenDeploymentPackage(ctx, deployment.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "激活配置")
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
Version: "v2026-001",
SnapshotJSON: fmt.Sprintf(`{"routes":[{"upstream_type":"pages","pages_deployment":{"deployment_id":%d}}]}`, deployment.ID),
MainConfig: "",
RenderedConfig: "",
SupportFilesJSON: "[]",
Checksum: "test-checksum",
IsActive: true,
CreatedBy: "test",
}).Error)
packageObj, fileName, err := OpenDeploymentPackage(ctx, deployment.ID)
require.NoError(t, err)
defer packageObj.Body.Close()
assert.Equal(t, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), fileName)
body, err := io.ReadAll(packageObj.Body)
require.NoError(t, err)
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
require.NoError(t, err)
require.Len(t, reader.File, 1)
assert.Equal(t, "index.html", reader.File[0].Name)
}
func testPagesZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, err := writer.Create(name)
require.NoError(t, err)
_, err = file.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
return buffer.Bytes()
}
func testPagesMultipartFile(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("package", fileName)
require.NoError(t, err)
_, err = part.Write(content)
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest("POST", "/", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
require.NoError(t, req.ParseMultipartForm(int64(len(content))+1024))
file, header, err := req.FormFile("package")
require.NoError(t, err)
file.Close()
return header
}
@@ -0,0 +1,310 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errPagesProjectNotFound)
}
func deploymentIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("deployment_id")
if raw == "" {
response.AbortBadRequest(c, "无效的 ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
response.AbortBadRequest(c, "无效的 ID")
return 0, false
}
return uint(id64), true
}
// ListProjectsHandler 列出全部 Pages 项目。
// @Summary 列出 Pages 项目
// @Description 返回全部 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]pages.View} "Pages 项目列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages [get]
func ListProjectsHandler(c *gin.Context) {
projects, err := ListProjects(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(projects))
}
// GetProjectHandler 获取 Pages 项目详情。
// @Summary 获取 Pages 项目详情
// @Description 按 ID 返回 Pages 项目详情,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any{data=pages.View} "Pages 项目详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id} [get]
func GetProjectHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
project, err := GetProject(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(project))
}
// CreateProjectHandler 创建 Pages 项目。
// @Summary 创建 Pages 项目
// @Description 创建新的 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body pages.Input true "项目参数"
// @Success 200 {object} response.Any{data=pages.View} "创建成功的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages [post]
func CreateProjectHandler(c *gin.Context) {
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
project, err := CreateProject(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(project))
}
// UpdateProjectHandler 更新 Pages 项目。
// @Summary 更新 Pages 项目
// @Description 按 ID 更新 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param request body pages.Input true "项目参数"
// @Success 200 {object} response.Any{data=pages.View} "更新后的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/update [post]
func UpdateProjectHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
project, err := UpdateProject(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(project))
}
// DeleteProjectHandler 删除 Pages 项目。
// @Summary 删除 Pages 项目
// @Description 按 ID 删除 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/delete [post]
func DeleteProjectHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteProject(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListDeploymentsHandler 列出项目的全部部署。
// @Summary 列出 Pages 部署
// @Description 返回指定项目的全部部署记录,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any{data=[]pages.DeploymentView} "部署列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/deployments [get]
func ListDeploymentsHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
deployments, err := ListProjectDeployments(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(deployments))
}
// UploadDeploymentHandler 上传 Pages 部署包。
// @Summary 上传 Pages 部署包
// @Description 为指定项目上传 ZIP 部署包,需要管理员权限
// @Tags openflare-pages
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param package formData file true "部署包 ZIP 文件"
// @Success 200 {object} response.Any{data=pages.DeploymentView} "部署记录"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/deployments/upload [post]
func UploadDeploymentHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
file, err := c.FormFile("package")
if err != nil {
response.AbortBadRequest(c, errPagesPackageMissing)
return
}
deployment, err := UploadDeployment(c.Request.Context(), id, file, "")
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(deployment))
}
// ActivateDeploymentHandler 激活 Pages 部署。
// @Summary 激活 Pages 部署
// @Description 将指定部署设为项目当前生效版本,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any{data=pages.View} "激活后的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目或部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/deployments/{deployment_id}/activate [post]
func ActivateDeploymentHandler(c *gin.Context) {
projectID, ok := apiutil.IDParam(c)
if !ok {
return
}
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
project, err := ActivateDeployment(c.Request.Context(), projectID, deploymentID)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(project))
}
// DeleteDeploymentHandler 删除 Pages 部署。
// @Summary 删除 Pages 部署
// @Description 删除指定项目的部署记录,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "项目或部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/{id}/deployments/{deployment_id}/delete [post]
func DeleteDeploymentHandler(c *gin.Context) {
projectID, ok := apiutil.IDParam(c)
if !ok {
return
}
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
if err := DeleteDeployment(c.Request.Context(), projectID, deploymentID); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListDeploymentFilesHandler 列出部署文件清单。
// @Summary 列出 Pages 部署文件
// @Description 返回指定部署包含的文件清单,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any{data=[]pages.DeploymentFileView} "部署文件列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/pages/deployments/{deployment_id}/files [get]
func ListDeploymentFilesHandler(c *gin.Context) {
deploymentID, ok := deploymentIDParam(c)
if !ok {
return
}
files, err := ListDeploymentFiles(c.Request.Context(), deploymentID)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(files))
}