mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
943818f7d4
将 OpenFlare 与平台业务的数据访问从 model 与 apps 直连迁入 repository, model 仅保留实体与无 IO 规则;补充 code-check 架构守卫与开发规范。
517 lines
18 KiB
Go
517 lines
18 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package pages
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
// PagesSourceTypeManual represents projects without a persisted source row.
|
|
PagesSourceTypeManual = "manual"
|
|
// PagesSourceTypeRemoteURL represents a persisted artifact URL.
|
|
PagesSourceTypeRemoteURL = "remote_url"
|
|
// PagesSourceTypeGitHubRelease represents a public GitHub Release asset.
|
|
PagesSourceTypeGitHubRelease = "github_release"
|
|
|
|
pagesSourceStatusIdle = "idle"
|
|
pagesSourceStatusChecking = "checking"
|
|
pagesSourceStatusUpdateAvailable = "update_available"
|
|
pagesSourceStatusSyncing = "syncing"
|
|
pagesSourceStatusFailed = "failed"
|
|
pagesSourceStatusAttention = "attention"
|
|
|
|
defaultRemoteAssetLabel = "pages-package"
|
|
defaultGitHubAssetName = "dist.zip"
|
|
defaultCheckInterval = 1440
|
|
minimumCheckInterval = 5
|
|
maximumCheckInterval = 1440
|
|
)
|
|
|
|
// SourceUpdateInput is the discriminated source configuration payload.
|
|
// GitHub fields are accepted by the decoder so mode-incompatible values can be
|
|
// rejected deterministically.
|
|
type SourceUpdateInput struct {
|
|
SourceType string `json:"source_type"`
|
|
RemoteURL string `json:"remote_url"`
|
|
AllowInsecure bool `json:"allow_insecure"`
|
|
RepositoryURL string `json:"repository_url"`
|
|
ReleaseSelector string `json:"release_selector"`
|
|
ReleaseTag string `json:"release_tag"`
|
|
AssetName string `json:"asset_name"`
|
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
|
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
|
}
|
|
|
|
// SourceRevisionView is a source cursor shown to the console.
|
|
type SourceRevisionView struct {
|
|
Revision string `json:"revision"`
|
|
Label string `json:"label"`
|
|
AssetName string `json:"asset_name,omitempty"`
|
|
}
|
|
|
|
// SourceView is the discriminated source view returned to the console.
|
|
type SourceView struct {
|
|
SourceType string `json:"source_type"`
|
|
RemoteURL string `json:"remote_url,omitempty"`
|
|
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
|
GitHubRepository string `json:"github_repository,omitempty"`
|
|
ReleaseSelector string `json:"release_selector,omitempty"`
|
|
ReleaseTag string `json:"release_tag,omitempty"`
|
|
AssetName string `json:"asset_name,omitempty"`
|
|
AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"`
|
|
CheckIntervalMinutes int `json:"check_interval_minutes,omitempty"`
|
|
SyncStatus string `json:"sync_status,omitempty"`
|
|
UpdateAvailable bool `json:"update_available,omitempty"`
|
|
LastSeen *SourceRevisionView `json:"last_seen,omitempty"`
|
|
LastApplied *SourceRevisionView `json:"last_applied,omitempty"`
|
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
|
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
|
NextCheckAt *time.Time `json:"next_check_at,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// SourceActionReceipt identifies the internal task execution created by an action API.
|
|
type SourceActionReceipt struct {
|
|
TaskID string `json:"task_id"`
|
|
ExecutionID string `json:"execution_id"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
// SourceUpdateResult is returned after persisting source configuration.
|
|
type SourceUpdateResult struct {
|
|
Source *SourceView `json:"source"`
|
|
CheckTask *SourceActionReceipt `json:"check_task"`
|
|
Warning string `json:"warning"`
|
|
}
|
|
|
|
type sourceDetail struct {
|
|
Provider string `json:"provider"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Tag string `json:"tag,omitempty"`
|
|
LegacyLabel string `json:"label,omitempty"`
|
|
AssetName string `json:"asset_name,omitempty"`
|
|
ReleaseID string `json:"release_id,omitempty"`
|
|
AssetID string `json:"asset_id,omitempty"`
|
|
AssetUpdatedAt string `json:"asset_updated_at,omitempty"`
|
|
Digest string `json:"digest,omitempty"`
|
|
}
|
|
|
|
type remoteSourceConfig struct {
|
|
URL string
|
|
AllowInsecure bool
|
|
Identity string
|
|
}
|
|
|
|
// GetSource returns the current persisted source or a manual discriminator.
|
|
func GetSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
|
if _, err := repository.GetPagesProjectByID(ctx, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
source, runtime, err := loadSourceByProject(ctx, projectID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return &SourceView{SourceType: PagesSourceTypeManual}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildSourceView(source, runtime)
|
|
}
|
|
|
|
// UpdateSource creates or updates a source. Direct callers use the system actor;
|
|
// HTTP handlers should call UpdateSourceAs so the initial check is auditable.
|
|
func UpdateSource(ctx context.Context, projectID uint, input SourceUpdateInput) (*SourceUpdateResult, error) {
|
|
return UpdateSourceAs(ctx, projectID, input, pagesSourceCreatedBySystem)
|
|
}
|
|
|
|
// UpdateSourceAs persists source configuration and queues the first GitHub check
|
|
// after commit when the GitHub configuration was materially changed.
|
|
func UpdateSourceAs(
|
|
ctx context.Context,
|
|
projectID uint,
|
|
input SourceUpdateInput,
|
|
actor string,
|
|
) (*SourceUpdateResult, error) {
|
|
if !validPagesSourceActor(actor) {
|
|
return nil, errors.New(errPagesSourceActionInvalid)
|
|
}
|
|
if err := validateSourceUpdateInput(input); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
changed := false
|
|
var persistedSource model.PagesProjectSource
|
|
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
|
var err error
|
|
switch strings.TrimSpace(input.SourceType) {
|
|
case PagesSourceTypeRemoteURL:
|
|
changed, err = updateRemoteSourceTx(tx, projectID, input)
|
|
case PagesSourceTypeGitHubRelease:
|
|
changed, err = updateGitHubSourceTx(tx, projectID, input)
|
|
default:
|
|
err = errors.New(errPagesSourceTypeUnsupported)
|
|
}
|
|
if err != nil || !changed || strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
|
return err
|
|
}
|
|
source, loadErr := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
persistedSource = *source
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
view, err := GetSource(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &SourceUpdateResult{Source: view, Warning: ""}
|
|
if changed && strings.TrimSpace(input.SourceType) == PagesSourceTypeGitHubRelease {
|
|
receipt, dispatchErr := dispatchSourceActionSnapshot(ctx, persistedSource, sourceActionCheck, actor, "", "", "manual")
|
|
if dispatchErr != nil {
|
|
result.Warning = errPagesSourceInitialCheckWarning
|
|
markInitialCheckDispatchFailed(ctx, persistedSource.ID, persistedSource.ConfigVersion)
|
|
} else {
|
|
result.CheckTask = receipt
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func updateRemoteSourceTx(tx *gorm.DB, projectID uint, input SourceUpdateInput) (bool, error) {
|
|
if _, err := repository.LockPagesProjectByIDTx(tx, projectID); err != nil {
|
|
return false, err
|
|
}
|
|
existing, hasExisting, err := loadProjectSourceForUpdate(tx, projectID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
config, err := buildRemoteSourceConfig(existing, hasExisting, input)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !hasExisting {
|
|
return true, createRemoteSourceTx(tx, projectID, config)
|
|
}
|
|
return updateExistingRemoteSourceTx(tx, existing, config)
|
|
}
|
|
|
|
func loadProjectSourceForUpdate(tx *gorm.DB, projectID uint) (*model.PagesProjectSource, bool, error) {
|
|
source, err := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return &model.PagesProjectSource{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return source, true, nil
|
|
}
|
|
|
|
func buildRemoteSourceConfig(
|
|
_ *model.PagesProjectSource,
|
|
_ bool,
|
|
input SourceUpdateInput,
|
|
) (remoteSourceConfig, error) {
|
|
remoteURL := strings.TrimSpace(input.RemoteURL)
|
|
if remoteURL == "" {
|
|
return remoteSourceConfig{}, errors.New(errPagesSourceRemoteURLRequired)
|
|
}
|
|
parsedURL, err := parseRemoteSourceURL(remoteURL)
|
|
if err != nil {
|
|
return remoteSourceConfig{}, err
|
|
}
|
|
return remoteSourceConfig{
|
|
URL: remoteURL,
|
|
AllowInsecure: input.AllowInsecure,
|
|
Identity: remoteSourceIdentity(parsedURL),
|
|
}, nil
|
|
}
|
|
|
|
func createRemoteSourceTx(tx *gorm.DB, projectID uint, config remoteSourceConfig) error {
|
|
source := &model.PagesProjectSource{
|
|
ProjectID: projectID,
|
|
SourceType: PagesSourceTypeRemoteURL,
|
|
RemoteURL: config.URL,
|
|
AllowInsecure: config.AllowInsecure,
|
|
AutoUpdateEnabled: false,
|
|
CheckIntervalMinutes: 0,
|
|
ConfigVersion: 1,
|
|
SourceIdentity: config.Identity,
|
|
}
|
|
if err := repository.CreatePagesProjectSourceTx(tx, source); err != nil {
|
|
return err
|
|
}
|
|
return repository.CreatePagesProjectSourceRuntimeTx(tx, &model.PagesProjectSourceRuntime{
|
|
SourceID: source.ID,
|
|
SyncStatus: pagesSourceStatusIdle,
|
|
})
|
|
}
|
|
|
|
func updateExistingRemoteSourceTx(
|
|
tx *gorm.DB,
|
|
existing *model.PagesProjectSource,
|
|
config remoteSourceConfig,
|
|
) (bool, error) {
|
|
if !remoteSourceConfigChanged(existing, config) {
|
|
return false, nil
|
|
}
|
|
runtime, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, existing.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
identityChanged := existing.SourceIdentity != config.Identity
|
|
if err := repository.UpdatePagesProjectSourceTx(tx, existing, map[string]any{
|
|
"source_type": PagesSourceTypeRemoteURL,
|
|
"remote_url": config.URL,
|
|
"allow_insecure": config.AllowInsecure,
|
|
"github_repository": "",
|
|
"release_selector": "",
|
|
"release_tag": "",
|
|
"asset_name": "",
|
|
sourceColumnAutoUpdateEnabled: false,
|
|
"check_interval_minutes": 0,
|
|
sourceColumnConfigVersion: existing.ConfigVersion + 1,
|
|
"source_identity": config.Identity,
|
|
}); err != nil {
|
|
return false, err
|
|
}
|
|
return true, resetRuntimeAfterSourceUpdate(tx, runtime, identityChanged)
|
|
}
|
|
|
|
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
|
return existing.SourceType != PagesSourceTypeRemoteURL ||
|
|
existing.RemoteURL != config.URL ||
|
|
existing.AllowInsecure != config.AllowInsecure ||
|
|
existing.GitHubRepository != "" ||
|
|
existing.ReleaseSelector != "" ||
|
|
existing.ReleaseTag != "" ||
|
|
existing.AssetName != "" ||
|
|
existing.AutoUpdateEnabled ||
|
|
existing.CheckIntervalMinutes != 0
|
|
}
|
|
|
|
// DeleteSource idempotently switches a project back to manual mode.
|
|
func DeleteSource(ctx context.Context, projectID uint) (*SourceView, error) {
|
|
err := repository.WithPagesTx(ctx, func(tx *gorm.DB) error {
|
|
if _, err := repository.LockPagesProjectByIDTx(tx, projectID); err != nil {
|
|
return err
|
|
}
|
|
source, err := repository.LockPagesProjectSourceByProjectIDTx(tx, projectID)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := repository.LockPagesProjectSourceRuntimeBySourceIDTx(tx, source.ID); err != nil &&
|
|
!errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
if err := repository.DeletePagesProjectSourceRuntimeBySourceIDTx(tx, source.ID); err != nil {
|
|
return err
|
|
}
|
|
return repository.DeletePagesProjectSourceTx(tx, source)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SourceView{SourceType: PagesSourceTypeManual}, nil
|
|
}
|
|
|
|
func validateRemoteSourceInput(input SourceUpdateInput) error {
|
|
sourceType := strings.TrimSpace(input.SourceType)
|
|
if sourceType == "" {
|
|
return errors.New(errPagesSourceTypeRequired)
|
|
}
|
|
if sourceType != PagesSourceTypeRemoteURL {
|
|
return errors.New(errPagesSourceTypeUnsupported)
|
|
}
|
|
if strings.TrimSpace(input.RepositoryURL) != "" || strings.TrimSpace(input.ReleaseSelector) != "" ||
|
|
strings.TrimSpace(input.ReleaseTag) != "" || strings.TrimSpace(input.AssetName) != "" ||
|
|
input.AutoUpdateEnabled || input.CheckIntervalMinutes != 0 {
|
|
return errors.New(errPagesSourceRemoteFields)
|
|
}
|
|
if strings.TrimSpace(input.RemoteURL) == "" {
|
|
return errors.New(errPagesSourceRemoteURLRequired)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSourceUpdateInput(input SourceUpdateInput) error {
|
|
switch strings.TrimSpace(input.SourceType) {
|
|
case PagesSourceTypeRemoteURL:
|
|
return validateRemoteSourceInput(input)
|
|
case PagesSourceTypeGitHubRelease:
|
|
return validateGitHubSourceInput(input)
|
|
case "":
|
|
return errors.New(errPagesSourceTypeRequired)
|
|
default:
|
|
return errors.New(errPagesSourceTypeUnsupported)
|
|
}
|
|
}
|
|
|
|
func parseRemoteSourceURL(raw string) (*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
|
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
|
}
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
|
if parsed.Scheme != remoteSourceSchemeHTTP && parsed.Scheme != remoteSourceSchemeHTTPS {
|
|
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
|
}
|
|
if strings.TrimSpace(parsed.Hostname()) == "" {
|
|
return nil, errors.New(errPagesSourceRemoteURLInvalid)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func remoteSourceIdentity(parsed *url.URL) string {
|
|
hostname := strings.ToLower(parsed.Hostname())
|
|
port := parsed.Port()
|
|
if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") {
|
|
port = ""
|
|
}
|
|
host := hostname
|
|
if port != "" {
|
|
host = net.JoinHostPort(hostname, port)
|
|
} else if strings.Contains(hostname, ":") {
|
|
host = "[" + hostname + "]"
|
|
}
|
|
canonicalPath := parsed.EscapedPath()
|
|
if canonicalPath == "" {
|
|
canonicalPath = "/"
|
|
}
|
|
canonicalPath = path.Clean("/" + strings.TrimPrefix(canonicalPath, "/"))
|
|
canonical := parsed.Scheme + "://" + host + canonicalPath
|
|
sum := sha256.Sum256([]byte("remote_url|" + canonical))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func loadSourceByProject(ctx context.Context, projectID uint) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime, error) {
|
|
return repository.GetPagesProjectSourceAndRuntimeByProjectID(ctx, projectID)
|
|
}
|
|
|
|
func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProjectSourceRuntime) (*SourceView, error) {
|
|
if source == nil || runtime == nil {
|
|
return nil, errors.New(errPagesSourceNotFound)
|
|
}
|
|
view := &SourceView{
|
|
SourceType: source.SourceType,
|
|
SyncStatus: runtime.SyncStatus,
|
|
LastSyncedAt: runtime.LastSyncedAt,
|
|
LastError: runtime.LastError,
|
|
}
|
|
if runtime.LastAppliedRevision != "" {
|
|
view.LastApplied = revisionView(runtime.LastAppliedRevision, runtime.LastAppliedDetail)
|
|
}
|
|
switch source.SourceType {
|
|
case PagesSourceTypeRemoteURL:
|
|
view.RemoteURL = source.RemoteURL
|
|
view.AllowInsecure = source.AllowInsecure
|
|
case PagesSourceTypeGitHubRelease:
|
|
view.LastCheckedAt = runtime.LastCheckedAt
|
|
view.NextCheckAt = runtime.NextCheckAt
|
|
view.UpdateAvailable = runtime.LastSeenRevision != "" && runtime.LastSeenRevision != runtime.LastAppliedRevision
|
|
if runtime.LastSeenRevision != "" {
|
|
view.LastSeen = revisionView(runtime.LastSeenRevision, runtime.LastSeenDetail)
|
|
}
|
|
view.GitHubRepository = source.GitHubRepository
|
|
view.ReleaseSelector = source.ReleaseSelector
|
|
view.ReleaseTag = source.ReleaseTag
|
|
view.AssetName = source.AssetName
|
|
autoUpdateEnabled := source.AutoUpdateEnabled
|
|
view.AutoUpdateEnabled = &autoUpdateEnabled
|
|
view.CheckIntervalMinutes = source.CheckIntervalMinutes
|
|
default:
|
|
return nil, errors.New(errPagesSourceTypeUnsupported)
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
func revisionView(revision string, detailJSON string) *SourceRevisionView {
|
|
detail := sourceDetail{}
|
|
_ = unmarshalSourceDetail(detailJSON, &detail)
|
|
label := sourceDetailLabel(detail)
|
|
if label == "" {
|
|
label = defaultRemoteAssetLabel
|
|
}
|
|
return &SourceRevisionView{
|
|
Revision: revision,
|
|
Label: label,
|
|
AssetName: detail.AssetName,
|
|
}
|
|
}
|
|
|
|
func sourceDetailLabel(detail sourceDetail) string {
|
|
if detail.Provider == githubSourceDetailProvider {
|
|
if label := strings.TrimSpace(detail.Tag); label != "" {
|
|
return label
|
|
}
|
|
return strings.TrimSpace(detail.LegacyLabel)
|
|
}
|
|
if label := strings.TrimSpace(detail.DisplayName); label != "" {
|
|
return label
|
|
}
|
|
return strings.TrimSpace(detail.LegacyLabel)
|
|
}
|
|
|
|
func unmarshalSourceDetail(raw string, detail *sourceDetail) error {
|
|
if detail == nil || strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
return json.Unmarshal([]byte(raw), detail)
|
|
}
|
|
|
|
func resetRuntimeAfterSourceUpdate(tx *gorm.DB, runtime *model.PagesProjectSourceRuntime, identityChanged bool) error {
|
|
updates := map[string]any{
|
|
sourceRuntimeColumnLeaseToken: "",
|
|
sourceRuntimeColumnLeaseExpiresAt: nil,
|
|
sourceRuntimeColumnLastError: "",
|
|
}
|
|
if identityChanged {
|
|
updates["etag"] = ""
|
|
updates["last_seen_revision"] = ""
|
|
updates["last_seen_detail"] = ""
|
|
updates["last_applied_revision"] = ""
|
|
updates["last_applied_detail"] = ""
|
|
updates["last_checked_at"] = nil
|
|
updates["last_synced_at"] = nil
|
|
updates["next_check_at"] = nil
|
|
updates[sourceRuntimeColumnSyncStatus] = pagesSourceStatusIdle
|
|
} else {
|
|
updates[sourceRuntimeColumnSyncStatus] = normalizedSourceRuntimeStatus(runtime)
|
|
}
|
|
return repository.UpdatePagesProjectSourceRuntimeTx(tx, runtime, updates)
|
|
}
|
|
|
|
func normalizedSourceRuntimeStatus(runtime *model.PagesProjectSourceRuntime) string {
|
|
if runtime == nil {
|
|
return pagesSourceStatusIdle
|
|
}
|
|
if sourceHasSameReleaseReplacement(runtime) {
|
|
return pagesSourceStatusAttention
|
|
}
|
|
if runtime.LastSeenRevision != "" && runtime.LastSeenRevision != runtime.LastAppliedRevision {
|
|
return pagesSourceStatusUpdateAvailable
|
|
}
|
|
return pagesSourceStatusIdle
|
|
}
|