a love letter to tangled (android, iOS, and a search API)
1package config
2
3import (
4 "errors"
5 "os"
6 "os/user"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/joho/godotenv"
13)
14
15type Config struct {
16 DatabaseURL string
17 TapURL string
18 TapAuthPassword string
19 IndexedCollections string
20 ReadThroughMode string
21 ReadThroughCollections string
22 ReadThroughMaxAttempts int
23 SearchDefaultLimit int
24 SearchMaxLimit int
25 SearchDefaultMode string
26 HTTPBindAddr string
27 IndexerHealthAddr string
28 LogLevel string
29 LogFormat string
30 EnableAdminEndpoints bool
31 AdminAuthToken string
32 EnableIngestEnrichment bool
33 PLCDirectoryURL string
34 IdentityServiceURL string
35 XRPCTimeout time.Duration
36 ConstellationURL string
37 ConstellationUserAgent string
38 ConstellationTimeout time.Duration
39 ConstellationCacheTTL time.Duration
40 OAuthClientID string
41 OAuthRedirectURIs []string
42 JetstreamURL string
43 JetstreamWantedCollections string
44 ActivityMaxEvents int
45 ActivityRewindDuration time.Duration
46}
47
48type LoadOptions struct {
49 Local bool
50 WorkDir string
51}
52
53func Load(opts LoadOptions) (*Config, error) {
54 loadDotEnv()
55
56 cfg := &Config{
57 DatabaseURL: databaseURL(),
58 TapURL: os.Getenv("TAP_URL"),
59 TapAuthPassword: os.Getenv("TAP_AUTH_PASSWORD"),
60 IndexedCollections: os.Getenv("INDEXED_COLLECTIONS"),
61 ReadThroughMode: envOrDefault("READ_THROUGH_MODE", "missing"),
62 ReadThroughCollections: envOrDefault("READ_THROUGH_COLLECTIONS", os.Getenv("INDEXED_COLLECTIONS")),
63 ReadThroughMaxAttempts: envInt("READ_THROUGH_MAX_ATTEMPTS", 5),
64 SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"),
65 HTTPBindAddr: envBindAddr("HTTP_BIND_ADDR", "PORT", 8080),
66 IndexerHealthAddr: envBindAddr("INDEXER_HEALTH_ADDR", "PORT", 9090),
67 LogLevel: envOrDefault("LOG_LEVEL", "info"),
68 LogFormat: envOrDefault("LOG_FORMAT", "json"),
69 AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"),
70 SearchDefaultLimit: envInt("SEARCH_DEFAULT_LIMIT", 20),
71 SearchMaxLimit: envInt("SEARCH_MAX_LIMIT", 100),
72 EnableAdminEndpoints: envBool("ENABLE_ADMIN_ENDPOINTS", false),
73 EnableIngestEnrichment: envBool("ENABLE_INGEST_ENRICHMENT", true),
74 PLCDirectoryURL: envOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"),
75 IdentityServiceURL: envOrDefault("IDENTITY_SERVICE_URL", "https://public.api.bsky.app"),
76 XRPCTimeout: envDuration("XRPC_TIMEOUT", 15*time.Second),
77 ConstellationURL: envOrDefault("CONSTELLATION_URL", "https://constellation.microcosm.blue"),
78 ConstellationUserAgent: envOrDefault("CONSTELLATION_USER_AGENT", "twister/1.0 (https://tangled.org/desertthunder.dev/twisted; Owais <desertthunder.dev@gmail.com>)"),
79 ConstellationTimeout: envDuration("CONSTELLATION_TIMEOUT", 10*time.Second),
80 ConstellationCacheTTL: envDuration("CONSTELLATION_CACHE_TTL", 5*time.Minute),
81 OAuthClientID: os.Getenv("OAUTH_CLIENT_ID"),
82 OAuthRedirectURIs: envSlice("OAUTH_REDIRECT_URIS", nil),
83 JetstreamURL: envOrDefault("JETSTREAM_URL", "wss://jetstream2.us-east.bsky.network/subscribe"),
84 JetstreamWantedCollections: envOrDefault("JETSTREAM_WANTED_COLLECTIONS", "sh.tangled.*"),
85 ActivityMaxEvents: envInt("ACTIVITY_MAX_EVENTS", 500),
86 ActivityRewindDuration: envDuration("ACTIVITY_REWIND_DURATION", 5*time.Minute),
87 }
88
89 if opts.Local {
90 dbURL, err := localSQLiteDatabaseURL(opts.WorkDir)
91 if err != nil {
92 return nil, err
93 }
94 cfg.DatabaseURL = dbURL
95 cfg.LogFormat = "text"
96 }
97
98 var errs []error
99 if cfg.DatabaseURL == "" {
100 errs = append(errs, errors.New("DATABASE_URL is required"))
101 }
102 if len(errs) > 0 {
103 return nil, errors.Join(errs...)
104 }
105 return cfg, nil
106}
107
108func localSQLiteDatabaseURL(workDir string) (string, error) {
109 if strings.TrimSpace(workDir) == "" {
110 var err error
111 workDir, err = os.Getwd()
112 if err != nil {
113 return "", err
114 }
115 }
116 return "file:" + filepath.Join(workDir, "twister-dev.db"), nil
117}
118
119func databaseURL() string {
120 if v := strings.TrimSpace(os.Getenv("DATABASE_URL")); v != "" {
121 return v
122 }
123 if v := strings.TrimSpace(os.Getenv("TURSO_DATABASE_URL")); v != "" {
124 return v
125 }
126 return defaultLocalDatabaseURL()
127}
128
129func defaultLocalDatabaseURL() string {
130 userName := strings.TrimSpace(os.Getenv("USER"))
131 if userName == "" {
132 if current, err := user.Current(); err == nil {
133 userName = strings.TrimSpace(current.Username)
134 }
135 }
136 if userName == "" {
137 userName = "postgres"
138 }
139 return "postgresql://localhost/" + userName + "_dev?sslmode=disable"
140}
141
142func loadDotEnv() {
143 seen := map[string]bool{}
144 candidates := make([]string, 0, 8)
145
146 if explicit := strings.TrimSpace(os.Getenv("TWISTER_ENV_FILE")); explicit != "" {
147 candidates = append(candidates, explicit)
148 }
149
150 if cwd, err := os.Getwd(); err == nil {
151 for _, rel := range []string{".env", "../.env", "../../.env"} {
152 candidates = append(candidates, filepath.Join(cwd, rel))
153 }
154 }
155
156 for _, candidate := range candidates {
157 if candidate == "" || seen[candidate] {
158 continue
159 }
160 seen[candidate] = true
161 if _, err := os.Stat(candidate); err != nil {
162 continue
163 }
164
165 _ = godotenv.Load(candidate)
166 }
167}
168
169func envOrDefault(key, def string) string {
170 if v := os.Getenv(key); v != "" {
171 return v
172 }
173 return def
174}
175
176func envBindAddr(key, portKey string, defaultPort int) string {
177 if v := strings.TrimSpace(os.Getenv(key)); v != "" {
178 return v
179 }
180 if port := strings.TrimSpace(os.Getenv(portKey)); port != "" {
181 return ":" + port
182 }
183 return ":" + strconv.Itoa(defaultPort)
184}
185
186func envInt(key string, def int) int {
187 v := os.Getenv(key)
188 if v == "" {
189 return def
190 }
191 n, err := strconv.Atoi(v)
192 if err != nil {
193 return def
194 }
195 return n
196}
197
198func envDuration(key string, def time.Duration) time.Duration {
199 v := os.Getenv(key)
200 if v == "" {
201 return def
202 }
203 d, err := time.ParseDuration(v)
204 if err != nil {
205 return def
206 }
207 return d
208}
209
210func envBool(key string, def bool) bool {
211 v := os.Getenv(key)
212 if v == "" {
213 return def
214 }
215 b, err := strconv.ParseBool(v)
216 if err != nil {
217 return def
218 }
219 return b
220}
221
222func envSlice(key string, def []string) []string {
223 v := os.Getenv(key)
224 if v == "" {
225 return def
226 }
227 return strings.Split(v, ",")
228}