910c0fcd76
build / build (push) Failing after 16s
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid, 固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号 - API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态) - worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、 不持有来源凭据 - web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因) - scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
403 lines
19 KiB
Go
403 lines
19 KiB
Go
package appconfig
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
type Config struct {
|
|
DatabaseURL string
|
|
AppAddr string
|
|
WorkerID string
|
|
PollInterval time.Duration
|
|
LeaseDuration time.Duration
|
|
RetryDelay time.Duration
|
|
SourceRefreshSchedulerInterval time.Duration
|
|
ReleaseWorkerEnabled bool
|
|
ReleaseConfigFile string
|
|
ProductionReleaseRequestsEnabled bool
|
|
ProductionBackupRestoreVerified bool
|
|
ProductionReleaseAuthorizerRef string
|
|
ProductionReleaseAuthTokenFile string
|
|
// Production WeKnora endpoint: the release target itself stays declared in
|
|
// the release runtime config (RELEASE_CONFIG_FILE); these settings only let
|
|
// the API probe that endpoint so the console can show its real state.
|
|
ProductionWeKnoraBaseURL string
|
|
ProductionWeKnoraAPIKeyFile string
|
|
ProductionWeKnoraAllowDockerHTTP bool
|
|
PPScanEnabled bool
|
|
PPScanAllowedRoot string
|
|
PPScanLedgerPath string
|
|
PPScanManifestPath string
|
|
TestWeKnoraEnabled bool
|
|
TestWeKnoraAllowDockerHTTP bool
|
|
TestWeKnoraBaseURL string
|
|
MinerUBaseURL string
|
|
TestWeKnoraKBID string
|
|
InternalRoutingKnowledgeBaseID string
|
|
ExternalRoutingKnowledgeBaseID string
|
|
TestWeKnoraAPIKeyFile string
|
|
GovernanceSpoolRoot string
|
|
IngestionSpoolRoot string
|
|
ArtifactFetchMaxBytes int64
|
|
WorkerLivenessStaleAfter time.Duration
|
|
TestWeKnoraHTTPTimeout time.Duration
|
|
TestWeKnoraPollInitial time.Duration
|
|
TestWeKnoraPollMax time.Duration
|
|
TestWeKnoraStalledAfter time.Duration
|
|
TestWeKnoraMaxUploadBytes int64
|
|
TestAcceptancePolicyVersion string
|
|
// TestAutoReviewPolicy:验收通过后是否自动批准(off|internal|all;默认 internal)。
|
|
TestAutoReviewPolicy string
|
|
// 目标库所在主机(整库逻辑备份真正执行的地方)。凭据是挂载进来的 0600 ssh
|
|
// 配置文件与私钥;三项同时给出才接线,缺任一项即"未接线"(页面降级展示)。
|
|
BackupHostSSHConfig string
|
|
BackupHostTarget string
|
|
BackupHostScriptDir string
|
|
BackupHostRoot string
|
|
// 版本记录(「版本记录」页):**匿名只读**远端仓库的提交与 tag。来源留空即"未接线"
|
|
// (页面降级展示,接口返回可用性=false)。只允许配置里批准的 origin 与仓库,
|
|
// 不接受任何调用方传入的 URL;本功能**不使用凭据**。
|
|
VersionHistoryGiteaBaseURL string
|
|
VersionHistoryRepo string
|
|
VersionHistoryAllowedOrigins []string
|
|
// VersionHistoryLinkOrigin 是**页面链接**用的 origin(给内网外的浏览器点开),
|
|
// 留空即与抓取 origin 相同。只用于拼展示链接,不参与任何请求。
|
|
// LibraryArchiveRoot 是整库封存产物的只读归档根(主机私有 0700 目录的只读挂载)。
|
|
// 留空即"未接线":页面只显示原因,不显示空表冒充没有快照。
|
|
LibraryArchiveRoot string
|
|
// LibraryAgentSocket 是主机侧封存 agent 的 unix socket(只由 worker 连接)。
|
|
// 留空即"未接线":页面不显示可用的封存入口,接口返回原因而不是假装已排队。
|
|
LibraryAgentSocket string
|
|
VersionHistoryLinkOrigin string
|
|
VersionHistoryHTTPTimeout time.Duration
|
|
VersionHistoryMaxPages int
|
|
}
|
|
|
|
func Load(getenv func(string) string) (Config, error) {
|
|
config := Config{
|
|
DatabaseURL: getenv("DATABASE_URL"),
|
|
AppAddr: valueOrDefault(getenv("APP_ADDR"), ":8080"),
|
|
WorkerID: valueOrDefault(getenv("WORKER_ID"), "worker-1"),
|
|
PollInterval: time.Second,
|
|
LeaseDuration: 30 * time.Second,
|
|
RetryDelay: 5 * time.Second,
|
|
SourceRefreshSchedulerInterval: 30 * time.Second,
|
|
ReleaseConfigFile: strings.TrimSpace(getenv("RELEASE_CONFIG_FILE")),
|
|
ProductionReleaseAuthorizerRef: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTHORIZER_REF")),
|
|
ProductionReleaseAuthTokenFile: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTH_TOKEN_FILE")),
|
|
ProductionWeKnoraBaseURL: strings.TrimSpace(getenv("PRODUCTION_WEKNORA_BASE_URL")),
|
|
ProductionWeKnoraAPIKeyFile: strings.TrimSpace(getenv("PRODUCTION_WEKNORA_API_KEY_FILE")),
|
|
PPScanAllowedRoot: strings.TrimSpace(getenv("PP_SCAN_ALLOWED_ROOT")),
|
|
PPScanLedgerPath: strings.TrimSpace(getenv("PP_SCAN_LEDGER_PATH")),
|
|
PPScanManifestPath: strings.TrimSpace(getenv("PP_SCAN_MANIFEST_PATH")),
|
|
TestWeKnoraBaseURL: strings.TrimSpace(getenv("TEST_WEKNORA_BASE_URL")),
|
|
MinerUBaseURL: strings.TrimSpace(getenv("MINERU_BASE_URL")),
|
|
TestWeKnoraKBID: strings.TrimSpace(getenv("TEST_WEKNORA_KB_ID")),
|
|
InternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_INTERNAL_WEKNORA_KB_ID")),
|
|
ExternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_EXTERNAL_WEKNORA_KB_ID")),
|
|
TestWeKnoraAPIKeyFile: valueOrDefault(getenv("TEST_WEKNORA_API_KEY_FILE"), "/run/secrets/test_weknora_api_key"),
|
|
GovernanceSpoolRoot: valueOrDefault(getenv("GOVERNANCE_SPOOL_ROOT"), "/var/lib/governance/spool"),
|
|
IngestionSpoolRoot: strings.TrimSpace(getenv("INGESTION_SPOOL_ROOT")),
|
|
ArtifactFetchMaxBytes: 200 * 1024 * 1024,
|
|
WorkerLivenessStaleAfter: 2 * time.Minute,
|
|
TestWeKnoraHTTPTimeout: 60 * time.Second,
|
|
TestWeKnoraPollInitial: 10 * time.Second,
|
|
TestWeKnoraPollMax: time.Minute,
|
|
TestWeKnoraStalledAfter: 30 * time.Minute,
|
|
TestWeKnoraMaxUploadBytes: 50 * 1024 * 1024,
|
|
TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"),
|
|
TestAutoReviewPolicy: valueOrDefault(getenv("TEST_AUTO_REVIEW_POLICY"), string(governance.AutoReviewInternal)),
|
|
// 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。
|
|
BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")),
|
|
BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")),
|
|
BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"),
|
|
BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"),
|
|
VersionHistoryGiteaBaseURL: strings.TrimSpace(getenv("VERSION_HISTORY_GITEA_BASE_URL")),
|
|
VersionHistoryRepo: valueOrDefault(strings.TrimSpace(getenv("VERSION_HISTORY_REPO")), "Weknora/knowledge-governance"),
|
|
VersionHistoryAllowedOrigins: splitOrigins(getenv("VERSION_HISTORY_ALLOWED_ORIGINS")),
|
|
LibraryArchiveRoot: strings.TrimSpace(getenv("LIBRARY_ARCHIVE_ROOT")),
|
|
LibraryAgentSocket: strings.TrimSpace(getenv("LIBRARY_AGENT_SOCKET")),
|
|
VersionHistoryLinkOrigin: strings.TrimSpace(getenv("VERSION_HISTORY_LINK_ORIGIN")),
|
|
VersionHistoryHTTPTimeout: 15 * time.Second,
|
|
VersionHistoryMaxPages: valueOrDefaultInt(getenv("VERSION_HISTORY_MAX_PAGES"), 200),
|
|
}
|
|
if config.DatabaseURL == "" {
|
|
return Config{}, errors.New("DATABASE_URL is required")
|
|
}
|
|
var err error
|
|
if config.PollInterval, err = durationOrDefault(getenv("WORKER_POLL_INTERVAL"), config.PollInterval); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_POLL_INTERVAL: %w", err)
|
|
}
|
|
if config.LeaseDuration, err = durationOrDefault(getenv("WORKER_LEASE_DURATION"), config.LeaseDuration); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_LEASE_DURATION: %w", err)
|
|
}
|
|
if config.RetryDelay, err = durationOrDefault(getenv("WORKER_RETRY_DELAY"), config.RetryDelay); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_RETRY_DELAY: %w", err)
|
|
}
|
|
if config.SourceRefreshSchedulerInterval, err = durationOrDefault(getenv("SOURCE_REFRESH_SCHEDULER_INTERVAL"), config.SourceRefreshSchedulerInterval); err != nil {
|
|
return Config{}, fmt.Errorf("SOURCE_REFRESH_SCHEDULER_INTERVAL: %w", err)
|
|
}
|
|
if config.SourceRefreshSchedulerInterval < 5*time.Second {
|
|
return Config{}, errors.New("SOURCE_REFRESH_SCHEDULER_INTERVAL must be at least 5s")
|
|
}
|
|
if raw := getenv("RELEASE_WORKER_ENABLED"); raw != "" {
|
|
config.ReleaseWorkerEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("RELEASE_WORKER_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if config.ReleaseWorkerEnabled && (!filepath.IsAbs(config.ReleaseConfigFile) || filepath.Clean(config.ReleaseConfigFile) != config.ReleaseConfigFile) {
|
|
return Config{}, errors.New("RELEASE_CONFIG_FILE must be an absolute path when release worker is enabled")
|
|
}
|
|
if raw := getenv("PRODUCTION_RELEASE_REQUESTS_ENABLED"); raw != "" {
|
|
config.ProductionReleaseRequestsEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_RELEASE_REQUESTS_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("PRODUCTION_BACKUP_RESTORE_VERIFIED"); raw != "" {
|
|
config.ProductionBackupRestoreVerified, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_BACKUP_RESTORE_VERIFIED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("PRODUCTION_WEKNORA_ALLOW_DOCKER_HTTP"); raw != "" {
|
|
config.ProductionWeKnoraAllowDockerHTTP, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_WEKNORA_ALLOW_DOCKER_HTTP: %w", err)
|
|
}
|
|
}
|
|
if config.ProductionWeKnoraBaseURL != "" {
|
|
if err := validateHTTPBaseURL(config.ProductionWeKnoraBaseURL); err != nil {
|
|
return Config{}, fmt.Errorf("PRODUCTION_WEKNORA_BASE_URL: %w", err)
|
|
}
|
|
}
|
|
if config.ProductionWeKnoraAPIKeyFile != "" {
|
|
if config.ProductionWeKnoraBaseURL == "" {
|
|
return Config{}, errors.New("PRODUCTION_WEKNORA_API_KEY_FILE requires PRODUCTION_WEKNORA_BASE_URL")
|
|
}
|
|
if !filepath.IsAbs(config.ProductionWeKnoraAPIKeyFile) {
|
|
return Config{}, errors.New("PRODUCTION_WEKNORA_API_KEY_FILE must be an absolute path")
|
|
}
|
|
}
|
|
if raw := getenv("PP_SCAN_ENABLED"); raw != "" {
|
|
config.PPScanEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("PP_SCAN_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("TEST_WEKNORA_ENABLED"); raw != "" {
|
|
config.TestWeKnoraEnabled, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_ENABLED: %w", err)
|
|
}
|
|
}
|
|
if raw := getenv("TEST_WEKNORA_ALLOW_DOCKER_HTTP"); raw != "" {
|
|
config.TestWeKnoraAllowDockerHTTP, err = strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_ALLOW_DOCKER_HTTP: %w", err)
|
|
}
|
|
}
|
|
if config.WorkerLivenessStaleAfter, err = durationOrDefault(getenv("WORKER_LIVENESS_STALE_AFTER"), config.WorkerLivenessStaleAfter); err != nil {
|
|
return Config{}, fmt.Errorf("WORKER_LIVENESS_STALE_AFTER: %w", err)
|
|
}
|
|
if config.VersionHistoryHTTPTimeout, err = durationOrDefault(getenv("VERSION_HISTORY_HTTP_TIMEOUT"), config.VersionHistoryHTTPTimeout); err != nil {
|
|
return Config{}, fmt.Errorf("VERSION_HISTORY_HTTP_TIMEOUT: %w", err)
|
|
}
|
|
// 版本记录来源:给了 origin 就必须是干净的 http(s) origin;白名单留空则默认只许自己。
|
|
if config.VersionHistoryGiteaBaseURL != "" {
|
|
if err := validateHTTPBaseURL(config.VersionHistoryGiteaBaseURL); err != nil {
|
|
return Config{}, fmt.Errorf("VERSION_HISTORY_GITEA_BASE_URL: %w%s", err,
|
|
envHint(config.VersionHistoryGiteaBaseURL))
|
|
}
|
|
if len(config.VersionHistoryAllowedOrigins) == 0 {
|
|
config.VersionHistoryAllowedOrigins = []string{config.VersionHistoryGiteaBaseURL}
|
|
}
|
|
for _, origin := range config.VersionHistoryAllowedOrigins {
|
|
if err := validateHTTPBaseURL(origin); err != nil {
|
|
return Config{}, fmt.Errorf("VERSION_HISTORY_ALLOWED_ORIGINS: %w", err)
|
|
}
|
|
}
|
|
if strings.Count(config.VersionHistoryRepo, "/") != 1 {
|
|
return Config{}, errors.New("VERSION_HISTORY_REPO 必须是 owner/name 形式")
|
|
}
|
|
if config.VersionHistoryLinkOrigin != "" {
|
|
if err := validateHTTPBaseURL(config.VersionHistoryLinkOrigin); err != nil {
|
|
return Config{}, fmt.Errorf("VERSION_HISTORY_LINK_ORIGIN: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if config.LibraryArchiveRoot != "" && !filepath.IsAbs(config.LibraryArchiveRoot) {
|
|
return Config{}, errors.New("LIBRARY_ARCHIVE_ROOT 必须是绝对路径(归档根按只读目录挂载)")
|
|
}
|
|
if config.LibraryAgentSocket != "" && !filepath.IsAbs(config.LibraryAgentSocket) {
|
|
return Config{}, errors.New("LIBRARY_AGENT_SOCKET 必须是绝对路径(unix socket 由主机侧 agent 持有)")
|
|
}
|
|
if config.TestWeKnoraHTTPTimeout, err = durationOrDefault(getenv("TEST_WEKNORA_HTTP_TIMEOUT"), config.TestWeKnoraHTTPTimeout); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_HTTP_TIMEOUT: %w", err)
|
|
}
|
|
if config.TestWeKnoraPollInitial, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_INITIAL"), config.TestWeKnoraPollInitial); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_INITIAL: %w", err)
|
|
}
|
|
if config.TestWeKnoraPollMax, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_MAX"), config.TestWeKnoraPollMax); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_MAX: %w", err)
|
|
}
|
|
if config.TestWeKnoraStalledAfter, err = durationOrDefault(getenv("TEST_WEKNORA_STALLED_AFTER"), config.TestWeKnoraStalledAfter); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_STALLED_AFTER: %w", err)
|
|
}
|
|
if config.TestWeKnoraMaxUploadBytes, err = positiveInt64OrDefault(getenv("TEST_WEKNORA_MAX_UPLOAD_BYTES"), config.TestWeKnoraMaxUploadBytes); err != nil {
|
|
return Config{}, fmt.Errorf("TEST_WEKNORA_MAX_UPLOAD_BYTES: %w", err)
|
|
}
|
|
if config.TestWeKnoraMaxUploadBytes > 50*1024*1024 {
|
|
return Config{}, errors.New("TEST_WEKNORA_MAX_UPLOAD_BYTES must not exceed 50 MiB")
|
|
}
|
|
if config.TestWeKnoraPollMax < config.TestWeKnoraPollInitial {
|
|
return Config{}, errors.New("TEST_WEKNORA_POLL_MAX must not be less than TEST_WEKNORA_POLL_INITIAL")
|
|
}
|
|
if config.TestWeKnoraStalledAfter <= config.TestWeKnoraPollMax {
|
|
return Config{}, errors.New("TEST_WEKNORA_STALLED_AFTER must be greater than TEST_WEKNORA_POLL_MAX")
|
|
}
|
|
config.TestAcceptancePolicyVersion = strings.TrimSpace(config.TestAcceptancePolicyVersion)
|
|
if config.TestAcceptancePolicyVersion == "" {
|
|
return Config{}, errors.New("TEST_ACCEPTANCE_POLICY_VERSION must not be empty")
|
|
}
|
|
// 审核策略:值不认识就拒绝启动,避免"写错一个词 → 静默变成另一种审核强度"。
|
|
normalizedPolicy, err := governance.ParseAutoReviewPolicy(config.TestAutoReviewPolicy)
|
|
if err != nil {
|
|
return Config{}, errors.New("TEST_AUTO_REVIEW_POLICY must be one of off, internal, all")
|
|
}
|
|
config.TestAutoReviewPolicy = string(normalizedPolicy)
|
|
if config.ProductionReleaseRequestsEnabled {
|
|
// 目标主机凭据要么都给要么都不给:只给一半会把"未接线"伪装成"已接线"。
|
|
if (config.BackupHostSSHConfig == "") != (config.BackupHostTarget == "") {
|
|
return Config{}, errors.New("BACKUP_HOST_SSH_CONFIG and BACKUP_HOST_TARGET must be set together")
|
|
}
|
|
if config.ProductionReleaseAuthorizerRef == "" || len(config.ProductionReleaseAuthorizerRef) > 128 || strings.ContainsAny(config.ProductionReleaseAuthorizerRef, "\x00\r\n ") {
|
|
return Config{}, errors.New("PRODUCTION_RELEASE_AUTHORIZER_REF is required and must be safe when production release requests are enabled")
|
|
}
|
|
if config.ProductionReleaseAuthTokenFile == "" || !filepath.IsAbs(config.ProductionReleaseAuthTokenFile) {
|
|
return Config{}, errors.New("PRODUCTION_RELEASE_AUTH_TOKEN_FILE must be an absolute path when production release requests are enabled")
|
|
}
|
|
}
|
|
if config.PPScanEnabled {
|
|
for key, value := range map[string]string{
|
|
"PP_SCAN_ALLOWED_ROOT": config.PPScanAllowedRoot,
|
|
"PP_SCAN_LEDGER_PATH": config.PPScanLedgerPath,
|
|
"PP_SCAN_MANIFEST_PATH": config.PPScanManifestPath,
|
|
} {
|
|
if value == "" {
|
|
return Config{}, fmt.Errorf("%s is required when PP scan is enabled", key)
|
|
}
|
|
if !filepath.IsAbs(value) {
|
|
return Config{}, fmt.Errorf("%s must be absolute", key)
|
|
}
|
|
}
|
|
}
|
|
if config.TestWeKnoraEnabled {
|
|
if config.TestWeKnoraBaseURL == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_BASE_URL is required when test WeKnora is enabled")
|
|
}
|
|
if config.TestWeKnoraKBID == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_KB_ID is required when test WeKnora is enabled")
|
|
}
|
|
if strings.TrimSpace(config.TestWeKnoraAPIKeyFile) == "" {
|
|
return Config{}, errors.New("TEST_WEKNORA_API_KEY_FILE is required when test WeKnora is enabled")
|
|
}
|
|
if strings.TrimSpace(config.GovernanceSpoolRoot) == "" {
|
|
return Config{}, errors.New("GOVERNANCE_SPOOL_ROOT is required when test WeKnora is enabled")
|
|
}
|
|
}
|
|
if (config.InternalRoutingKnowledgeBaseID == "") != (config.ExternalRoutingKnowledgeBaseID == "") {
|
|
return Config{}, errors.New("both internal and external routing knowledge bases must be configured together")
|
|
}
|
|
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
|
|
if len(knowledgeBaseID) > 128 || strings.ContainsAny(knowledgeBaseID, "\x00\r\n /?#\\") {
|
|
return Config{}, errors.New("routing knowledge base ID is invalid")
|
|
}
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
// validateHTTPBaseURL accepts only a bare http(s) origin: no credentials, query
|
|
// or fragment, so a probe address can never smuggle in secrets. The rule itself
|
|
// lives with the domain type because the web-configured endpoint must obey the
|
|
// exact same shape.
|
|
func validateHTTPBaseURL(value string) error {
|
|
if !governance.ValidHTTPOrigin(value) {
|
|
return errors.New("must be an http(s) origin without credentials, query or fragment")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// envHint 给"值明显不像一个地址"的情况补一句线索:最常见的成因是 `.env` 里两行粘在一起
|
|
// (新键被追加到没有结尾换行的上一行后面)—— 值里就会出现第二个 `KEY=`。
|
|
func envHint(value string) string {
|
|
if strings.Contains(value, "=") {
|
|
return "(值里出现了 `=`:`.env` 里两行可能粘成了一行,改成一行一值再上线)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// splitOrigins 解析逗号分隔的 origin 白名单(空项丢弃)。
|
|
func splitOrigins(value string) []string {
|
|
parts := strings.Split(value, ",")
|
|
origins := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
origins = append(origins, trimmed)
|
|
}
|
|
}
|
|
return origins
|
|
}
|
|
|
|
// valueOrDefaultInt 解析正整数(非法/缺省都退回默认值)。
|
|
func valueOrDefaultInt(value string, fallback int) int {
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func valueOrDefault(value, fallback string) string {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func durationOrDefault(value string, fallback time.Duration) (time.Duration, error) {
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if parsed <= 0 {
|
|
return 0, errors.New("must be positive")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func positiveInt64OrDefault(value string, fallback int64) (int64, error) {
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if parsed <= 0 {
|
|
return 0, errors.New("must be positive")
|
|
}
|
|
return parsed, nil
|
|
}
|