Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

graft-source / internal / config / config.go
7.4 kB 198 lines
1// Package config loads and validates the sync daemon's configuration. 2package config 3 4import ( 5 "fmt" 6 "os" 7 "time" 8 9 "gopkg.in/yaml.v3" 10) 11 12// Config is the top-level configuration for the sync daemon. 13type Config struct { 14 SyncInterval time.Duration `yaml:"sync_interval"` 15 StateDB string `yaml:"state_db"` 16 Repos []RepoPair `yaml:"repos"` 17 // SourceURL is graft's own source repository, shown in the status 18 // dashboard's footer. Optional. 19 SourceURL string `yaml:"source_url"` 20 // PublicHost is the hostname graft is reachable at (e.g. 21 // graft.cyberwild.org), used to build ActivityPub actor/acct URIs. 22 // Required only if any series should be reachable over ActivityPub. 23 PublicHost string `yaml:"public_host"` 24 // AdminPassword gates the dashboard's self-service actions (adding a 25 // peer, starting a new repo) — anyone who knows it can use them. 26 // Defaults to "graft" if unset; Load logs a warning when that default 27 // is still in effect, since it's meant to be changed. 28 AdminPassword string `yaml:"admin_password"` 29} 30 31// RepoPair links one Forgejo repository to either one Radicle repository or 32// a second Forgejo repository, and declares which content types are kept in 33// sync between them. Exactly one of Radicle and ForgejoMirror is set: the 34// Radicle case is the original, fully-featured pairing (git, issues, 35// patches); the ForgejoMirror case is a plain Forgejo-to-Forgejo git mirror 36// for series that have no Radicle side at all — issues and patches aren't 37// supported there yet, since that mirroring logic is written entirely in 38// terms of a Radicle client (see internal/sync/issues.go, patches.go). 39type RepoPair struct { 40 Name string `yaml:"name"` 41 Forgejo ForgejoTarget `yaml:"forgejo"` 42 Radicle *RadicleTarget `yaml:"radicle"` 43 ForgejoMirror *ForgejoTarget `yaml:"forgejo_mirror"` 44 Sync SyncScope `yaml:"sync"` 45 // Series groups this pair with others that mirror the same underlying 46 // repository (one per forge/Radicle side) under one row in the status 47 // dashboard. Defaults to Name — i.e. its own row — if unset. 48 Series string `yaml:"series"` 49 // Bluesky, if set, posts a short note to this account whenever this 50 // pair mirrors a new commit or opens a patch/PR. Optional: nil means 51 // this pair never posts to Bluesky. 52 Bluesky *BlueskyTarget `yaml:"bluesky"` 53} 54 55// BlueskyTarget identifies the Bluesky account to post mirror activity to. 56// AppPasswordFile follows the same convention as ForgejoTarget.TokenFile — 57// an app password (never the account's real password), read from a 58// separate chmod-600 file so the config itself holds no secrets. 59type BlueskyTarget struct { 60 Handle string `yaml:"handle"` 61 AppPasswordFile string `yaml:"app_password_file"` 62 // PDSHost is the XRPC endpoint to talk to. Optional: defaults to 63 // https://bsky.social, correct for any account hosted there (the 64 // common case). 65 PDSHost string `yaml:"pds_host"` 66} 67 68// ForgejoTarget identifies a repository on a Forgejo instance and where to 69// read its access token from. The token is never stored inline in the 70// config file so the file itself can be handled as non-sensitive. 71type ForgejoTarget struct { 72 BaseURL string `yaml:"base_url"` 73 Owner string `yaml:"owner"` 74 Repo string `yaml:"repo"` 75 TokenFile string `yaml:"token_file"` 76} 77 78// RadicleTarget identifies a repository on a Radicle node. HTTPBaseURL is 79// that node's radicle-httpd endpoint (e.g. https://r1.cyberwild.org), used 80// for reads; RadHome is the local RAD_HOME whose keys the "rad" CLI uses 81// for writes — the two are only the same node when the daemon runs 82// alongside it, which is not assumed here. 83type RadicleTarget struct { 84 RID string `yaml:"rid"` 85 HTTPBaseURL string `yaml:"http_base_url"` 86 RadHome string `yaml:"rad_home"` 87 // ExplorerURL is a Radicle Explorer deployment (e.g. 88 // https://app.radicle.xyz, or a self-hosted one) used to build "view 89 // this issue/patch" links in the status dashboard. Optional: links are 90 // omitted if unset. 91 ExplorerURL string `yaml:"explorer_url"` 92} 93 94// SyncScope selects which content types are synchronized for a repo pair. 95type SyncScope struct { 96 Git bool `yaml:"git"` 97 Issues bool `yaml:"issues"` 98 Patches bool `yaml:"patches"` 99} 100 101// Load reads and validates the configuration file at path. 102func Load(path string) (*Config, error) { 103 data, err := os.ReadFile(path) 104 if err != nil { 105 return nil, fmt.Errorf("read config: %w", err) 106 } 107 108 var cfg Config 109 if err := yaml.Unmarshal(data, &cfg); err != nil { 110 return nil, fmt.Errorf("parse config: %w", err) 111 } 112 113 if cfg.SyncInterval <= 0 { 114 cfg.SyncInterval = 5 * time.Minute 115 } 116 if cfg.AdminPassword == "" { 117 cfg.AdminPassword = "graft" 118 } 119 if cfg.StateDB == "" { 120 return nil, fmt.Errorf("state_db must be set") 121 } 122 if len(cfg.Repos) == 0 { 123 return nil, fmt.Errorf("no repos configured") 124 } 125 for i, r := range cfg.Repos { 126 if err := r.validate(); err != nil { 127 return nil, fmt.Errorf("repos[%d] (%s): %w", i, r.Name, err) 128 } 129 } 130 131 return &cfg, nil 132} 133 134func (r RepoPair) validate() error { 135 if r.Name == "" { 136 return fmt.Errorf("name is required") 137 } 138 if r.Forgejo.BaseURL == "" || r.Forgejo.Owner == "" || r.Forgejo.Repo == "" { 139 return fmt.Errorf("forgejo.base_url, owner and repo are required") 140 } 141 if r.Forgejo.TokenFile == "" { 142 return fmt.Errorf("forgejo.token_file is required") 143 } 144 if r.Radicle == nil && r.ForgejoMirror == nil { 145 return fmt.Errorf("exactly one of radicle or forgejo_mirror is required") 146 } 147 if r.Radicle != nil && r.ForgejoMirror != nil { 148 return fmt.Errorf("radicle and forgejo_mirror are mutually exclusive") 149 } 150 if r.Radicle != nil { 151 if r.Radicle.RID == "" { 152 return fmt.Errorf("radicle.rid is required") 153 } 154 if r.Radicle.HTTPBaseURL == "" { 155 return fmt.Errorf("radicle.http_base_url is required") 156 } 157 if r.Radicle.RadHome == "" { 158 return fmt.Errorf("radicle.rad_home is required") 159 } 160 } 161 if r.ForgejoMirror != nil { 162 if r.ForgejoMirror.BaseURL == "" || r.ForgejoMirror.Owner == "" || r.ForgejoMirror.Repo == "" { 163 return fmt.Errorf("forgejo_mirror.base_url, owner and repo are required") 164 } 165 if r.ForgejoMirror.TokenFile == "" { 166 return fmt.Errorf("forgejo_mirror.token_file is required") 167 } 168 if r.Sync.Issues || r.Sync.Patches { 169 return fmt.Errorf("forgejo_mirror pairs only support sync.git — issues/patches mirroring needs a Radicle side") 170 } 171 } 172 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches { 173 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true") 174 } 175 if r.Bluesky != nil { 176 if r.Bluesky.Handle == "" || r.Bluesky.AppPasswordFile == "" { 177 return fmt.Errorf("bluesky.handle and app_password_file are required when bluesky is set") 178 } 179 } 180 return nil 181} 182 183// ReadToken reads a token from a file, trimming surrounding whitespace. 184// Token files are expected to be chmod 600 and owned by the service user. 185func ReadToken(path string) (string, error) { 186 data, err := os.ReadFile(path) 187 if err != nil { 188 return "", fmt.Errorf("read token file %s: %w", path, err) 189 } 190 token := string(data) 191 for len(token) > 0 && (token[len(token)-1] == '\n' || token[len(token)-1] == '\r' || token[len(token)-1] == ' ') { 192 token = token[:len(token)-1] 193 } 194 if token == "" { 195 return "", fmt.Errorf("token file %s is empty", path) 196 } 197 return token, nil 198}