Coverage for src / local_deep_research / web / server_config.py: 100%

72 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

1"""Server configuration management for web app startup. 

2 

3This module handles server configuration that needs to be available before 

4Flask app context is established. All settings are read from environment 

5variables (LDR_* naming convention) with sensible defaults. 

6 

7During the deprecation period, legacy ``server_config.json`` files are read 

8as a read-only fallback (env var > legacy file > default). No data is 

9written back to the JSON file. 

10""" 

11 

12import json 

13import os 

14from pathlib import Path 

15from typing import Any, Dict, Optional 

16 

17from loguru import logger 

18 

19from ..config.paths import get_data_dir 

20from ..settings.manager import get_typed_setting_value 

21 

22# Maps legacy JSON keys → (setting_key, env_var_name, is_security_critical) 

23_LEGACY_KEY_MAP: Dict[str, tuple] = { 

24 "host": ("web.host", "LDR_WEB_HOST", False), 

25 "port": ("web.port", "LDR_WEB_PORT", False), 

26 "debug": ("app.debug", "LDR_APP_DEBUG", False), 

27 "use_https": ("web.use_https", "LDR_WEB_USE_HTTPS", False), 

28 "allow_registrations": ( 

29 "app.allow_registrations", 

30 "LDR_APP_ALLOW_REGISTRATIONS", 

31 True, 

32 ), 

33 "rate_limit_default": ( 

34 "security.rate_limit_default", 

35 "LDR_SECURITY_RATE_LIMIT_DEFAULT", 

36 False, 

37 ), 

38 "rate_limit_login": ( 

39 "security.rate_limit_login", 

40 "LDR_SECURITY_RATE_LIMIT_LOGIN", 

41 False, 

42 ), 

43 "rate_limit_registration": ( 

44 "security.rate_limit_registration", 

45 "LDR_SECURITY_RATE_LIMIT_REGISTRATION", 

46 False, 

47 ), 

48 "rate_limit_settings": ( 

49 "security.rate_limit_settings", 

50 "LDR_SECURITY_RATE_LIMIT_SETTINGS", 

51 False, 

52 ), 

53} 

54 

55 

56_DEFAULTS: Dict[str, Any] = { 

57 "host": "0.0.0.0", 

58 "port": 5000, 

59 "debug": False, 

60 "use_https": True, 

61 "allow_registrations": True, 

62 "rate_limit_default": "5000 per hour;50000 per day", 

63 "rate_limit_login": "5 per 15 minutes", 

64 "rate_limit_registration": "3 per hour", 

65 "rate_limit_settings": "30 per minute", 

66} 

67 

68 

69def get_server_config_path() -> Path: 

70 """Return the path to the legacy server_config.json file.""" 

71 return Path(get_data_dir()) / "server_config.json" 

72 

73 

74def has_legacy_customizations(config_path: Optional[Path] = None) -> bool: 

75 """Return True if server_config.json exists with non-default values. 

76 

77 Parameters 

78 ---------- 

79 config_path : Path, optional 

80 Path to the legacy JSON file. Defaults to get_server_config_path(). 

81 """ 

82 path = config_path or get_server_config_path() 

83 if not path.exists(): 

84 return False 

85 try: 

86 data = json.loads(path.read_text(encoding="utf-8-sig")) 

87 except (json.JSONDecodeError, OSError, UnicodeDecodeError): 

88 return False 

89 if not isinstance(data, dict): 

90 return False 

91 for json_key in _LEGACY_KEY_MAP: 

92 if json_key in data and data[json_key] != _DEFAULTS.get(json_key): 

93 return True 

94 unrecognized = set(data.keys()) - set(_LEGACY_KEY_MAP.keys()) 

95 return bool(unrecognized) 

96 

97 

98def _load_legacy_config() -> Dict[str, Any]: 

99 """Read legacy server_config.json as a read-only migration fallback. 

100 

101 Returns a dict of ``{json_key: value}`` for recognized keys found in the 

102 file. Returns ``{}`` when the file does not exist or is malformed. 

103 

104 Logs deprecation warnings so users know to migrate to env vars. 

105 """ 

106 config_path = get_server_config_path() 

107 if not config_path.exists(): 

108 return {} 

109 

110 try: 

111 data = json.loads(config_path.read_text(encoding="utf-8-sig")) 

112 except (json.JSONDecodeError, OSError, UnicodeDecodeError): 

113 logger.warning( 

114 f"Could not read legacy server_config.json at {config_path}: " 

115 f"Ignoring file." 

116 ) 

117 return {} 

118 

119 if not isinstance(data, dict): 

120 logger.warning( 

121 f"Legacy server_config.json at {config_path} does not contain a " 

122 f"JSON object. Ignoring file." 

123 ) 

124 return {} 

125 

126 saved: Dict[str, Any] = {} 

127 for json_key in _LEGACY_KEY_MAP: 

128 if json_key in data: 

129 saved[json_key] = data[json_key] 

130 

131 # Warn about unrecognized keys (likely typos) 

132 unrecognized = set(data.keys()) - set(_LEGACY_KEY_MAP.keys()) 

133 if unrecognized: 

134 logger.warning( 

135 f"Legacy server_config.json contains unrecognized keys: " 

136 f"{sorted(unrecognized)}. These will be ignored. " 

137 f"Recognized keys: {sorted(_LEGACY_KEY_MAP.keys())}." 

138 ) 

139 

140 if saved: 

141 logger.info( 

142 f"server_config.json detected at {config_path}. " 

143 f"Environment variables are the preferred configuration method." 

144 ) 

145 

146 return saved 

147 

148 

149def load_server_config() -> Dict[str, Any]: 

150 """Load server configuration from environment variables. 

151 

152 During the deprecation period, values from a legacy ``server_config.json`` 

153 are used as fallbacks when the corresponding env var is not set. 

154 

155 Priority: environment variable > legacy JSON file > built-in default. 

156 

157 Returns: 

158 dict: Server configuration with keys: host, port, debug, use_https, 

159 allow_registrations, rate_limit_default, rate_limit_login, 

160 rate_limit_registration, rate_limit_settings 

161 """ 

162 saved = _load_legacy_config() 

163 

164 config = { 

165 "host": get_typed_setting_value( 

166 "web.host", saved.get("host"), "text", default=_DEFAULTS["host"] 

167 ), 

168 "port": get_typed_setting_value( 

169 "web.port", saved.get("port"), "number", default=_DEFAULTS["port"] 

170 ), 

171 "debug": get_typed_setting_value( 

172 "app.debug", 

173 saved.get("debug"), 

174 "checkbox", 

175 default=_DEFAULTS["debug"], 

176 ), 

177 "use_https": get_typed_setting_value( 

178 "web.use_https", 

179 saved.get("use_https"), 

180 "checkbox", 

181 default=_DEFAULTS["use_https"], 

182 ), 

183 "allow_registrations": get_typed_setting_value( 

184 "app.allow_registrations", 

185 saved.get("allow_registrations"), 

186 "checkbox", 

187 default=_DEFAULTS["allow_registrations"], 

188 ), 

189 "rate_limit_default": get_typed_setting_value( 

190 "security.rate_limit_default", 

191 saved.get("rate_limit_default"), 

192 "text", 

193 default=_DEFAULTS["rate_limit_default"], 

194 ), 

195 "rate_limit_login": get_typed_setting_value( 

196 "security.rate_limit_login", 

197 saved.get("rate_limit_login"), 

198 "text", 

199 default=_DEFAULTS["rate_limit_login"], 

200 ), 

201 "rate_limit_registration": get_typed_setting_value( 

202 "security.rate_limit_registration", 

203 saved.get("rate_limit_registration"), 

204 "text", 

205 default=_DEFAULTS["rate_limit_registration"], 

206 ), 

207 "rate_limit_settings": get_typed_setting_value( 

208 "security.rate_limit_settings", 

209 saved.get("rate_limit_settings"), 

210 "text", 

211 default=_DEFAULTS["rate_limit_settings"], 

212 ), 

213 } 

214 

215 # Log per-key messages for legacy values that differ from defaults 

216 if saved: 

217 for json_key in saved: 

218 setting_key, env_var, is_critical = _LEGACY_KEY_MAP[json_key] 

219 typed_value = config[json_key] 

220 default_value = _DEFAULTS[json_key] 

221 if typed_value == default_value: 

222 continue # matches default — no noise 

223 if is_critical: 

224 logger.warning( 

225 f"SECURITY: server_config.json sets '{json_key}' to a non-default value. " 

226 f"Consider migrating to environment variable {env_var}." 

227 ) 

228 else: 

229 logger.info( 

230 f"server_config.json sets '{json_key}' to a non-default value. " 

231 f"Environment variable {env_var} is the preferred configuration method." 

232 ) 

233 

234 # Security: if allow_registrations is set to an unrecognized string value 

235 # (e.g. "disabled", "nein", typos like "flase"), default to False 

236 # (registrations disabled) rather than True. This "fail closed" approach 

237 # prevents accidental open registration when an admin clearly intended to 

238 # restrict it but used a non-standard boolean string. 

239 _RECOGNIZED_BOOL_VALUES = { 

240 "true", 

241 "false", 

242 "1", 

243 "0", 

244 "yes", 

245 "no", 

246 "on", 

247 "off", 

248 } 

249 

250 # Guard for env var path 

251 raw_reg_env = os.getenv("LDR_APP_ALLOW_REGISTRATIONS") 

252 if raw_reg_env is not None: 

253 normalized = raw_reg_env.lower().strip() 

254 if normalized not in _RECOGNIZED_BOOL_VALUES: 

255 logger.warning( 

256 f"LDR_APP_ALLOW_REGISTRATIONS='{raw_reg_env}' is not a " 

257 f"recognized boolean value. Defaulting to FALSE " 

258 f"(registrations disabled) for security. Use " 

259 f"'true'/'false', '1'/'0', 'yes'/'no', or 'on'/'off'." 

260 ) 

261 config["allow_registrations"] = False 

262 

263 # Guard for legacy JSON path: if legacy JSON had a string value for 

264 # allow_registrations (e.g. "disabled") and no env var overrides it, 

265 # parse_boolean would treat any non-empty string as True (HTML checkbox 

266 # semantics). Fail closed to prevent accidental open registration. 

267 elif ( 

268 "allow_registrations" in saved 

269 and isinstance(saved["allow_registrations"], str) 

270 and saved["allow_registrations"].lower().strip() 

271 not in _RECOGNIZED_BOOL_VALUES 

272 ): 

273 logger.warning( 

274 f"Legacy server_config.json has allow_registrations=" 

275 f"'{saved['allow_registrations']}' which is not a recognized " 

276 f"boolean value. Defaulting to FALSE (registrations disabled) " 

277 f"for security." 

278 ) 

279 config["allow_registrations"] = False 

280 

281 return config