1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
package shared

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"sort"
	"time"
)

const (
	configVarName  = "CONFIG"               // If set, will load config.json from this path and not from devConfigPath
	secretsVarName = "SECRETS"              // If set, will load secrets.json from this path and not from devSecretsPath
	devConfigPath  = "dev/config.dev.json"  // Path to config.json in development environment
	devSecretsPath = "dev/secrets.dev.json" // Path to secrets.json in development environment
)

type Config struct {
	Secrets     Secrets `json:"-"`
	LogFile     string  `json:"log_file"`
	LogLevel    string  `json:"log_level"`
	ServicePort uint    `json:"service_port"`
	Host        string  `json:"host"`
	DbFile      string  `json:"db_file"`
	// RestrictRequests, when true, closes new-feed requests by default: only
	// fediverse monikers (@user@host) in the allowlist, which is kept in the
	// database and edited with `rss-kakapo allow`, may request feeds; everyone
	// else is refused, and an empty allowlist denies everyone. When false
	// (default) anyone may request feeds.
	RestrictRequests bool           `json:"restrict_requests"`
	ProfileDir       string         `json:"profile_dir"`
	ProfileKeepDays  int            `json:"profile_keep_days"`
	UpdateSchedule   UpdateSchedule `json:"update_schedule"`
	// MaxRetryDelayHours caps how long a feed is left alone after a failed
	// check, whatever the backoff or the server's Retry-After say, unless its
	// normal interval is longer. 0 means the default of 24.
	MaxRetryDelayHours int `json:"max_retry_delay_hours"`
	// BackdateGraceHours: an item never seen before is tooted only if it is
	// dated after the newest item seen so far, or at most this many hours
	// before it (for posts published with an earlier date, or a wrong time
	// zone). nil means the default of 48.
	BackdateGraceHours *int `json:"backdate_grace_hours"`
	// MaxNewItemsPerFetch, if above 0, limits how many new items one fetch of
	// a feed may toot: if a feed suddenly shows more, only the newest that
	// many are tooted, and the rest are remembered as seen. 0 (default) is
	// no limit.
	MaxNewItemsPerFetch int       `json:"max_new_items_per_fetch"`
	PostsMinCountKept   int       `json:"posts_min_count_kept"`
	PostsMinDaysKept    int       `json:"posts_min_days_kept"`
	PurgeWaitSec        int       `json:"purge_wait_sec"`
	FallbackProfilePic  string    `json:"fallback_profile_pic"`
	Birb                *UserInfo `json:"birb"`
}

// UpdateSchedule is how often a feed is checked, in hours, by how long ago it
// last had a new post: within a day, a week, four weeks, or longer.
type UpdateSchedule struct {
	Day    int `json:"day"`
	Week   int `json:"week"`
	Weeks4 int `json:"weeks4"`
	Older  int `json:"older"`
}

// DefaultUpdateSchedule is upstream's documented schedule.
var DefaultUpdateSchedule = UpdateSchedule{Day: 1, Week: 3, Weeks4: 6, Older: 12}

// WithDefaults fills in every interval that is not set (or not positive) from
// DefaultUpdateSchedule. An interval of 0 would schedule the next check right
// away, so the feed would be checked as fast as the loop runs.
func (s UpdateSchedule) WithDefaults() UpdateSchedule {
	def := DefaultUpdateSchedule
	if s.Day <= 0 {
		s.Day = def.Day
	}
	if s.Week <= 0 {
		s.Week = def.Week
	}
	if s.Weeks4 <= 0 {
		s.Weeks4 = def.Weeks4
	}
	if s.Older <= 0 {
		s.Older = def.Older
	}
	return s
}

type UserInfo struct {
	User                    string    `json:"user"`
	Published               time.Time `json:"published"`
	ManuallyApprovesFollows bool      `json:"manually_approves_follows"`
	ProfilePic              string    `json:"profile_pic"`
	HeaderPic               string    `json:"header_pic"`
	PubKey                  string    `json:"pub_key"`
	PrivKey                 string    `json:"priv_key"`
}

type Secrets struct {
	BirdPrivKeyPass string   `json:"birb_privkey_passphrase"`
	ApiKeys         []string `json:"api_keys"`
}

func LoadConfig() *Config {

	// Where are our config and secrets files?
	cfgPath := os.Getenv(configVarName)
	if len(cfgPath) == 0 {
		cfgPath = devConfigPath
	}
	secretsPath := os.Getenv(secretsVarName)
	if len(secretsPath) == 0 {
		secretsPath = devSecretsPath
	}

	// Read config file
	var config Config
	cfgJson := mustDeserializeFile(cfgPath, &config)
	for _, w := range removedKeyWarnings(cfgJson) {
		fmt.Fprintf(os.Stderr, "rss-kakapo: warning: %s\n", w)
	}
	// Read secrets member from secrets file
	mustDeserializeFile(secretsPath, &config.Secrets)
	return &config
}

// removedKeys are config keys that once had an effect and no longer do, with
// what replaced them. Like any unknown key they are ignored, but with a
// warning, since whoever set one expects it to do something.
var removedKeys = map[string]string{
	"blocked_feeds_file":      "the blocklist is kept in the database; see rss-kakapo block",
	"allowed_requesters_file": "the allowlist is kept in the database; see rss-kakapo allow",
	"cache_page_templates":    "the page templates are compiled into the binary",
}

// removedKeyWarnings returns a warning for every key of removedKeys that the
// config JSON sets, sorted by key.
func removedKeyWarnings(cfgJson []byte) []string {
	var keys map[string]json.RawMessage
	if json.Unmarshal(cfgJson, &keys) != nil {
		return nil
	}
	var res []string
	for key := range keys {
		if instead, ok := removedKeys[key]; ok {
			res = append(res, fmt.Sprintf("config key %s is no longer supported and is ignored: %s", key, instead))
		}
	}
	sort.Strings(res)
	return res
}

// mustDeserializeFile parses the JSON file into obj and returns its contents.
func mustDeserializeFile[T any](fileName string, obj *T) []byte {
	cfgJson, err := os.ReadFile(fileName)
	if err != nil {
		log.Fatal(err)
	}
	if err := json.Unmarshal(cfgJson, obj); err != nil {
		log.Fatalf("%s: %v", fileName, err)
	}
	return cfgJson
}