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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
1"""Server configuration management for web app startup.
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.
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"""
12import json
13import os
14from pathlib import Path
15from typing import Any, Dict, Optional
17from loguru import logger
19from ..config.paths import get_data_dir
20from ..settings.manager import get_typed_setting_value
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}
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}
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"
74def has_legacy_customizations(config_path: Optional[Path] = None) -> bool:
75 """Return True if server_config.json exists with non-default values.
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)
98def _load_legacy_config() -> Dict[str, Any]:
99 """Read legacy server_config.json as a read-only migration fallback.
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.
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 {}
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 {}
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 {}
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]
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 )
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 )
146 return saved
149def load_server_config() -> Dict[str, Any]:
150 """Load server configuration from environment variables.
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.
155 Priority: environment variable > legacy JSON file > built-in default.
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()
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 }
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 )
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 }
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
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
281 return config