Coverage for an_website/main.py: 81.070%

243 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-14 14:44 +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 { 

307 "root": Path(".well-known"), 

308 "headers": (("Access-Control-Allow-Origin", "*"),), 

309 }, 

310 ) 

311 ) 

312 

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

314 

315 return handlers 

316 

317 

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

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

320 IGNORED_MODULES.update( 

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

322 ) 

323 

324 

325def get_normed_paths_from_module_infos( 

326 module_infos: Iterable[ModuleInfo], 

327) -> dict[str, str]: 

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

329 

330 def tuple_has_no_none( 

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

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

333 return None not in value 

334 

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

336 return ( 

337 Stream(((info.path, info.path),)) 

338 .chain( 

339 info.aliases.items() 

340 if isinstance(info.aliases, Mapping) 

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

342 ) 

343 .chain( 

344 Stream(info.sub_pages) 

345 .map(lambda sub_info: sub_info.path) 

346 .filter() 

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

348 ) 

349 .filter(tuple_has_no_none) 

350 ) 

351 

352 return ( 

353 Stream(module_infos) 

354 .flat_map(info_to_paths) 

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

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

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

358 .collect(dict) 

359 ) 

360 

361 

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

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

364 module_infos, duration = time_function(get_module_infos) 

365 if isinstance(module_infos, str): 

366 return module_infos 

367 if duration > 1: 

368 LOGGER.warning( 

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

370 duration, 

371 ) 

372 handlers = get_all_handlers(module_infos) 

373 return Application( 

374 handlers, 

375 MODULE_INFOS=module_infos, 

376 SHOW_HAMBURGER_MENU=not Stream(module_infos) 

377 .exclude(lambda info: info.hidden) 

378 .filter(lambda info: info.path) 

379 .empty(), 

380 NORMED_PATHS=get_normed_paths_from_module_infos(module_infos), 

381 HANDLERS=handlers, 

382 # General settings 

383 autoreload=False, 

384 debug=sys.flags.dev_mode, 

385 default_handler_class=NotFoundHandler, 

386 compress_response=config.getboolean( 

387 "GENERAL", "COMPRESS_RESPONSE", fallback=False 

388 ), 

389 websocket_ping_interval=10, 

390 # Template settings 

391 template_loader=TemplateLoader( 

392 root=TEMPLATES_DIR, whitespace="oneline" 

393 ), 

394 ) 

395 

396 

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

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

399 app.settings["CONFIG"] = config 

400 

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

402 "GENERAL", "COOKIE_SECRET", fallback="xyzzy" 

403 ) 

404 

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

406 "APP_SEARCH", "CRAWLER_SECRET", fallback=None 

407 ) 

408 

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

410 

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

412 "ELASTICSEARCH", "PREFIX", fallback=NAME 

413 ) 

414 

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

416 

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

418 "GENERAL", "NETCUP", fallback=False 

419 ) 

420 

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

422 app.settings["ONION_ADDRESS"] = onion_address 

423 if onion_address is None: 

424 app.settings["ONION_PROTOCOL"] = None 

425 else: 

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

427 

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

429 "GENERAL", 

430 "RATELIMITS", 

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

432 ) 

433 

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

435 

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

437 "REPORTING", "ENABLED", fallback=True 

438 ) 

439 

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

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

442 ) 

443 

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

445 "REPORTING", 

446 "ENDPOINT", 

447 fallback=( 

448 "/api/reports" 

449 if app.settings["REPORTING_BUILTIN"] 

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

451 ), 

452 ) 

453 

454 app.settings["TRUSTED_API_SECRETS"] = { 

455 key_perms[0]: Permission( 

456 int(key_perms[1]) 

457 if len(key_perms) > 1 

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

459 ) 

460 for secret in config.getset( 

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

462 ) 

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

464 if key_perms[0] 

465 } 

466 

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

468 "GENERAL", "AUTH_TOKEN_SECRET", fallback=None 

469 ) 

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

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

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

473 LOGGER.warning( 

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

475 secret, 

476 ) 

477 app.settings["AUTH_TOKEN_SECRET"] = secret 

478 

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

480 "GENERAL", "UNDER_ATTACK", fallback=False 

481 ) 

482 

483 apply_contact_stuff_to_app(app, config) 

484 

485 

486def get_ssl_context( # pragma: no cover 

487 config: ConfigParser, 

488) -> None | ssl.SSLContext: 

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

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

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

492 ssl_ctx.load_cert_chain( 

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

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

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

496 ) 

497 return ssl_ctx 

498 return None 

499 

500 

501def setup_logging( # pragma: no cover 

502 config: ConfigParser, 

503 force: bool = False, 

504) -> None: 

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

506 root_logger = logging.getLogger() 

507 

508 if root_logger.handlers: 

509 if not force: 

510 return 

511 for handler in root_logger.handlers[:]: 

512 root_logger.removeHandler(handler) 

513 handler.close() 

514 

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

516 

517 logging.captureWarnings(True) 

518 

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

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

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

522 

523 stream_handler = logging.StreamHandler() 

524 if sys.flags.dev_mode: 

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

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

527 else: 

528 formatter = LogFormatter() 

529 stream_handler.setFormatter(formatter) 

530 root_logger.addHandler(stream_handler) 

531 

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

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

534 file_handler = logging.handlers.TimedRotatingFileHandler( 

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

536 encoding="UTF-8", 

537 when="midnight", 

538 backupCount=30, 

539 utc=True, 

540 ) 

541 file_handler.setFormatter(StdlibFormatter()) 

542 root_logger.addHandler(file_handler) 

543 

544 

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

546 """Webhook logging options.""" 

547 

548 __slots__ = ( 

549 "url", 

550 "content_type", 

551 "body_format", 

552 "timestamp_format", 

553 "timestamp_timezone", 

554 "escape_message", 

555 "max_message_length", 

556 ) 

557 

558 url: str | None 

559 content_type: str 

560 body_format: str 

561 timestamp_format: str | None 

562 timestamp_timezone: str | None 

563 escape_message: bool 

564 max_message_length: int | None 

565 

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

567 """Initialize Webhook logging options.""" 

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

569 self.content_type = config.get( 

570 "LOGGING", 

571 "WEBHOOK_CONTENT_TYPE", 

572 fallback="application/json", 

573 ) 

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

575 self.body_format = config.get( 

576 "LOGGING", 

577 "WEBHOOK_BODY_FORMAT", 

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

579 ) 

580 self.timestamp_format = config.get( 

581 "LOGGING", 

582 "WEBHOOK_TIMESTAMP_FORMAT", 

583 fallback=None, 

584 ) 

585 self.timestamp_timezone = config.get( 

586 "LOGGING", "WEBHOOK_TIMESTAMP_TIMEZONE", fallback=None 

587 ) 

588 self.escape_message = config.getboolean( 

589 "LOGGING", 

590 "WEBHOOK_ESCAPE_MESSAGE", 

591 fallback=True, 

592 ) 

593 self.max_message_length = config.getint( 

594 "LOGGING", "WEBHOOK_MAX_MESSAGE_LENGTH", fallback=None 

595 ) 

596 

597 

598def setup_webhook_logging( # pragma: no cover 

599 options: WebhookLoggingOptions, 

600 loop: asyncio.AbstractEventLoop, 

601) -> None: 

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

603 if not options.url: 

604 return 

605 

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

607 

608 root_logger = logging.getLogger() 

609 

610 webhook_content_type = options.content_type 

611 webhook_handler = WebhookHandler( 

612 logging.ERROR, 

613 loop=loop, 

614 url=options.url, 

615 content_type=webhook_content_type, 

616 ) 

617 formatter = WebhookFormatter( 

618 options.body_format, 

619 options.timestamp_format, 

620 ) 

621 formatter.timezone = ( 

622 None 

623 if options.timestamp_format is None 

624 else ZoneInfo(options.timestamp_format) 

625 ) 

626 formatter.escape_message = options.escape_message 

627 formatter.max_message_length = options.max_message_length 

628 formatter.get_context_line = lambda _: ( 

629 f"Request: {request}" 

630 if (request := request_ctx_var.get(None)) 

631 else None 

632 ) 

633 webhook_handler.setFormatter(formatter) 

634 root_logger.addHandler(webhook_handler) 

635 

636 info_handler = WebhookHandler( 

637 logging.INFO, 

638 loop=loop, 

639 url=options.url, 

640 content_type=webhook_content_type, 

641 ) 

642 info_handler.setFormatter(formatter) 

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

644 

645 

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

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

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

649 app.settings["ELASTIC_APM"] = { 

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

651 "SERVER_URL": config.get( 

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

653 ), 

654 "SECRET_TOKEN": config.get( 

655 "ELASTIC_APM", "SECRET_TOKEN", fallback=None 

656 ), 

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

658 "VERIFY_SERVER_CERT": config.getboolean( 

659 "ELASTIC_APM", "VERIFY_SERVER_CERT", fallback=True 

660 ), 

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

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

663 "SERVICE_VERSION": VERSION, 

664 "ENVIRONMENT": ( 

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

666 ), 

667 "DEBUG": True, 

668 "CAPTURE_BODY": "errors", 

669 "TRANSACTION_IGNORE_URLS": [ 

670 "/api/ping", 

671 "/static/*", 

672 "/favicon.png", 

673 ], 

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

675 "PROCESSORS": [ 

676 "an_website.utils.utils.apm_anonymization_processor", 

677 "elasticapm.processors.sanitize_stacktrace_locals", 

678 "elasticapm.processors.sanitize_http_request_cookies", 

679 "elasticapm.processors.sanitize_http_headers", 

680 "elasticapm.processors.sanitize_http_wsgi_env", 

681 "elasticapm.processors.sanitize_http_request_body", 

682 ], 

683 "RUM_SERVER_URL": config.get( 

684 "ELASTIC_APM", "RUM_SERVER_URL", fallback=None 

685 ), 

686 "RUM_SERVER_URL_PREFIX": config.get( 

687 "ELASTIC_APM", "RUM_SERVER_URL_PREFIX", fallback=None 

688 ), 

689 } 

690 

691 script_options = [ 

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

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

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

695 ] 

696 

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

698 

699 if rum_server_url is None: 

700 script_options.append( 

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

702 ) 

703 elif rum_server_url: 

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

705 else: 

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

707 

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

709 script_options.append( 

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

711 ) 

712 

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

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

715 ) 

716 

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

718 sha256( 

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

720 ).digest() 

721 ).decode("ASCII") 

722 

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

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

725 

726 

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

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

729 with catch_warnings(): 

730 simplefilter("ignore", DeprecationWarning) 

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

732 from elastic_enterprise_search import ( # type: ignore[import-untyped] 

733 AppSearch, 

734 ) 

735 

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

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

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

739 verify_certs = config.getboolean( 

740 "APP_SEARCH", "VERIFY_CERTS", fallback=True 

741 ) 

742 app.settings["APP_SEARCH"] = ( 

743 AppSearch( 

744 host, 

745 bearer_auth=key, 

746 verify_certs=verify_certs, 

747 ca_certs=CA_BUNDLE_PATH, 

748 ) 

749 if host 

750 else None 

751 ) 

752 app.settings["APP_SEARCH_HOST"] = host 

753 app.settings["APP_SEARCH_KEY"] = key 

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

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

756 ) 

757 

758 

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

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

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

762 

763 class Kwargs(TypedDict, total=False): 

764 """Kwargs of BlockingConnectionPool constructor.""" 

765 

766 db: int 

767 username: None | str 

768 password: None | str 

769 retry_on_timeout: bool 

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

771 path: str 

772 host: str 

773 port: int 

774 ssl_ca_certs: str 

775 ssl_keyfile: None | str 

776 ssl_certfile: None | str 

777 ssl_check_hostname: bool 

778 ssl_cert_reqs: str 

779 

780 kwargs: Kwargs = { 

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

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

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

784 "retry_on_timeout": config.getboolean( 

785 "REDIS", "RETRY_ON_TIMEOUT", fallback=False 

786 ), 

787 } 

788 redis_ssl_kwargs: Kwargs = { 

789 "connection_class": SSLConnection, 

790 "ssl_ca_certs": CA_BUNDLE_PATH, 

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

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

793 "ssl_cert_reqs": config.get( 

794 "REDIS", "SSL_CERT_REQS", fallback="required" 

795 ), 

796 "ssl_check_hostname": config.getboolean( 

797 "REDIS", "SSL_CHECK_HOSTNAME", fallback=False 

798 ), 

799 } 

800 redis_host_port_kwargs: Kwargs = { 

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

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

803 } 

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

805 redis_unix_socket_path = config.get( 

806 "REDIS", "UNIX_SOCKET_PATH", fallback=None 

807 ) 

808 

809 if redis_unix_socket_path is not None: 

810 if redis_use_ssl: 

811 LOGGER.warning( 

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

813 ) 

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

815 LOGGER.warning( 

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

817 ) 

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

819 LOGGER.warning( 

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

821 ) 

822 kwargs.update( 

823 { 

824 "connection_class": UnixDomainSocketConnection, 

825 "path": redis_unix_socket_path, 

826 } 

827 ) 

828 else: 

829 kwargs.update(redis_host_port_kwargs) 

830 if redis_use_ssl: 

831 kwargs.update(redis_ssl_kwargs) 

832 

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

834 app.settings["REDIS"] = None 

835 return None 

836 connection_pool = BlockingConnectionPool( 

837 client_name=NAME, 

838 decode_responses=True, 

839 **kwargs, 

840 ) 

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

842 app.settings["REDIS"] = redis 

843 return redis 

844 

845 

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

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

848) -> None: 

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

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

851 EVENT_SHUTDOWN.set() 

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

853 EVENT_SHUTDOWN.set() 

854 

855 

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

857 """Install the signal handler.""" 

858 signal.signal(signal.SIGINT, signal_handler) 

859 signal.signal(signal.SIGTERM, signal_handler) 

860 if hasattr(signal, "SIGHUP"): 

861 signal.signal(signal.SIGHUP, signal_handler) 

862 

863 

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

865 """Supervise.""" 

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

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

868 worker = task_id() 

869 pid = os.getpid() 

870 

871 task = asyncio.current_task(loop) 

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

873 

874 LOGGER.fatal( 

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

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

877 worker, 

878 pid, 

879 request, 

880 task, 

881 ) 

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

883 os.abort() 

884 time.sleep(1) 

885 

886 

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

888 config: BetterConfigParser | None = None, 

889) -> int | str: 

890 """ 

891 Start everything. 

892 

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

894 """ 

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

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

897 setproctitle(NAME) 

898 

899 install_signal_handler() 

900 

901 parser = create_argument_parser() 

902 args, _ = parser.parse_known_args( 

903 get_arguments_without_help(), ArgparseNamespace() 

904 ) 

905 

906 if args.version: 

907 print("Version:", VERSION) 

908 if args.verbose: 

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

910 from .version.version import ( 

911 get_file_hashes, 

912 get_hash_of_file_hashes, 

913 ) 

914 

915 print() 

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

917 print(get_hash_of_file_hashes()) 

918 

919 if args.verbose > 1: 

920 print() 

921 print("Datei-Hashes:") 

922 print(get_file_hashes()) 

923 

924 return 0 

925 

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

927 assert config is not None 

928 config.add_override_argument_parser(parser) 

929 

930 setup_logging(config) 

931 

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

933 

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

935 LOGGER.warning( 

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

937 NAME.removesuffix("-dev"), 

938 ) 

939 

940 ignore_modules(config) 

941 app = make_app(config) 

942 if isinstance(app, str): 

943 return app 

944 

945 apply_config_to_app(app, config) 

946 setup_elasticsearch(app) 

947 setup_app_search(app) 

948 setup_redis(app) 

949 setup_apm(app) 

950 

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

952 

953 server = HTTPServer( 

954 app, 

955 body_timeout=3600, 

956 decompress_request=True, 

957 max_body_size=1_000_000_000, 

958 ssl_options=get_ssl_context(config), 

959 xheaders=behind_proxy, 

960 ) 

961 

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

963 

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

965 

966 if port: 

967 socket_factories.append( 

968 partial( 

969 bind_sockets, 

970 port, 

971 "localhost" if behind_proxy else "", 

972 ) 

973 ) 

974 

975 unix_socket_path = config.get( 

976 "GENERAL", 

977 "UNIX_SOCKET_PATH", 

978 fallback=None, 

979 ) 

980 

981 if unix_socket_path: 

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

983 socket_factories.append( 

984 lambda: ( 

985 bind_unix_socket( 

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

987 mode=0o666, 

988 ), 

989 ) 

990 ) 

991 

992 processes = config.getint( 

993 "GENERAL", 

994 "PROCESSES", 

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

996 ) 

997 

998 if processes < 0: 

999 processes = ( 

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

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

1002 else os.cpu_count() 

1003 ) or 0 

1004 

1005 worker: None | int = None 

1006 

1007 run_supervisor_thread = config.getboolean( 

1008 "GENERAL", "SUPERVISE", fallback=False 

1009 ) 

1010 elasticsearch_is_enabled = config.getboolean( 

1011 "ELASTICSEARCH", "ENABLED", fallback=False 

1012 ) 

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

1014 webhook_logging_options = WebhookLoggingOptions(config) 

1015 # all config options should be read before forking 

1016 if args.save_config_to: 

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

1018 config.write(file) 

1019 config.set_all_options_should_be_parsed() 

1020 del config 

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

1022 parser.parse_args() 

1023 

1024 if not socket_factories: 

1025 LOGGER.warning("No sockets configured") 

1026 return 0 

1027 

1028 # create sockets after checking for --help 

1029 sockets: list[socket] = ( 

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

1031 ) 

1032 

1033 UPTIME.reset() 

1034 main_pid = os.getpid() 

1035 

1036 if processes: 

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

1038 

1039 worker = fork_processes(processes) 

1040 

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

1042 

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

1044 _children.clear() 

1045 

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

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

1048 AUTHORS_CACHE, 

1049 QUOTES_CACHE, 

1050 WRONG_QUOTES_CACHE, 

1051 ) 

1052 

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

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

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

1056 del (geoip.__kwdefaults__ or {})["caches"].control.created_by_ultra 

1057 

1058 if unix_socket_path: 

1059 sockets.append( 

1060 bind_unix_socket( 

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

1062 mode=0o666, 

1063 ) 

1064 ) 

1065 

1066 # get loop after forking 

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

1068 loop: None | asyncio.AbstractEventLoop 

1069 try: 

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

1071 simplefilter("ignore", DeprecationWarning) 

1072 loop = asyncio.get_event_loop() 

1073 if loop.is_closed(): 

1074 loop = None 

1075 except RuntimeError: 

1076 loop = None 

1077 

1078 if loop is None: 

1079 loop = asyncio.new_event_loop() 

1080 asyncio.set_event_loop(loop) 

1081 

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

1083 loop.set_task_factory(asyncio.eager_task_factory) 

1084 

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

1086 loop.run_until_complete(perf8.enable()) 

1087 

1088 setup_webhook_logging(webhook_logging_options, loop) 

1089 

1090 server.add_sockets(sockets) 

1091 

1092 tasks = background_tasks.start_background_tasks( # noqa: F841 

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

1094 loop=loop, 

1095 main_pid=main_pid, 

1096 app=app, 

1097 processes=processes, 

1098 elasticsearch_is_enabled=elasticsearch_is_enabled, 

1099 redis_is_enabled=redis_is_enabled, 

1100 worker=worker, 

1101 ) 

1102 

1103 if run_supervisor_thread: 

1104 background_tasks.HEARTBEAT = time.monotonic() 

1105 threading.Thread( 

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

1107 ).start() 

1108 

1109 try: 

1110 loop.run_forever() 

1111 EVENT_SHUTDOWN.set() 

1112 finally: 

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

1114 server.stop() 

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

1116 loop.run_until_complete(server.close_all_connections()) 

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

1118 loop.run_until_complete(perf8.disable()) 

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

1120 loop.run_until_complete( 

1121 redis.aclose(close_connection_pool=True) 

1122 ) 

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

1124 loop.run_until_complete(elasticsearch.close()) 

1125 finally: 

1126 try: 

1127 _cancel_all_tasks(loop) 

1128 loop.run_until_complete(loop.shutdown_asyncgens()) 

1129 loop.run_until_complete(loop.shutdown_default_executor()) 

1130 finally: 

1131 loop.close() 

1132 background_tasks.HEARTBEAT = 0 

1133 

1134 return len(tasks)