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