Coverage for an_website/main.py: 81.070%

243 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-01 08:32 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13# pylint: disable=import-private-name, too-many-lines 

14 

15""" 

16The website of the AN. 

17 

18Loads config and modules and starts Tornado. 

19""" 

20 

21from __future__ import annotations 

22 

23import asyncio 

24import atexit 

25import importlib 

26import logging 

27import os 

28import platform 

29import signal 

30import ssl 

31import sys 

32import threading 

33import time 

34import types 

35import uuid 

36from asyncio import AbstractEventLoop 

37from asyncio.runners import _cancel_all_tasks # type: ignore[attr-defined] 

38from base64 import b64encode 

39from collections.abc import Callable, Iterable, Mapping, MutableSequence 

40from configparser import ConfigParser 

41from functools import partial 

42from hashlib import sha256 

43from multiprocessing.process import _children # type: ignore[attr-defined] 

44from pathlib import Path 

45from socket import socket 

46from typing import Any, Final, TypedDict, TypeGuard, cast 

47from warnings import catch_warnings, simplefilter 

48from zoneinfo import ZoneInfo 

49 

50import regex 

51from Crypto.Hash import RIPEMD160 

52from ecs_logging import StdlibFormatter 

53from elasticapm.contrib.tornado import ElasticAPM 

54from redis.asyncio import ( 

55 BlockingConnectionPool, 

56 Redis, 

57 SSLConnection, 

58 UnixDomainSocketConnection, 

59) 

60from setproctitle import setproctitle 

61from tornado.httpserver import HTTPServer 

62from tornado.log import LogFormatter 

63from tornado.netutil import bind_sockets, bind_unix_socket 

64from tornado.process import fork_processes, task_id 

65from tornado.web import Application, RedirectHandler 

66from typed_stream import Stream 

67 

68from . import ( 

69 CA_BUNDLE_PATH, 

70 DIR, 

71 EVENT_SHUTDOWN, 

72 NAME, 

73 TEMPLATES_DIR, 

74 UPTIME, 

75 VERSION, 

76 pytest_is_running, 

77) 

78from .contact.contact import apply_contact_stuff_to_app 

79from .utils import background_tasks, static_file_handling 

80from .utils.base_request_handler import BaseRequestHandler, request_ctx_var 

81from .utils.better_config_parser import BetterConfigParser 

82from .utils.elasticsearch_setup import setup_elasticsearch 

83from .utils.logging import WebhookFormatter, WebhookHandler 

84from .utils.request_handler import NotFoundHandler 

85from .utils.static_file_from_traversable import TraversableStaticFileHandler 

86from .utils.template_loader import TemplateLoader 

87from .utils.utils import ( 

88 ArgparseNamespace, 

89 Handler, 

90 ModuleInfo, 

91 Permission, 

92 Timer, 

93 create_argument_parser, 

94 geoip, 

95 get_arguments_without_help, 

96 time_function, 

97) 

98 

99try: 

100 import perf8 # type: ignore[import, unused-ignore] 

101except ModuleNotFoundError: 

102 perf8 = None # pylint: disable=invalid-name 

103 

104IGNORED_MODULES: Final[set[str]] = { 

105 "patches", 

106 "static", 

107 "templates", 

108} | (set() if sys.flags.dev_mode or pytest_is_running() else {"example"}) 

109 

110LOGGER: Final = logging.getLogger(__name__) 

111 

112 

113# add all the information from the packages to a list 

114# this calls the get_module_info function in every file 

115# files and dirs starting with '_' get ignored 

116def get_module_infos() -> str | tuple[ModuleInfo, ...]: 

117 """Import the modules and return the loaded module infos in a tuple.""" 

118 module_infos: list[ModuleInfo] = [] 

119 loaded_modules: list[str] = [] 

120 errors: list[str] = [] 

121 

122 for potential_module in DIR.iterdir(): 

123 if ( 

124 potential_module.name.startswith("_") 

125 or potential_module.name in IGNORED_MODULES 

126 or not potential_module.is_dir() 

127 ): 

128 continue 

129 

130 _module_infos = get_module_infos_from_module( 

131 potential_module.name, errors, ignore_not_found=True 

132 ) 

133 if _module_infos: 

134 module_infos.extend(_module_infos) 

135 loaded_modules.append(potential_module.name) 

136 LOGGER.debug( 

137 ( 

138 "Found module_infos in %s.__init__.py, " 

139 "not searching in other modules in the package." 

140 ), 

141 potential_module, 

142 ) 

143 continue 

144 

145 if f"{potential_module.name}.*" in IGNORED_MODULES: 

146 continue 

147 

148 for potential_file in potential_module.iterdir(): 

149 module_name = f"{potential_module.name}.{potential_file.name[:-3]}" 

150 if ( 

151 not potential_file.name.endswith(".py") 

152 or module_name in IGNORED_MODULES 

153 or potential_file.name.startswith("_") 

154 ): 

155 continue 

156 _module_infos = get_module_infos_from_module(module_name, errors) 

157 if _module_infos: 

158 module_infos.extend(_module_infos) 

159 loaded_modules.append(module_name) 

160 

161 if len(errors) > 0: 

162 if sys.flags.dev_mode: 

163 # exit to make sure it gets fixed 

164 return "\n".join(errors) 

165 # don't exit in production to keep stuff running 

166 LOGGER.error("\n".join(errors)) 

167 

168 LOGGER.info( 

169 "Loaded %d modules: '%s'", 

170 len(loaded_modules), 

171 "', '".join(loaded_modules), 

172 ) 

173 

174 LOGGER.info( 

175 "Ignored %d modules: '%s'", 

176 len(IGNORED_MODULES), 

177 "', '".join(IGNORED_MODULES), 

178 ) 

179 

180 sort_module_infos(module_infos) 

181 

182 # make module_infos immutable so it never changes 

183 return tuple(module_infos) 

184 

185 

186def get_module_infos_from_module( 

187 module_name: str, 

188 errors: MutableSequence[str], # gets modified 

189 ignore_not_found: bool = False, 

190) -> None | list[ModuleInfo]: 

191 """Get the module infos based on a module.""" 

192 import_timer = Timer() 

193 module = importlib.import_module( 

194 f".{module_name}", 

195 package="an_website", 

196 ) 

197 if import_timer.stop() > 0.1: 

198 LOGGER.warning( 

199 "Import of %s took %ss. That's affecting the startup time.", 

200 module_name, 

201 import_timer.get(), 

202 ) 

203 

204 module_infos: list[ModuleInfo] = [] 

205 

206 has_get_module_info = "get_module_info" in dir(module) 

207 has_get_module_infos = "get_module_infos" in dir(module) 

208 

209 if not (has_get_module_info or has_get_module_infos): 

210 if ignore_not_found: 

211 return None 

212 errors.append( 

213 f"{module_name} has no 'get_module_info' and no 'get_module_infos' " 

214 "method. Please add at least one of the methods or add " 

215 f"'{module_name.rsplit('.', 1)[0]}.*' or {module_name!r} to " 

216 "IGNORED_MODULES." 

217 ) 

218 return None 

219 

220 if has_get_module_info and isinstance( 

221 module_info := module.get_module_info(), 

222 ModuleInfo, 

223 ): 

224 module_infos.append(module_info) 

225 elif has_get_module_info: 

226 errors.append( 

227 f"'get_module_info' in {module_name} does not return ModuleInfo. " 

228 "Please fix the returned value." 

229 ) 

230 

231 if not has_get_module_infos: 

232 return module_infos or None 

233 

234 _module_infos = module.get_module_infos() 

235 

236 if not isinstance(_module_infos, Iterable): 

237 errors.append( 

238 f"'get_module_infos' in {module_name} does not return an Iterable. " 

239 "Please fix the returned value." 

240 ) 

241 return module_infos or None 

242 

243 for _module_info in _module_infos: 

244 if isinstance(_module_info, ModuleInfo): 

245 module_infos.append(_module_info) 

246 else: 

247 errors.append( 

248 f"'get_module_infos' in {module_name} did return an Iterable " 

249 f"with an element of type {type(_module_info)}. " 

250 "Please fix the returned value." 

251 ) 

252 

253 return module_infos or None 

254 

255 

256def sort_module_infos(module_infos: list[ModuleInfo]) -> None: 

257 """Sort a list of module info and move the main page to the top.""" 

258 # sort it so the order makes sense 

259 module_infos.sort() 

260 

261 # move the main page to the top 

262 for i, info in enumerate(module_infos): 

263 if info.path == "/": 

264 module_infos.insert(0, module_infos.pop(i)) 

265 break 

266 

267 

268def get_all_handlers(module_infos: Iterable[ModuleInfo]) -> list[Handler]: 

269 """ 

270 Parse the module information and return the handlers in a tuple. 

271 

272 If a handler has only 2 elements a dict with title and description 

273 gets added. This information is gotten from the module info. 

274 """ 

275 handler: Handler | list[Any] 

276 handlers: list[Handler] = static_file_handling.get_handlers() 

277 

278 # add all the normal handlers 

279 for module_info in module_infos: 

280 for handler in module_info.handlers: 

281 handler = list(handler) # pylint: disable=redefined-loop-name 

282 # if the handler is a request handler from us 

283 # and not a built-in like StaticFileHandler & RedirectHandler 

284 if issubclass(handler[1], BaseRequestHandler): 

285 if len(handler) == 2: 

286 # set "default_title" or "default_description" to False so 

287 # that module_info.name & module_info.description get used 

288 handler.append( 

289 { 

290 "default_title": False, 

291 "default_description": False, 

292 "module_info": module_info, 

293 } 

294 ) 

295 else: 

296 handler[2]["module_info"] = module_info 

297 handlers.append(tuple(handler)) 

298 

299 # redirect handler, to make finding APIs easier 

300 handlers.append((r"/(.+)/api/*", RedirectHandler, {"url": "/api/{0}"})) 

301 

302 handlers.append( 

303 ( 

304 r"(?i)/\.well-known/(.*)", 

305 TraversableStaticFileHandler, 

306 {"root": Path(".well-known"), "hashes": {}}, 

307 ) 

308 ) 

309 

310 LOGGER.debug("Loaded %d handlers", len(handlers)) 

311 

312 return handlers 

313 

314 

315def ignore_modules(config: BetterConfigParser) -> None: 

316 """Read ignored modules from the config.""" 

317 IGNORED_MODULES.update( 

318 config.getset("GENERAL", "IGNORED_MODULES", fallback=set()) 

319 ) 

320 

321 

322def get_normed_paths_from_module_infos( 

323 module_infos: Iterable[ModuleInfo], 

324) -> dict[str, str]: 

325 """Get all paths from the module infos.""" 

326 

327 def tuple_has_no_none( 

328 value: tuple[str | None, str | None], 

329 ) -> TypeGuard[tuple[str, str]]: 

330 return None not in value 

331 

332 def info_to_paths(info: ModuleInfo) -> Stream[tuple[str, str]]: 

333 return ( 

334 Stream(((info.path, info.path),)) 

335 .chain( 

336 info.aliases.items() 

337 if isinstance(info.aliases, Mapping) 

338 else ((alias, info.path) for alias in info.aliases) 

339 ) 

340 .chain( 

341 Stream(info.sub_pages) 

342 .map(lambda sub_info: sub_info.path) 

343 .filter() 

344 .map(lambda path: (path, path)) 

345 ) 

346 .filter(tuple_has_no_none) 

347 ) 

348 

349 return ( 

350 Stream(module_infos) 

351 .flat_map(info_to_paths) 

352 .filter(lambda p: p[0].startswith("/")) 

353 .map(lambda p: (p[0].strip("/").lower(), p[1])) 

354 .filter(lambda p: p[0]) 

355 .collect(dict) 

356 ) 

357 

358 

359def make_app(config: ConfigParser) -> str | Application: 

360 """Create the Tornado application and return it.""" 

361 module_infos, duration = time_function(get_module_infos) 

362 if isinstance(module_infos, str): 

363 return module_infos 

364 if duration > 1: 

365 LOGGER.warning( 

366 "Getting the module infos took %ss. That's probably too long.", 

367 duration, 

368 ) 

369 handlers = get_all_handlers(module_infos) 

370 return Application( 

371 handlers, 

372 MODULE_INFOS=module_infos, 

373 SHOW_HAMBURGER_MENU=not Stream(module_infos) 

374 .exclude(lambda info: info.hidden) 

375 .filter(lambda info: info.path) 

376 .empty(), 

377 NORMED_PATHS=get_normed_paths_from_module_infos(module_infos), 

378 HANDLERS=handlers, 

379 # General settings 

380 autoreload=False, 

381 debug=sys.flags.dev_mode, 

382 default_handler_class=NotFoundHandler, 

383 compress_response=config.getboolean( 

384 "GENERAL", "COMPRESS_RESPONSE", fallback=False 

385 ), 

386 websocket_ping_interval=10, 

387 # Template settings 

388 template_loader=TemplateLoader( 

389 root=TEMPLATES_DIR, whitespace="oneline" 

390 ), 

391 ) 

392 

393 

394def apply_config_to_app(app: Application, config: BetterConfigParser) -> None: 

395 """Apply the config (from the config.ini file) to the application.""" 

396 app.settings["CONFIG"] = config 

397 

398 app.settings["cookie_secret"] = config.get( 

399 "GENERAL", "COOKIE_SECRET", fallback="xyzzy" 

400 ) 

401 

402 app.settings["CRAWLER_SECRET"] = config.get( 

403 "APP_SEARCH", "CRAWLER_SECRET", fallback=None 

404 ) 

405 

406 app.settings["DOMAIN"] = config.get("GENERAL", "DOMAIN", fallback=None) 

407 

408 app.settings["ELASTICSEARCH_PREFIX"] = config.get( 

409 "ELASTICSEARCH", "PREFIX", fallback=NAME 

410 ) 

411 

412 app.settings["HSTS"] = config.getboolean("TLS", "HSTS", fallback=False) 

413 

414 app.settings["NETCUP"] = config.getboolean( 

415 "GENERAL", "NETCUP", fallback=False 

416 ) 

417 

418 onion_address = config.get("GENERAL", "ONION_ADDRESS", fallback=None) 

419 app.settings["ONION_ADDRESS"] = onion_address 

420 if onion_address is None: 

421 app.settings["ONION_PROTOCOL"] = None 

422 else: 

423 app.settings["ONION_PROTOCOL"] = onion_address.split("://")[0] 

424 

425 app.settings["RATELIMITS"] = config.getboolean( 

426 "GENERAL", 

427 "RATELIMITS", 

428 fallback=config.getboolean("REDIS", "ENABLED", fallback=False), 

429 ) 

430 

431 app.settings["REDIS_PREFIX"] = config.get("REDIS", "PREFIX", fallback=NAME) 

432 

433 app.settings["REPORTING"] = config.getboolean( 

434 "REPORTING", "ENABLED", fallback=True 

435 ) 

436 

437 app.settings["REPORTING_BUILTIN"] = config.getboolean( 

438 "REPORTING", "BUILTIN", fallback=sys.flags.dev_mode 

439 ) 

440 

441 app.settings["REPORTING_ENDPOINT"] = config.get( 

442 "REPORTING", 

443 "ENDPOINT", 

444 fallback=( 

445 "/api/reports" 

446 if app.settings["REPORTING_BUILTIN"] 

447 else "https://asozial.org/api/reports" 

448 ), 

449 ) 

450 

451 app.settings["TRUSTED_API_SECRETS"] = { 

452 key_perms[0]: Permission( 

453 int(key_perms[1]) 

454 if len(key_perms) > 1 

455 else (1 << len(Permission)) - 1 # should be all permissions 

456 ) 

457 for secret in config.getset( 

458 "GENERAL", "TRUSTED_API_SECRETS", fallback={"xyzzy"} 

459 ) 

460 if (key_perms := [part.strip() for part in secret.split("=")]) 

461 if key_perms[0] 

462 } 

463 

464 app.settings["AUTH_TOKEN_SECRET"] = config.get( 

465 "GENERAL", "AUTH_TOKEN_SECRET", fallback=None 

466 ) 

467 if not app.settings["AUTH_TOKEN_SECRET"]: 

468 node = uuid.getnode().to_bytes(6, "big") 

469 secret = RIPEMD160.new(node).digest().decode("BRAILLE") 

470 LOGGER.warning( 

471 "AUTH_TOKEN_SECRET is unset, implicitly setting it to %r", 

472 secret, 

473 ) 

474 app.settings["AUTH_TOKEN_SECRET"] = secret 

475 

476 app.settings["UNDER_ATTACK"] = config.getboolean( 

477 "GENERAL", "UNDER_ATTACK", fallback=False 

478 ) 

479 

480 apply_contact_stuff_to_app(app, config) 

481 

482 

483def get_ssl_context( # pragma: no cover 

484 config: ConfigParser, 

485) -> None | ssl.SSLContext: 

486 """Create SSL context and configure using the config.""" 

487 if config.getboolean("TLS", "ENABLED", fallback=False): 

488 ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 

489 ssl_ctx.load_cert_chain( 

490 config.get("TLS", "CERTFILE"), 

491 config.get("TLS", "KEYFILE", fallback=None), 

492 config.get("TLS", "PASSWORD", fallback=None), 

493 ) 

494 return ssl_ctx 

495 return None 

496 

497 

498def setup_logging( # pragma: no cover 

499 config: ConfigParser, 

500 force: bool = False, 

501) -> None: 

502 """Setup logging.""" # noqa: D401 

503 root_logger = logging.getLogger() 

504 

505 if root_logger.handlers: 

506 if not force: 

507 return 

508 for handler in root_logger.handlers[:]: 

509 root_logger.removeHandler(handler) 

510 handler.close() 

511 

512 debug = config.getboolean("LOGGING", "DEBUG", fallback=sys.flags.dev_mode) 

513 

514 logging.captureWarnings(True) 

515 

516 root_logger.setLevel(logging.DEBUG if debug else logging.INFO) 

517 logging.getLogger("tornado.curl_httpclient").setLevel(logging.INFO) 

518 logging.getLogger("elasticsearch").setLevel(logging.INFO) 

519 

520 stream_handler = logging.StreamHandler() 

521 if sys.flags.dev_mode: 

522 spam = regex.sub(r"%\((end_)?color\)s", "", LogFormatter.DEFAULT_FORMAT) 

523 formatter = logging.Formatter(spam, LogFormatter.DEFAULT_DATE_FORMAT) 

524 else: 

525 formatter = LogFormatter() 

526 stream_handler.setFormatter(formatter) 

527 root_logger.addHandler(stream_handler) 

528 

529 if path := config.get("LOGGING", "PATH", fallback=None): 

530 os.makedirs(path, 0o755, True) 

531 file_handler = logging.handlers.TimedRotatingFileHandler( 

532 os.path.join(path, f"{NAME}.log"), 

533 encoding="UTF-8", 

534 when="midnight", 

535 backupCount=30, 

536 utc=True, 

537 ) 

538 file_handler.setFormatter(StdlibFormatter()) 

539 root_logger.addHandler(file_handler) 

540 

541 

542class WebhookLoggingOptions: # pylint: disable=too-few-public-methods 

543 """Webhook logging options.""" 

544 

545 __slots__ = ( 

546 "url", 

547 "content_type", 

548 "body_format", 

549 "timestamp_format", 

550 "timestamp_timezone", 

551 "escape_message", 

552 "max_message_length", 

553 ) 

554 

555 url: str | None 

556 content_type: str 

557 body_format: str 

558 timestamp_format: str | None 

559 timestamp_timezone: str | None 

560 escape_message: bool 

561 max_message_length: int | None 

562 

563 def __init__(self, config: ConfigParser) -> None: 

564 """Initialize Webhook logging options.""" 

565 self.url = config.get("LOGGING", "WEBHOOK_URL", fallback=None) 

566 self.content_type = config.get( 

567 "LOGGING", 

568 "WEBHOOK_CONTENT_TYPE", 

569 fallback="application/json", 

570 ) 

571 spam = regex.sub(r"%\((end_)?color\)s", "", LogFormatter.DEFAULT_FORMAT) 

572 self.body_format = config.get( 

573 "LOGGING", 

574 "WEBHOOK_BODY_FORMAT", 

575 fallback='{"text":"' + spam + '"}', 

576 ) 

577 self.timestamp_format = config.get( 

578 "LOGGING", 

579 "WEBHOOK_TIMESTAMP_FORMAT", 

580 fallback=None, 

581 ) 

582 self.timestamp_timezone = config.get( 

583 "LOGGING", "WEBHOOK_TIMESTAMP_TIMEZONE", fallback=None 

584 ) 

585 self.escape_message = config.getboolean( 

586 "LOGGING", 

587 "WEBHOOK_ESCAPE_MESSAGE", 

588 fallback=True, 

589 ) 

590 self.max_message_length = config.getint( 

591 "LOGGING", "WEBHOOK_MAX_MESSAGE_LENGTH", fallback=None 

592 ) 

593 

594 

595def setup_webhook_logging( # pragma: no cover 

596 options: WebhookLoggingOptions, 

597 loop: asyncio.AbstractEventLoop, 

598) -> None: 

599 """Setup Webhook logging.""" # noqa: D401 

600 if not options.url: 

601 return 

602 

603 LOGGER.info("Setting up Webhook logging") 

604 

605 root_logger = logging.getLogger() 

606 

607 webhook_content_type = options.content_type 

608 webhook_handler = WebhookHandler( 

609 logging.ERROR, 

610 loop=loop, 

611 url=options.url, 

612 content_type=webhook_content_type, 

613 ) 

614 formatter = WebhookFormatter( 

615 options.body_format, 

616 options.timestamp_format, 

617 ) 

618 formatter.timezone = ( 

619 None 

620 if options.timestamp_format is None 

621 else ZoneInfo(options.timestamp_format) 

622 ) 

623 formatter.escape_message = options.escape_message 

624 formatter.max_message_length = options.max_message_length 

625 webhook_handler.setFormatter(formatter) 

626 root_logger.addHandler(webhook_handler) 

627 

628 info_handler = WebhookHandler( 

629 logging.INFO, 

630 loop=loop, 

631 url=options.url, 

632 content_type=webhook_content_type, 

633 ) 

634 info_handler.setFormatter(formatter) 

635 logging.getLogger("an_website.quotes.create").addHandler(info_handler) 

636 

637 

638def setup_apm(app: Application) -> None: # pragma: no cover 

639 """Setup APM.""" # noqa: D401 

640 config: BetterConfigParser = app.settings["CONFIG"] 

641 app.settings["ELASTIC_APM"] = { 

642 "ENABLED": config.getboolean("ELASTIC_APM", "ENABLED", fallback=False), 

643 "SERVER_URL": config.get( 

644 "ELASTIC_APM", "SERVER_URL", fallback="http://localhost:8200" 

645 ), 

646 "SECRET_TOKEN": config.get( 

647 "ELASTIC_APM", "SECRET_TOKEN", fallback=None 

648 ), 

649 "API_KEY": config.get("ELASTIC_APM", "API_KEY", fallback=None), 

650 "VERIFY_SERVER_CERT": config.getboolean( 

651 "ELASTIC_APM", "VERIFY_SERVER_CERT", fallback=True 

652 ), 

653 "USE_CERTIFI": True, # doesn't actually use certifi 

654 "SERVICE_NAME": NAME.removesuffix("-dev"), 

655 "SERVICE_VERSION": VERSION, 

656 "ENVIRONMENT": ( 

657 "production" if not sys.flags.dev_mode else "development" 

658 ), 

659 "DEBUG": True, 

660 "CAPTURE_BODY": "errors", 

661 "TRANSACTION_IGNORE_URLS": [ 

662 "/api/ping", 

663 "/static/*", 

664 "/favicon.png", 

665 ], 

666 "TRANSACTIONS_IGNORE_PATTERNS": ["^OPTIONS "], 

667 "PROCESSORS": [ 

668 "an_website.utils.utils.apm_anonymization_processor", 

669 "elasticapm.processors.sanitize_stacktrace_locals", 

670 "elasticapm.processors.sanitize_http_request_cookies", 

671 "elasticapm.processors.sanitize_http_headers", 

672 "elasticapm.processors.sanitize_http_wsgi_env", 

673 "elasticapm.processors.sanitize_http_request_body", 

674 ], 

675 "RUM_SERVER_URL": config.get( 

676 "ELASTIC_APM", "RUM_SERVER_URL", fallback=None 

677 ), 

678 "RUM_SERVER_URL_PREFIX": config.get( 

679 "ELASTIC_APM", "RUM_SERVER_URL_PREFIX", fallback=None 

680 ), 

681 } 

682 

683 script_options = [ 

684 f"serviceName:{app.settings['ELASTIC_APM']['SERVICE_NAME']!r}", 

685 f"serviceVersion:{app.settings['ELASTIC_APM']['SERVICE_VERSION']!r}", 

686 f"environment:{app.settings['ELASTIC_APM']['ENVIRONMENT']!r}", 

687 ] 

688 

689 rum_server_url = app.settings["ELASTIC_APM"]["RUM_SERVER_URL"] 

690 

691 if rum_server_url is None: 

692 script_options.append( 

693 f"serverUrl:{app.settings['ELASTIC_APM']['SERVER_URL']!r}" 

694 ) 

695 elif rum_server_url: 

696 script_options.append(f"serverUrl:{rum_server_url!r}") 

697 else: 

698 script_options.append("serverUrl:window.location.origin") 

699 

700 if app.settings["ELASTIC_APM"]["RUM_SERVER_URL_PREFIX"]: 

701 script_options.append( 

702 f"serverUrlPrefix:{app.settings['ELASTIC_APM']['RUM_SERVER_URL_PREFIX']!r}" 

703 ) 

704 

705 app.settings["ELASTIC_APM"]["INLINE_SCRIPT"] = ( 

706 "elasticApm.init({" + ",".join(script_options) + "})" 

707 ) 

708 

709 app.settings["ELASTIC_APM"]["INLINE_SCRIPT_HASH"] = b64encode( 

710 sha256( 

711 app.settings["ELASTIC_APM"]["INLINE_SCRIPT"].encode("ASCII") 

712 ).digest() 

713 ).decode("ASCII") 

714 

715 if app.settings["ELASTIC_APM"]["ENABLED"]: 

716 app.settings["ELASTIC_APM"]["CLIENT"] = ElasticAPM(app).client 

717 

718 

719def setup_app_search(app: Application) -> None: # pragma: no cover 

720 """Setup Elastic App Search.""" # noqa: D401 

721 with catch_warnings(): 

722 simplefilter("ignore", DeprecationWarning) 

723 # pylint: disable-next=import-outside-toplevel 

724 from elastic_enterprise_search import ( 

725 AppSearch, # type: ignore[import-untyped] 

726 ) 

727 

728 config: BetterConfigParser = app.settings["CONFIG"] 

729 host = config.get("APP_SEARCH", "HOST", fallback=None) 

730 key = config.get("APP_SEARCH", "SEARCH_KEY", fallback=None) 

731 verify_certs = config.getboolean( 

732 "APP_SEARCH", "VERIFY_CERTS", fallback=True 

733 ) 

734 app.settings["APP_SEARCH"] = ( 

735 AppSearch( 

736 host, 

737 bearer_auth=key, 

738 verify_certs=verify_certs, 

739 ca_certs=CA_BUNDLE_PATH, 

740 ) 

741 if host 

742 else None 

743 ) 

744 app.settings["APP_SEARCH_HOST"] = host 

745 app.settings["APP_SEARCH_KEY"] = key 

746 app.settings["APP_SEARCH_ENGINE"] = config.get( 

747 "APP_SEARCH", "ENGINE_NAME", fallback=NAME.removesuffix("-dev") 

748 ) 

749 

750 

751def setup_redis(app: Application) -> None | Redis[str]: 

752 """Setup Redis.""" # noqa: D401 

753 config: BetterConfigParser = app.settings["CONFIG"] 

754 

755 class Kwargs(TypedDict, total=False): 

756 """Kwargs of BlockingConnectionPool constructor.""" 

757 

758 db: int 

759 username: None | str 

760 password: None | str 

761 retry_on_timeout: bool 

762 connection_class: type[UnixDomainSocketConnection] | type[SSLConnection] 

763 path: str 

764 host: str 

765 port: int 

766 ssl_ca_certs: str 

767 ssl_keyfile: None | str 

768 ssl_certfile: None | str 

769 ssl_check_hostname: bool 

770 ssl_cert_reqs: str 

771 

772 kwargs: Kwargs = { 

773 "db": config.getint("REDIS", "DB", fallback=0), 

774 "username": config.get("REDIS", "USERNAME", fallback=None), 

775 "password": config.get("REDIS", "PASSWORD", fallback=None), 

776 "retry_on_timeout": config.getboolean( 

777 "REDIS", "RETRY_ON_TIMEOUT", fallback=False 

778 ), 

779 } 

780 redis_ssl_kwargs: Kwargs = { 

781 "connection_class": SSLConnection, 

782 "ssl_ca_certs": CA_BUNDLE_PATH, 

783 "ssl_keyfile": config.get("REDIS", "SSL_KEYFILE", fallback=None), 

784 "ssl_certfile": config.get("REDIS", "SSL_CERTFILE", fallback=None), 

785 "ssl_cert_reqs": config.get( 

786 "REDIS", "SSL_CERT_REQS", fallback="required" 

787 ), 

788 "ssl_check_hostname": config.getboolean( 

789 "REDIS", "SSL_CHECK_HOSTNAME", fallback=False 

790 ), 

791 } 

792 redis_host_port_kwargs: Kwargs = { 

793 "host": config.get("REDIS", "HOST", fallback="localhost"), 

794 "port": config.getint("REDIS", "PORT", fallback=6379), 

795 } 

796 redis_use_ssl = config.getboolean("REDIS", "SSL", fallback=False) 

797 redis_unix_socket_path = config.get( 

798 "REDIS", "UNIX_SOCKET_PATH", fallback=None 

799 ) 

800 

801 if redis_unix_socket_path is not None: 

802 if redis_use_ssl: 

803 LOGGER.warning( 

804 "SSL is enabled for Redis, but a UNIX socket is used" 

805 ) 

806 if config.has_option("REDIS", "HOST"): 

807 LOGGER.warning( 

808 "A host is configured for Redis, but a UNIX socket is used" 

809 ) 

810 if config.has_option("REDIS", "PORT"): 

811 LOGGER.warning( 

812 "A port is configured for Redis, but a UNIX socket is used" 

813 ) 

814 kwargs.update( 

815 { 

816 "connection_class": UnixDomainSocketConnection, 

817 "path": redis_unix_socket_path, 

818 } 

819 ) 

820 else: 

821 kwargs.update(redis_host_port_kwargs) 

822 if redis_use_ssl: 

823 kwargs.update(redis_ssl_kwargs) 

824 

825 if not config.getboolean("REDIS", "ENABLED", fallback=False): 

826 app.settings["REDIS"] = None 

827 return None 

828 connection_pool = BlockingConnectionPool( 

829 client_name=NAME, 

830 decode_responses=True, 

831 **kwargs, 

832 ) 

833 redis = cast("Redis[str]", Redis(connection_pool=connection_pool)) 

834 app.settings["REDIS"] = redis 

835 return redis 

836 

837 

838def signal_handler( # noqa: D103 # pragma: no cover 

839 signalnum: int, frame: None | types.FrameType 

840) -> None: 

841 # pylint: disable=unused-argument, missing-function-docstring 

842 if signalnum in {signal.SIGINT, signal.SIGTERM}: 

843 EVENT_SHUTDOWN.set() 

844 if signalnum == getattr(signal, "SIGHUP", None): 

845 EVENT_SHUTDOWN.set() 

846 

847 

848def install_signal_handler() -> None: # pragma: no cover 

849 """Install the signal handler.""" 

850 signal.signal(signal.SIGINT, signal_handler) 

851 signal.signal(signal.SIGTERM, signal_handler) 

852 if hasattr(signal, "SIGHUP"): 

853 signal.signal(signal.SIGHUP, signal_handler) 

854 

855 

856def supervise(loop: AbstractEventLoop) -> None: 

857 """Supervise.""" 

858 while foobarbaz := background_tasks.HEARTBEAT: # pylint: disable=while-used 

859 if time.monotonic() - foobarbaz >= 10: 

860 worker = task_id() 

861 pid = os.getpid() 

862 

863 task = asyncio.current_task(loop) 

864 request = task.get_context().get(request_ctx_var) if task else None 

865 

866 LOGGER.fatal( 

867 "Heartbeat timed out for worker %s (pid %d), " 

868 "current request: %s, current task: %s", 

869 worker, 

870 pid, 

871 request, 

872 task, 

873 ) 

874 atexit._run_exitfuncs() # pylint: disable=protected-access 

875 os.abort() 

876 time.sleep(1) 

877 

878 

879def main( # noqa: C901 # pragma: no cover 

880 config: BetterConfigParser | None = None, 

881) -> int | str: 

882 """ 

883 Start everything. 

884 

885 This is the main function that is called when running this program. 

886 """ 

887 # pylint: disable=too-complex, too-many-branches 

888 # pylint: disable=too-many-locals, too-many-statements 

889 setproctitle(NAME) 

890 

891 install_signal_handler() 

892 

893 parser = create_argument_parser() 

894 args, _ = parser.parse_known_args( 

895 get_arguments_without_help(), ArgparseNamespace() 

896 ) 

897 

898 if args.version: 

899 print("Version:", VERSION) 

900 if args.verbose: 

901 # pylint: disable-next=import-outside-toplevel 

902 from .version.version import ( 

903 get_file_hashes, 

904 get_hash_of_file_hashes, 

905 ) 

906 

907 print() 

908 print("Hash der Datei-Hashes:") 

909 print(get_hash_of_file_hashes()) 

910 

911 if args.verbose > 1: 

912 print() 

913 print("Datei-Hashes:") 

914 print(get_file_hashes()) 

915 

916 return 0 

917 

918 config = config or BetterConfigParser.from_path(*args.config) 

919 assert config is not None 

920 config.add_override_argument_parser(parser) 

921 

922 setup_logging(config) 

923 

924 LOGGER.info("Starting %s %s", NAME, VERSION) 

925 

926 if platform.system() == "Windows": 

927 LOGGER.warning( 

928 "Running %s on Windows is not officially supported", 

929 NAME.removesuffix("-dev"), 

930 ) 

931 

932 ignore_modules(config) 

933 app = make_app(config) 

934 if isinstance(app, str): 

935 return app 

936 

937 apply_config_to_app(app, config) 

938 setup_elasticsearch(app) 

939 setup_app_search(app) 

940 setup_redis(app) 

941 setup_apm(app) 

942 

943 behind_proxy = config.getboolean("GENERAL", "BEHIND_PROXY", fallback=False) 

944 

945 server = HTTPServer( 

946 app, 

947 body_timeout=3600, 

948 decompress_request=True, 

949 max_body_size=1_000_000_000, 

950 ssl_options=get_ssl_context(config), 

951 xheaders=behind_proxy, 

952 ) 

953 

954 socket_factories: list[Callable[[], Iterable[socket]]] = [] 

955 

956 port = config.getint("GENERAL", "PORT", fallback=None) 

957 

958 if port: 

959 socket_factories.append( 

960 partial( 

961 bind_sockets, 

962 port, 

963 "localhost" if behind_proxy else "", 

964 ) 

965 ) 

966 

967 unix_socket_path = config.get( 

968 "GENERAL", 

969 "UNIX_SOCKET_PATH", 

970 fallback=None, 

971 ) 

972 

973 if unix_socket_path: 

974 os.makedirs(unix_socket_path, 0o755, True) 

975 socket_factories.append( 

976 lambda: ( 

977 bind_unix_socket( 

978 os.path.join(unix_socket_path, f"{NAME}.sock"), 

979 mode=0o666, 

980 ), 

981 ) 

982 ) 

983 

984 processes = config.getint( 

985 "GENERAL", 

986 "PROCESSES", 

987 fallback=hasattr(os, "fork") * (2 if sys.flags.dev_mode else -1), 

988 ) 

989 

990 if processes < 0: 

991 processes = ( 

992 os.process_cpu_count() # type: ignore[attr-defined] 

993 if sys.version_info >= (3, 13) 

994 else os.cpu_count() 

995 ) or 0 

996 

997 worker: None | int = None 

998 

999 run_supervisor_thread = config.getboolean( 

1000 "GENERAL", "SUPERVISE", fallback=False 

1001 ) 

1002 elasticsearch_is_enabled = config.getboolean( 

1003 "ELASTICSEARCH", "ENABLED", fallback=False 

1004 ) 

1005 redis_is_enabled = config.getboolean("REDIS", "ENABLED", fallback=False) 

1006 webhook_logging_options = WebhookLoggingOptions(config) 

1007 # all config options should be read before forking 

1008 if args.save_config_to: 

1009 with open(args.save_config_to, "w", encoding="UTF-8") as file: 

1010 config.write(file) 

1011 config.set_all_options_should_be_parsed() 

1012 del config 

1013 # show help message if --help is given (after reading config, before forking) 

1014 parser.parse_args() 

1015 

1016 if not socket_factories: 

1017 LOGGER.warning("No sockets configured") 

1018 return 0 

1019 

1020 # create sockets after checking for --help 

1021 sockets: list[socket] = ( 

1022 Stream(socket_factories).flat_map(lambda fun: fun()).collect(list) 

1023 ) 

1024 

1025 UPTIME.reset() 

1026 main_pid = os.getpid() 

1027 

1028 if processes: 

1029 setproctitle(f"{NAME} - Master") 

1030 

1031 worker = fork_processes(processes) 

1032 

1033 setproctitle(f"{NAME} - Worker {worker}") 

1034 

1035 # yeet all children (there should be none, but do it regardless, just in case) 

1036 _children.clear() 

1037 

1038 if "an_website.quotes" in sys.modules: 

1039 from .quotes.utils import ( # pylint: disable=import-outside-toplevel 

1040 AUTHORS_CACHE, 

1041 QUOTES_CACHE, 

1042 WRONG_QUOTES_CACHE, 

1043 ) 

1044 

1045 del AUTHORS_CACHE.control.created_by_ultra # type: ignore[attr-defined] 

1046 del QUOTES_CACHE.control.created_by_ultra # type: ignore[attr-defined] 

1047 del WRONG_QUOTES_CACHE.control.created_by_ultra # type: ignore[attr-defined] 

1048 del geoip.__kwdefaults__["caches"].control.created_by_ultra 

1049 

1050 if unix_socket_path: 

1051 sockets.append( 

1052 bind_unix_socket( 

1053 os.path.join(unix_socket_path, f"{NAME}.{worker}.sock"), 

1054 mode=0o666, 

1055 ) 

1056 ) 

1057 

1058 # get loop after forking 

1059 # if not forking allow loop to be set in advance by external code 

1060 loop: None | asyncio.AbstractEventLoop 

1061 try: 

1062 with catch_warnings(): # TODO: remove after dropping support for 3.13 

1063 simplefilter("ignore", DeprecationWarning) 

1064 loop = asyncio.get_event_loop() 

1065 if loop.is_closed(): 

1066 loop = None 

1067 except RuntimeError: 

1068 loop = None 

1069 

1070 if loop is None: 

1071 loop = asyncio.new_event_loop() 

1072 asyncio.set_event_loop(loop) 

1073 

1074 if sys.version_info >= (3, 13) and not loop.get_task_factory(): 

1075 loop.set_task_factory(asyncio.eager_task_factory) 

1076 

1077 if perf8 and "PERF8" in os.environ: 

1078 loop.run_until_complete(perf8.enable()) 

1079 

1080 setup_webhook_logging(webhook_logging_options, loop) 

1081 

1082 server.add_sockets(sockets) 

1083 

1084 tasks = background_tasks.start_background_tasks( # noqa: F841 

1085 module_infos=app.settings["MODULE_INFOS"], 

1086 loop=loop, 

1087 main_pid=main_pid, 

1088 app=app, 

1089 processes=processes, 

1090 elasticsearch_is_enabled=elasticsearch_is_enabled, 

1091 redis_is_enabled=redis_is_enabled, 

1092 worker=worker, 

1093 ) 

1094 

1095 if run_supervisor_thread: 

1096 background_tasks.HEARTBEAT = time.monotonic() 

1097 threading.Thread( 

1098 target=supervise, args=(loop,), name="supervisor", daemon=True 

1099 ).start() 

1100 

1101 try: 

1102 loop.run_forever() 

1103 EVENT_SHUTDOWN.set() 

1104 finally: 

1105 try: # pylint: disable=too-many-try-statements 

1106 server.stop() 

1107 loop.run_until_complete(asyncio.sleep(1)) 

1108 loop.run_until_complete(server.close_all_connections()) 

1109 if perf8 and "PERF8" in os.environ: 

1110 loop.run_until_complete(perf8.disable()) 

1111 if redis := app.settings.get("REDIS"): 

1112 loop.run_until_complete( 

1113 redis.aclose(close_connection_pool=True) 

1114 ) 

1115 if elasticsearch := app.settings.get("ELASTICSEARCH"): 

1116 loop.run_until_complete(elasticsearch.close()) 

1117 finally: 

1118 try: 

1119 _cancel_all_tasks(loop) 

1120 loop.run_until_complete(loop.shutdown_asyncgens()) 

1121 loop.run_until_complete(loop.shutdown_default_executor()) 

1122 finally: 

1123 loop.close() 

1124 background_tasks.HEARTBEAT = 0 

1125 

1126 return len(tasks)