a love letter to tangled (android, iOS, and a search API)
at main 228 lines 6.6 kB view raw
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}