Federated via graft
0

Configure Feed

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

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 one Radicle repository and 32// declares which content types are kept in sync between them. 33type RepoPair struct { 34 Name string `yaml:"name"` 35 Forgejo ForgejoTarget `yaml:"forgejo"` 36 Radicle RadicleTarget `yaml:"radicle"` 37 Sync SyncScope `yaml:"sync"` 38 // Series groups this pair with others that mirror the same underlying 39 // repository (one per forge/Radicle side) under one row in the status 40 // dashboard. Defaults to Name — i.e. its own row — if unset. 41 Series string `yaml:"series"` 42 // Bluesky, if set, posts a short note to this account whenever this 43 // pair mirrors a new commit or opens a patch/PR. Optional: nil means 44 // this pair never posts to Bluesky. 45 Bluesky *BlueskyTarget `yaml:"bluesky"` 46} 47 48// BlueskyTarget identifies the Bluesky account to post mirror activity to. 49// AppPasswordFile follows the same convention as ForgejoTarget.TokenFile — 50// an app password (never the account's real password), read from a 51// separate chmod-600 file so the config itself holds no secrets. 52type BlueskyTarget struct { 53 Handle string `yaml:"handle"` 54 AppPasswordFile string `yaml:"app_password_file"` 55 // PDSHost is the XRPC endpoint to talk to. Optional: defaults to 56 // https://bsky.social, correct for any account hosted there (the 57 // common case). 58 PDSHost string `yaml:"pds_host"` 59} 60 61// ForgejoTarget identifies a repository on a Forgejo instance and where to 62// read its access token from. The token is never stored inline in the 63// config file so the file itself can be handled as non-sensitive. 64type ForgejoTarget struct { 65 BaseURL string `yaml:"base_url"` 66 Owner string `yaml:"owner"` 67 Repo string `yaml:"repo"` 68 TokenFile string `yaml:"token_file"` 69} 70 71// RadicleTarget identifies a repository on a Radicle node. HTTPBaseURL is 72// that node's radicle-httpd endpoint (e.g. https://r1.cyberwild.org), used 73// for reads; RadHome is the local RAD_HOME whose keys the "rad" CLI uses 74// for writes — the two are only the same node when the daemon runs 75// alongside it, which is not assumed here. 76type RadicleTarget struct { 77 RID string `yaml:"rid"` 78 HTTPBaseURL string `yaml:"http_base_url"` 79 RadHome string `yaml:"rad_home"` 80 // ExplorerURL is a Radicle Explorer deployment (e.g. 81 // https://app.radicle.xyz, or a self-hosted one) used to build "view 82 // this issue/patch" links in the status dashboard. Optional: links are 83 // omitted if unset. 84 ExplorerURL string `yaml:"explorer_url"` 85} 86 87// SyncScope selects which content types are synchronized for a repo pair. 88type SyncScope struct { 89 Git bool `yaml:"git"` 90 Issues bool `yaml:"issues"` 91 Patches bool `yaml:"patches"` 92} 93 94// Load reads and validates the configuration file at path. 95func Load(path string) (*Config, error) { 96 data, err := os.ReadFile(path) 97 if err != nil { 98 return nil, fmt.Errorf("read config: %w", err) 99 } 100 101 var cfg Config 102 if err := yaml.Unmarshal(data, &cfg); err != nil { 103 return nil, fmt.Errorf("parse config: %w", err) 104 } 105 106 if cfg.SyncInterval <= 0 { 107 cfg.SyncInterval = 5 * time.Minute 108 } 109 if cfg.AdminPassword == "" { 110 cfg.AdminPassword = "graft" 111 } 112 if cfg.StateDB == "" { 113 return nil, fmt.Errorf("state_db must be set") 114 } 115 if len(cfg.Repos) == 0 { 116 return nil, fmt.Errorf("no repos configured") 117 } 118 for i, r := range cfg.Repos { 119 if err := r.validate(); err != nil { 120 return nil, fmt.Errorf("repos[%d] (%s): %w", i, r.Name, err) 121 } 122 } 123 124 return &cfg, nil 125} 126 127func (r RepoPair) validate() error { 128 if r.Name == "" { 129 return fmt.Errorf("name is required") 130 } 131 if r.Forgejo.BaseURL == "" || r.Forgejo.Owner == "" || r.Forgejo.Repo == "" { 132 return fmt.Errorf("forgejo.base_url, owner and repo are required") 133 } 134 if r.Forgejo.TokenFile == "" { 135 return fmt.Errorf("forgejo.token_file is required") 136 } 137 if r.Radicle.RID == "" { 138 return fmt.Errorf("radicle.rid is required") 139 } 140 if r.Radicle.HTTPBaseURL == "" { 141 return fmt.Errorf("radicle.http_base_url is required") 142 } 143 if r.Radicle.RadHome == "" { 144 return fmt.Errorf("radicle.rad_home is required") 145 } 146 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches { 147 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true") 148 } 149 if r.Bluesky != nil { 150 if r.Bluesky.Handle == "" || r.Bluesky.AppPasswordFile == "" { 151 return fmt.Errorf("bluesky.handle and app_password_file are required when bluesky is set") 152 } 153 } 154 return nil 155} 156 157// ReadToken reads a token from a file, trimming surrounding whitespace. 158// Token files are expected to be chmod 600 and owned by the service user. 159func ReadToken(path string) (string, error) { 160 data, err := os.ReadFile(path) 161 if err != nil { 162 return "", fmt.Errorf("read token file %s: %w", path, err) 163 } 164 token := string(data) 165 for len(token) > 0 && (token[len(token)-1] == '\n' || token[len(token)-1] == '\r' || token[len(token)-1] == ' ') { 166 token = token[:len(token)-1] 167 } 168 if token == "" { 169 return "", fmt.Errorf("token file %s is empty", path) 170 } 171 return token, nil 172}