package user import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // SaveOwnerProfile writes p to path as YAML, atomically (temp file + rename) with // mode 0600 and lenient parent-directory creation, matching the runtime config // convention (SaveChannels). The whole struct is marshaled, so curated fields // (notes, identityFile, displayName, ...) survive a round-trip. YAML comments in // the existing file are not part of the struct and are dropped by the encode, // consistent with the project's other config writers. func SaveOwnerProfile(path string, p *OwnerProfile) error { if p == nil { return fmt.Errorf("user: save owner profile: empty profile") } if strings.TrimSpace(path) == "" { return fmt.Errorf("user: save owner profile: empty path") } data, err := yaml.Marshal(p) if err != nil { return fmt.Errorf("user: encode %s: %w", path, err) } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("user: create %s: %w", dir, err) } tmp, err := os.CreateTemp(dir, ".owner-*.yaml") if err != nil { return fmt.Errorf("user: temp file in %s: %w", dir, err) } tmpName := tmp.Name() closed := false defer func() { _ = tmp.Close() if !closed { _ = os.Remove(tmpName) } }() // CreateTemp opens with 0600; chmod explicitly so the on-disk file never // carries a broader mode even if the umask changes. if err := tmp.Chmod(0o600); err != nil { return fmt.Errorf("user: chmod %s: %w", tmpName, err) } if _, err := tmp.Write(data); err != nil { return fmt.Errorf("user: write %s: %w", tmpName, err) } if err := tmp.Sync(); err != nil { return fmt.Errorf("user: sync %s: %w", tmpName, err) } if err := tmp.Close(); err != nil { return fmt.Errorf("user: close %s: %w", tmpName, err) } closed = true if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("user: rename to %s: %w", path, err) } // Best-effort dir fsync so the rename is durable across a crash. if d, derr := os.Open(dir); derr == nil { _ = d.Sync() _ = d.Close() } return nil } // Replace 把 src 的**落盘字段**(owner.yaml 的那些)原地装进 p,**保留 p 的指针 // 身份与互斥量**。 // // 这是「后台面板改主人白名单立即生效」的实现方式:渠道 bridge 在启动时就捕获 // 了同一个 *OwnerProfile 指针并用它解析 actor(见 IsOwner),换指针则那些组件 // 仍看着旧档案。内存态来源(例如每渠道配置在热重载时带进来的层)不在这里的字段 // 之列,因此不会被一次面板保存抹掉。accounts 取副本,调用方之后改动 src 不会 // 影响活档案。它通过 mu 与 IsOwner/RegisterAccount 并发安全;src 为 nil 时是安全 // 的空操作。 func (p *OwnerProfile) Replace(src *OwnerProfile) { if p == nil || src == nil { return } p.mu.Lock() defer p.mu.Unlock() p.ID = src.ID p.DisplayName = src.DisplayName p.DefaultPersona = src.DefaultPersona p.IdentityFile = src.IdentityFile p.Notes = src.Notes p.Accounts = append([]OwnerAccount(nil), src.Accounts...) } // RegisterOwnerAccount loads the owner whitelist at path, appends (platform, // accountID) when it is not already present (idempotent), and writes the updated // profile back. It returns an error when the profile cannot be loaded or saved. // The on-disk replace is atomic, so a concurrent reader never sees a partial // file. Hot-reload of a running owner profile is the caller's concern (see // cmd/cyrene's ownerRegistrar), which applies RegisterAccount to the live profile // the channel bridge already holds. func RegisterOwnerAccount(path, platform, accountID string) error { if strings.TrimSpace(platform) == "" { return fmt.Errorf("user: register owner account: empty platform") } if strings.TrimSpace(accountID) == "" { return fmt.Errorf("user: register owner account: empty account id") } p, err := LoadOwnerProfile(path) if err != nil { return fmt.Errorf("user: load %s: %w", path, err) } p.RegisterAccount(platform, accountID) return SaveOwnerProfile(path, p) }