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

72 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 "rate_limit_upload_user": ( 

54 "security.rate_limit_upload_user", 

55 "LDR_SECURITY_RATE_LIMIT_UPLOAD_USER", 

56 False, 

57 ), 

58 "rate_limit_upload_ip": ( 

59 "security.rate_limit_upload_ip", 

60 "LDR_SECURITY_RATE_LIMIT_UPLOAD_IP", 

61 False, 

62 ), 

63} 

64 

65 

66_DEFAULTS: Dict[str, Any] = { 

67 # Bind to localhost by default — exposing to the LAN/internet 

68 # requires explicit operator opt-in via LDR_WEB_HOST=0.0.0.0. 

69 # The Dockerfile sets that env var so containers still work. 

70 "host": "127.0.0.1", 

71 "port": 5000, 

72 "debug": False, 

73 "use_https": True, 

74 "allow_registrations": True, 

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

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

77 "rate_limit_registration": "3 per hour", 

78 "rate_limit_settings": "30 per minute", 

79 "rate_limit_upload_user": "60 per minute;1000 per hour", 

80 "rate_limit_upload_ip": "60 per minute;1000 per hour", 

81} 

82 

83 

84def get_server_config_path() -> Path: 

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

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

87 

88 

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

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

91 

92 Parameters 

93 ---------- 

94 config_path : Path, optional 

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

96 """ 

97 path = config_path or get_server_config_path() 

98 if not path.exists(): 

99 return False 

100 try: 

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

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

103 return False 

104 if not isinstance(data, dict): 

105 return False 

106 for json_key in _LEGACY_KEY_MAP: 

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

108 return True 

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

110 return bool(unrecognized) 

111 

112 

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

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

115 

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

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

118 

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

120 """ 

121 config_path = get_server_config_path() 

122 if not config_path.exists(): 

123 return {} 

124 

125 try: 

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

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

128 logger.warning( 

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

130 f"Ignoring file." 

131 ) 

132 return {} 

133 

134 if not isinstance(data, dict): 

135 logger.warning( 

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

137 f"JSON object. Ignoring file." 

138 ) 

139 return {} 

140 

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

142 for json_key in _LEGACY_KEY_MAP: 

143 if json_key in data: 

144 saved[json_key] = data[json_key] 

145 

146 # Warn about unrecognized keys (likely typos) 

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

148 if unrecognized: 

149 logger.warning( 

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

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

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

153 ) 

154 

155 if saved: 

156 logger.info( 

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

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

159 ) 

160 

161 return saved 

162 

163 

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

165 """Load server configuration from environment variables. 

166 

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

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

169 

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

171 

172 Returns: 

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

174 allow_registrations, rate_limit_default, rate_limit_login, 

175 rate_limit_registration, rate_limit_settings 

176 """ 

177 saved = _load_legacy_config() 

178 

179 config = { 

180 "host": get_typed_setting_value( 

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

182 ), 

183 "port": get_typed_setting_value( 

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

185 ), 

186 "debug": get_typed_setting_value( 

187 "app.debug", 

188 saved.get("debug"), 

189 "checkbox", 

190 default=_DEFAULTS["debug"], 

191 ), 

192 "use_https": get_typed_setting_value( 

193 "web.use_https", 

194 saved.get("use_https"), 

195 "checkbox", 

196 default=_DEFAULTS["use_https"], 

197 ), 

198 "allow_registrations": get_typed_setting_value( 

199 "app.allow_registrations", 

200 saved.get("allow_registrations"), 

201 "checkbox", 

202 default=_DEFAULTS["allow_registrations"], 

203 ), 

204 "rate_limit_default": get_typed_setting_value( 

205 "security.rate_limit_default", 

206 saved.get("rate_limit_default"), 

207 "text", 

208 default=_DEFAULTS["rate_limit_default"], 

209 ), 

210 "rate_limit_login": get_typed_setting_value( 

211 "security.rate_limit_login", 

212 saved.get("rate_limit_login"), 

213 "text", 

214 default=_DEFAULTS["rate_limit_login"], 

215 ), 

216 "rate_limit_registration": get_typed_setting_value( 

217 "security.rate_limit_registration", 

218 saved.get("rate_limit_registration"), 

219 "text", 

220 default=_DEFAULTS["rate_limit_registration"], 

221 ), 

222 "rate_limit_settings": get_typed_setting_value( 

223 "security.rate_limit_settings", 

224 saved.get("rate_limit_settings"), 

225 "text", 

226 default=_DEFAULTS["rate_limit_settings"], 

227 ), 

228 "rate_limit_upload_user": get_typed_setting_value( 

229 "security.rate_limit_upload_user", 

230 saved.get("rate_limit_upload_user"), 

231 "text", 

232 default=_DEFAULTS["rate_limit_upload_user"], 

233 ), 

234 "rate_limit_upload_ip": get_typed_setting_value( 

235 "security.rate_limit_upload_ip", 

236 saved.get("rate_limit_upload_ip"), 

237 "text", 

238 default=_DEFAULTS["rate_limit_upload_ip"], 

239 ), 

240 } 

241 

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

243 if saved: 

244 for json_key in saved: 

245 setting_key, env_var, is_critical = _LEGACY_KEY_MAP[json_key] 

246 typed_value = config[json_key] 

247 default_value = _DEFAULTS[json_key] 

248 if typed_value == default_value: 

249 continue # matches default — no noise 

250 if is_critical: 

251 logger.warning( 

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

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

254 ) 

255 else: 

256 logger.info( 

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

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

259 ) 

260 

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

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

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

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

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

266 _RECOGNIZED_BOOL_VALUES = { 

267 "true", 

268 "false", 

269 "1", 

270 "0", 

271 "yes", 

272 "no", 

273 "on", 

274 "off", 

275 } 

276 

277 # Guard for env var path 

278 raw_reg_env = os.getenv("LDR_APP_ALLOW_REGISTRATIONS") 

279 if raw_reg_env is not None: 

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

281 if normalized not in _RECOGNIZED_BOOL_VALUES: 

282 logger.warning( 

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

284 f"recognized boolean value. Defaulting to FALSE " 

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

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

287 ) 

288 config["allow_registrations"] = False 

289 

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

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

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

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

294 elif ( 

295 "allow_registrations" in saved 

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

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

298 not in _RECOGNIZED_BOOL_VALUES 

299 ): 

300 logger.warning( 

301 f"Legacy server_config.json has allow_registrations=" 

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

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

304 f"for security." 

305 ) 

306 config["allow_registrations"] = False 

307 

308 return config