Coverage for app / backend / src / couchers / config.py: 41%

59 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +0000

1""" 

2A simple config system 

3""" 

4 

5import os 

6from typing import Any 

7 

8CONFIG_T = list[tuple[str, type | list[str]] | tuple[str, type | list[str], str | int]] 

9 

10# Allowed config options, as tuples (name, type, default). 

11# All fields are required 

12CONFIG_OPTIONS: CONFIG_T = [ 

13 # Whether we're in dev mode 

14 ("DEV", bool), 

15 # Whether we're `api` mode (answering API queries) or `scheduler` (scheduling background jobs), or `worker` 

16 # (servicing background jobs). Can also be set to `all` to do all three simultaneously 

17 ("ROLE", ["api", "scheduler", "worker", "all"], "all"), 

18 # number of bg worker processes, requires worker or all above 

19 ("BACKGROUND_WORKER_COUNT", int, 2), 

20 # Version string 

21 ("VERSION", str, "unknown"), 

22 # Base URL of frontend, e.g. https://couchers.org 

23 ("BASE_URL", str), 

24 # URL of the backend, e.g. https://api.couchers.org 

25 ("BACKEND_BASE_URL", str), 

26 # URL of the console, e.g. https://console.couchers.org 

27 ("CONSOLE_BASE_URL", str), 

28 # URL of the merch shop, e.g. https://shop.couchershq.org 

29 ("MERCH_SHOP_URL", str), 

30 # Used to generate a variety of secrets 

31 ("SECRET", bytes), 

32 # Domain that cookies should set as their domain value 

33 ("COOKIE_DOMAIN", str), 

34 # SQLAlchemy database connection string 

35 ("DATABASE_CONNECTION_STRING", str), 

36 # OpenTelemetry endpoint to send traces to 

37 ("OPENTELEMETRY_ENDPOINT", str, ""), 

38 # Path to a GeoLite2-City.mmdb file for geocoding IPs in user session info 

39 ("GEOLITE2_CITY_MMDB_FILE_LOCATION", str, ""), 

40 ("GEOLITE2_ASN_MMDB_FILE_LOCATION", str, ""), 

41 # Whether to try adding dummy data 

42 ("ADD_DUMMY_DATA", bool), 

43 # Donations 

44 ("ENABLE_DONATIONS", bool), 

45 ("STRIPE_API_KEY", str), 

46 ("STRIPE_WEBHOOK_SECRET", str), 

47 ("STRIPE_RECURRING_PRODUCT_ID", str), 

48 # Strong verification through Iris ID 

49 ("ENABLE_STRONG_VERIFICATION", bool), 

50 ("IRIS_ID_PUBKEY", str), 

51 ("IRIS_ID_SECRET", str), 

52 ("VERIFICATION_DATA_PUBLIC_KEY", bytes), 

53 # Postal verification 

54 ("ENABLE_POSTAL_VERIFICATION", bool), 

55 # MyPostcard API credentials 

56 ("MYPOSTCARD_API_KEY", str), 

57 ("MYPOSTCARD_USERNAME", str), 

58 ("MYPOSTCARD_PASSWORD", str), 

59 ("MYPOSTCARD_PRODUCT_CODE", str), 

60 ("MYPOSTCARD_CAMPAIGN_ID", str), 

61 # SMS 

62 ("ENABLE_SMS", bool), 

63 ("SMS_SENDER_ID", str), 

64 # Email 

65 ("ENABLE_EMAIL", bool), 

66 # Sender name for outgoing notification emails e.g. "Couchers.org" 

67 ("NOTIFICATION_EMAIL_SENDER", str), 

68 # Sender email, e.g. "notify@couchers.org" 

69 ("NOTIFICATION_EMAIL_ADDRESS", str), 

70 # An optional prefix for email subject, e.g. [STAGING] 

71 ("NOTIFICATION_PREFIX", str, ""), 

72 ("ENABLE_NOTIFICATION_TRANSLATIONS", bool), 

73 # Address to send emails about reported users 

74 ("REPORTS_EMAIL_RECIPIENT", str), 

75 # Address to send contributor forms when users sign up/fill the form 

76 ("CONTRIBUTOR_FORM_EMAIL_RECIPIENT", str), 

77 # Address to moderation notifications 

78 ("MODS_EMAIL_RECIPIENT", str), 

79 # SMTP settings 

80 ("SMTP_HOST", str), 

81 ("SMTP_PORT", int), 

82 ("SMTP_USERNAME", str), 

83 ("SMTP_PASSWORD", str), 

84 # Media server 

85 ("ENABLE_MEDIA", bool), 

86 ("MEDIA_SERVER_SECRET_KEY", bytes), 

87 ("MEDIA_SERVER_BEARER_TOKEN", str), 

88 ("MEDIA_SERVER_BASE_URL", str), 

89 ("MEDIA_SERVER_UPLOAD_BASE_URL", str), 

90 # Bug reporting tool 

91 ("BUG_TOOL_ENABLED", bool), 

92 ("BUG_TOOL_GITHUB_REPO", str), 

93 ("BUG_TOOL_GITHUB_USERNAME", str), 

94 ("BUG_TOOL_GITHUB_TOKEN", str), 

95 # Sentry 

96 ("SENTRY_ENABLED", bool), 

97 ("SENTRY_URL", str), 

98 # Push notifications 

99 ("PUSH_NOTIFICATIONS_ENABLED", bool), 

100 ("PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY", str), 

101 ("PUSH_NOTIFICATIONS_VAPID_SUBJECT", str), 

102 # Whether to initiate new activeness probes 

103 ("ACTIVENESS_PROBES_ENABLED", bool), 

104 # Listmonk (mailing list) 

105 ("LISTMONK_ENABLED", bool), 

106 ("LISTMONK_BASE_URL", str), 

107 ("LISTMONK_API_USERNAME", str), 

108 ("LISTMONK_API_KEY", str), 

109 ("LISTMONK_LIST_ID", int), 

110 # Google recaptcha antibot 

111 ("RECAPTHCA_ENABLED", bool), 

112 ("RECAPTHCA_PROJECT_ID", str), 

113 ("RECAPTHCA_API_KEY", str), 

114 ("RECAPTHCA_SITE_KEY", str), 

115 # Whether we're in test 

116 ("IN_TEST", bool, "0"), 

117 # Experimentation (feature flags via Statsig) 

118 ("EXPERIMENTATION_ENABLED", bool, "0"), 

119 # When enabled, all feature gates return True (useful for development/testing) 

120 ("EXPERIMENTATION_PASS_ALL_GATES", bool, "0"), 

121 # Statsig SDK configuration 

122 ("STATSIG_SERVER_SECRET_KEY", str, ""), 

123 ("STATSIG_ENVIRONMENT", str, "development"), 

124 # Moderation auto-approval deadline in seconds (0 to disable auto-approval) 

125 ("MODERATION_AUTO_APPROVE_DEADLINE_SECONDS", int), 

126 # User ID of the bot user for automated moderation actions 

127 ("MODERATION_BOT_USER_ID", int), 

128 # Enable development APIs (e.g., SendDevPushNotification) 

129 ("ENABLE_DEV_APIS", bool), 

130 # Slack notifications 

131 ("SLACK_ENABLED", bool), 

132 ("SLACK_BOT_TOKEN", str), 

133 ("SLACK_DONATIONS_CHANNEL", str), 

134 ("SLACK_MERCH_CHANNEL", str), 

135] 

136 

137 

138def check_config(cfg: dict[str, Any]) -> None: 

139 for name, *_ in CONFIG_OPTIONS: 

140 if name not in cfg: 

141 raise ValueError(f"Required config value {name} not set") 

142 

143 if not cfg["DEV"]: 

144 # checks for prod 

145 if "https" not in cfg["BASE_URL"]: 

146 raise Exception("Production site must be over HTTPS") 

147 if not cfg["ENABLE_EMAIL"]: 

148 raise Exception("Production site must have email enabled") 

149 if not cfg["ENABLE_SMS"]: 

150 raise Exception("Production site must have SMS enabled") 

151 if cfg["IN_TEST"]: 

152 raise Exception("IN_TEST while not DEV") 

153 

154 if cfg["ENABLE_DONATIONS"]: 

155 if not cfg["STRIPE_API_KEY"] or not cfg["STRIPE_WEBHOOK_SECRET"] or not cfg["STRIPE_RECURRING_PRODUCT_ID"]: 

156 raise Exception("No Stripe API key/recurring donation ID but donations enabled") 

157 

158 if cfg["ENABLE_STRONG_VERIFICATION"]: 

159 if not cfg["IRIS_ID_PUBKEY"] or not cfg["IRIS_ID_SECRET"] or not cfg["VERIFICATION_DATA_PUBLIC_KEY"]: 

160 raise Exception("No Iris ID pubkey/secret or verification data pubkey but strong verification enabled") 

161 

162 if cfg["ENABLE_POSTAL_VERIFICATION"]: 

163 if ( 

164 not cfg["MYPOSTCARD_API_KEY"] 

165 or not cfg["MYPOSTCARD_USERNAME"] 

166 or not cfg["MYPOSTCARD_PASSWORD"] 

167 or not cfg["MYPOSTCARD_PRODUCT_CODE"] 

168 or not cfg["MYPOSTCARD_CAMPAIGN_ID"] 

169 ): 

170 raise Exception("MyPostcard API credentials not configured but postal verification enabled") 

171 

172 if cfg["EXPERIMENTATION_ENABLED"]: 

173 if not cfg["STATSIG_SERVER_SECRET_KEY"]: 

174 raise Exception("No Statsig server secret key but experimentation enabled") 

175 

176 

177def make_config() -> dict[str, Any]: 

178 cfg = {} 

179 

180 for config_option in CONFIG_OPTIONS: 

181 if len(config_option) == 2: 

182 name, type_ = config_option 

183 optional = False 

184 elif len(config_option) == 3: 184 ↛ 188line 184 didn't jump to line 188 because the condition on line 184 was always true

185 name, type_, default_value = config_option 

186 optional = True 

187 else: 

188 raise ValueError("Invalid CONFIG_OPTIONS") 

189 

190 value: str | int | bytes | None = os.getenv(name) 

191 

192 if not value: 

193 if not optional: 

194 # config value not set - will cause a KeyError when trying 

195 # to access it. 

196 continue 

197 else: 

198 value = default_value 

199 

200 if type_ is bool: 

201 # 1 is true, 0 is false, everything else is illegal 

202 if value not in ["0", "1"]: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 raise ValueError(f'Invalid bool for {name}, need "0" or "1"') 

204 value = value == "1" 

205 elif type_ is bytes: 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was never true

206 # decode from hex 

207 if not isinstance(value, str): 

208 raise RuntimeError(type(value)) 

209 value = bytes.fromhex(value) 

210 elif isinstance(type_, list): 

211 # list of allowed string values 

212 if value not in type_: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true

213 raise ValueError(f"Invalid value for {name}, need one of {', '.join(type_)}") 

214 else: 

215 value = type_(value) 

216 

217 cfg[name] = value 

218 

219 return cfg 

220 

221 

222config = make_config()