Coverage for an_website/main.py: 81.224%

245 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-07 13: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 elastic_enterprise_search import AppSearch # type: ignore[import-untyped] 

54from elasticapm.contrib.tornado import ElasticAPM 

55from redis.asyncio import ( 

56 BlockingConnectionPool, 

57 Redis, 

58 SSLConnection, 

59 UnixDomainSocketConnection, 

60) 

61from setproctitle import setproctitle 

62from tornado.httpserver import HTTPServer 

63from tornado.log import LogFormatter 

64from tornado.netutil import bind_sockets, bind_unix_socket 

65from tornado.process import fork_processes, task_id 

66from tornado.web import Application, RedirectHandler 

67from typed_stream import Stream 

68 

69from . import ( 

70 CA_BUNDLE_PATH, 

71 DIR, 

72 EVENT_SHUTDOWN, 

73 NAME, 

74 TEMPLATES_DIR, 

75 UPTIME, 

76 VERSION, 

77 pytest_is_running, 

78) 

79from .contact.contact import apply_contact_stuff_to_app 

80from .utils import background_tasks, static_file_handling 

81from .utils.base_request_handler import BaseRequestHandler, request_ctx_var 

82from .utils.better_config_parser import BetterConfigParser 

83from .utils.elasticsearch_setup import setup_elasticsearch 

84from .utils.logging import WebhookFormatter, WebhookHandler 

85from .utils.request_handler import NotFoundHandler 

86from .utils.static_file_from_traversable import TraversableStaticFileHandler 

87from .utils.template_loader import TemplateLoader 

88from .utils.utils import ( 

89 ArgparseNamespace, 

90 Handler, 

91 ModuleInfo, 

92 Permission, 

93 Timer, 

94 create_argument_parser, 

95 geoip, 

96 get_arguments_without_help, 

97 time_function, 

98) 

99 

100try: 

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

102except ModuleNotFoundError: 

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

104 

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

106 "patches", 

107 "static", 

108 "templates", 

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

110 

111LOGGER: Final = logging.getLogger(__name__) 

112 

113 

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

115# this calls the get_module_info function in every file 

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

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

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

119 module_infos: list[ModuleInfo] = [] 

120 loaded_modules: list[str] = [] 

121 errors: list[str] = [] 

122 

123 for potential_module in DIR.iterdir(): 

124 if ( 

125 potential_module.name.startswith("_") 

126 or potential_module.name in IGNORED_MODULES 

127 or not potential_module.is_dir() 

128 ): 

129 continue 

130 

131 _module_infos = get_module_infos_from_module( 

132 potential_module.name, errors, ignore_not_found=True 

133 ) 

134 if _module_infos: 

135 module_infos.extend(_module_infos) 

136 loaded_modules.append(potential_module.name) 

137 LOGGER.debug( 

138 ( 

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

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

141 ), 

142 potential_module, 

143 ) 

144 continue 

145 

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

147 continue 

148 

149 for potential_file in potential_module.iterdir(): 

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

151 if ( 

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

153 or module_name in IGNORED_MODULES 

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

155 ): 

156 continue 

157 _module_infos = get_module_infos_from_module(module_name, errors) 

158 if _module_infos: 

159 module_infos.extend(_module_infos) 

160 loaded_modules.append(module_name) 

161 

162 if len(errors) > 0: 

163 if sys.flags.dev_mode: 

164 # exit to make sure it gets fixed 

165 return "\n".join(errors) 

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

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

168 

169 LOGGER.info( 

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

171 len(loaded_modules), 

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

173 ) 

174 

175 LOGGER.info( 

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

177 len(IGNORED_MODULES), 

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

179 ) 

180 

181 sort_module_infos(module_infos) 

182 

183 # make module_infos immutable so it never changes 

184 return tuple(module_infos) 

185 

186 

187def get_module_infos_from_module( 

188 module_name: str, 

189 errors: MutableSequence[str], # gets modified 

190 ignore_not_found: bool = False, 

191) -> None | list[ModuleInfo]: 

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

193 import_timer = Timer() 

194 module = importlib.import_module( 

195 f".{module_name}", 

196 package="an_website", 

197 ) 

198 if import_timer.stop() > 0.1: 

199 LOGGER.warning( 

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

201 module_name, 

202 import_timer.get(), 

203 ) 

204 

205 module_infos: list[ModuleInfo] = [] 

206 

207 has_get_module_info = "get_module_info" in dir(module) 

208 has_get_module_infos = "get_module_infos" in dir(module) 

209 

210 if not (has_get_module_info or has_get_module_infos): 

211 if ignore_not_found: 

212 return None 

213 errors.append( 

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

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

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

217 "IGNORED_MODULES." 

218 ) 

219 return None 

220 

221 if has_get_module_info and isinstance( 

222 module_info := module.get_module_info(), 

223 ModuleInfo, 

224 ): 

225 module_infos.append(module_info) 

226 elif has_get_module_info: 

227 errors.append( 

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

229 "Please fix the returned value." 

230 ) 

231 

232 if not has_get_module_infos: 

233 return module_infos or None 

234 

235 _module_infos = module.get_module_infos() 

236 

237 if not isinstance(_module_infos, Iterable): 

238 errors.append( 

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

240 "Please fix the returned value." 

241 ) 

242 return module_infos or None 

243 

244 for _module_info in _module_infos: 

245 if isinstance(_module_info, ModuleInfo): 

246 module_infos.append(_module_info) 

247 else: 

248 errors.append( 

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

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

251 "Please fix the returned value." 

252 ) 

253 

254 return module_infos or None 

255 

256 

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

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

259 # sort it so the order makes sense 

260 module_infos.sort() 

261 

262 # move the main page to the top 

263 for i, info in enumerate(module_infos): 

264 if info.path == "/": 

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

266 break 

267 

268 

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

270 """ 

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

272 

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

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

275 """ 

276 handler: Handler | list[Any] 

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

278 

279 # add all the normal handlers 

280 for module_info in module_infos: 

281 for handler in module_info.handlers: 

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

283 # if the handler is a request handler from us 

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

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

286 if len(handler) == 2: 

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

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

289 handler.append( 

290 { 

291 "default_title": False, 

292 "default_description": False, 

293 "module_info": module_info, 

294 } 

295 ) 

296 else: 

297 handler[2]["module_info"] = module_info 

298 handlers.append(tuple(handler)) 

299 

300 # redirect handler, to make finding APIs easier 

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

302 

303 handlers.append( 

304 ( 

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

306 TraversableStaticFileHandler, 

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

308 ) 

309 ) 

310 

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

312 

313 return handlers 

314 

315 

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

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

318 IGNORED_MODULES.update( 

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

320 ) 

321 

322 

323def get_normed_paths_from_module_infos( 

324 module_infos: Iterable[ModuleInfo], 

325) -> dict[str, str]: 

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

327 

328 def tuple_has_no_none( 

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

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

331 return None not in value 

332 

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

334 return ( 

335 Stream(((info.path, info.path),)) 

336 .chain( 

337 info.aliases.items() 

338 if isinstance(info.aliases, Mapping) 

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

340 ) 

341 .chain( 

342 Stream(info.sub_pages) 

343 .map(lambda sub_info: sub_info.path) 

344 .filter() 

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

346 ) 

347 .filter(tuple_has_no_none) 

348 ) 

349 

350 return ( 

351 Stream(module_infos) 

352 .flat_map(info_to_paths) 

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

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

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

356 .collect(dict) 

357 ) 

358 

359 

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

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

362 module_infos, duration = time_function(get_module_infos) 

363 if isinstance(module_infos, str): 

364 return module_infos 

365 if duration > 1: 

366 LOGGER.warning( 

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

368 duration, 

369 ) 

370 handlers = get_all_handlers(module_infos) 

371 return Application( 

372 handlers, # type: ignore[arg-type] 

373 MODULE_INFOS=module_infos, 

374 SHOW_HAMBURGER_MENU=not Stream(module_infos) 

375 .exclude(lambda info: info.hidden) 

376 .filter(lambda info: info.path) 

377 .empty(), 

378 NORMED_PATHS=get_normed_paths_from_module_infos(module_infos), 

379 HANDLERS=handlers, 

380 # General settings 

381 autoreload=False, 

382 debug=sys.flags.dev_mode, 

383 default_handler_class=NotFoundHandler, 

384 compress_response=config.getboolean( 

385 "GENERAL", "COMPRESS_RESPONSE", fallback=False 

386 ), 

387 websocket_ping_interval=10, 

388 # Template settings 

389 template_loader=TemplateLoader( 

390 root=TEMPLATES_DIR, whitespace="oneline" 

391 ), 

392 ) 

393 

394 

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

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

397 app.settings["CONFIG"] = config 

398 

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

400 "GENERAL", "COOKIE_SECRET", fallback="xyzzy" 

401 ) 

402 

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

404 "APP_SEARCH", "CRAWLER_SECRET", fallback=None 

405 ) 

406 

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

408 

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

410 "ELASTICSEARCH", "PREFIX", fallback=NAME 

411 ) 

412 

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

414 

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

416 "GENERAL", "NETCUP", fallback=False 

417 ) 

418 

419 app.settings["COMMITMENT_URI"] = config.get( 

420 "GENERAL", 

421 "COMMITMENT_URI", 

422 fallback="https://github.asozial.org/an-website/commitment.txt", 

423 ) 

424 

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

426 app.settings["ONION_ADDRESS"] = onion_address 

427 if onion_address is None: 

428 app.settings["ONION_PROTOCOL"] = None 

429 else: 

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

431 

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

433 "GENERAL", 

434 "RATELIMITS", 

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

436 ) 

437 

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

439 

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

441 "REPORTING", "ENABLED", fallback=True 

442 ) 

443 

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

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

446 ) 

447 

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

449 "REPORTING", 

450 "ENDPOINT", 

451 fallback=( 

452 "/api/reports" 

453 if app.settings["REPORTING_BUILTIN"] 

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

455 ), 

456 ) 

457 

458 app.settings["TRUSTED_API_SECRETS"] = { 

459 key_perms[0]: Permission( 

460 int(key_perms[1]) 

461 if len(key_perms) > 1 

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

463 ) 

464 for secret in config.getset( 

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

466 ) 

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

468 if key_perms[0] 

469 } 

470 

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

472 "GENERAL", "AUTH_TOKEN_SECRET", fallback=None 

473 ) 

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

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

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

477 LOGGER.warning( 

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

479 secret, 

480 ) 

481 app.settings["AUTH_TOKEN_SECRET"] = secret 

482 

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

484 "GENERAL", "UNDER_ATTACK", fallback=False 

485 ) 

486 

487 apply_contact_stuff_to_app(app, config) 

488 

489 

490def get_ssl_context( # pragma: no cover 

491 config: ConfigParser, 

492) -> None | ssl.SSLContext: 

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

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

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

496 ssl_ctx.load_cert_chain( 

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

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

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

500 ) 

501 return ssl_ctx 

502 return None 

503 

504 

505def setup_logging( # pragma: no cover 

506 config: ConfigParser, 

507 force: bool = False, 

508) -> None: 

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

510 root_logger = logging.getLogger() 

511 

512 if root_logger.handlers: 

513 if not force: 

514 return 

515 for handler in root_logger.handlers[:]: 

516 root_logger.removeHandler(handler) 

517 handler.close() 

518 

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

520 

521 logging.captureWarnings(True) 

522 

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

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

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

526 

527 stream_handler = logging.StreamHandler() 

528 if sys.flags.dev_mode: 

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

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

531 else: 

532 formatter = LogFormatter() 

533 stream_handler.setFormatter(formatter) 

534 root_logger.addHandler(stream_handler) 

535 

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

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

538 file_handler = logging.handlers.TimedRotatingFileHandler( 

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

540 encoding="UTF-8", 

541 when="midnight", 

542 backupCount=30, 

543 utc=True, 

544 ) 

545 file_handler.setFormatter(StdlibFormatter()) 

546 root_logger.addHandler(file_handler) 

547 

548 

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

550 """Webhook logging options.""" 

551 

552 __slots__ = ( 

553 "url", 

554 "content_type", 

555 "body_format", 

556 "timestamp_format", 

557 "timestamp_timezone", 

558 "escape_message", 

559 "max_message_length", 

560 ) 

561 

562 url: str | None 

563 content_type: str 

564 body_format: str 

565 timestamp_format: str | None 

566 timestamp_timezone: str | None 

567 escape_message: bool 

568 max_message_length: int | None 

569 

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

571 """Initialize Webhook logging options.""" 

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

573 self.content_type = config.get( 

574 "LOGGING", 

575 "WEBHOOK_CONTENT_TYPE", 

576 fallback="application/json", 

577 ) 

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

579 self.body_format = config.get( 

580 "LOGGING", 

581 "WEBHOOK_BODY_FORMAT", 

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

583 ) 

584 self.timestamp_format = config.get( 

585 "LOGGING", 

586 "WEBHOOK_TIMESTAMP_FORMAT", 

587 fallback=None, 

588 ) 

589 self.timestamp_timezone = config.get( 

590 "LOGGING", "WEBHOOK_TIMESTAMP_TIMEZONE", fallback=None 

591 ) 

592 self.escape_message = config.getboolean( 

593 "LOGGING", 

594 "WEBHOOK_ESCAPE_MESSAGE", 

595 fallback=True, 

596 ) 

597 self.max_message_length = config.getint( 

598 "LOGGING", "WEBHOOK_MAX_MESSAGE_LENGTH", fallback=None 

599 ) 

600 

601 

602def setup_webhook_logging( # pragma: no cover 

603 options: WebhookLoggingOptions, 

604 loop: asyncio.AbstractEventLoop, 

605) -> None: 

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

607 if not options.url: 

608 return 

609 

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

611 

612 root_logger = logging.getLogger() 

613 

614 webhook_content_type = options.content_type 

615 webhook_handler = WebhookHandler( 

616 logging.ERROR, 

617 loop=loop, 

618 url=options.url, 

619 content_type=webhook_content_type, 

620 ) 

621 formatter = WebhookFormatter( 

622 options.body_format, 

623 options.timestamp_format, 

624 ) 

625 formatter.timezone = ( 

626 None 

627 if options.timestamp_format is None 

628 else ZoneInfo(options.timestamp_format) 

629 ) 

630 formatter.escape_message = options.escape_message 

631 formatter.max_message_length = options.max_message_length 

632 webhook_handler.setFormatter(formatter) 

633 root_logger.addHandler(webhook_handler) 

634 

635 info_handler = WebhookHandler( 

636 logging.INFO, 

637 loop=loop, 

638 url=options.url, 

639 content_type=webhook_content_type, 

640 ) 

641 info_handler.setFormatter(formatter) 

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

643 

644 

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

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

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

648 app.settings["ELASTIC_APM"] = { 

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

650 "SERVER_URL": config.get( 

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

652 ), 

653 "SECRET_TOKEN": config.get( 

654 "ELASTIC_APM", "SECRET_TOKEN", fallback=None 

655 ), 

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

657 "VERIFY_SERVER_CERT": config.getboolean( 

658 "ELASTIC_APM", "VERIFY_SERVER_CERT", fallback=True 

659 ), 

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

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

662 "SERVICE_VERSION": VERSION, 

663 "ENVIRONMENT": ( 

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

665 ), 

666 "DEBUG": True, 

667 "CAPTURE_BODY": "errors", 

668 "TRANSACTION_IGNORE_URLS": [ 

669 "/api/ping", 

670 "/static/*", 

671 "/favicon.png", 

672 ], 

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

674 "PROCESSORS": [ 

675 "an_website.utils.utils.apm_anonymization_processor", 

676 "elasticapm.processors.sanitize_stacktrace_locals", 

677 "elasticapm.processors.sanitize_http_request_cookies", 

678 "elasticapm.processors.sanitize_http_headers", 

679 "elasticapm.processors.sanitize_http_wsgi_env", 

680 "elasticapm.processors.sanitize_http_request_body", 

681 ], 

682 "RUM_SERVER_URL": config.get( 

683 "ELASTIC_APM", "RUM_SERVER_URL", fallback=None 

684 ), 

685 "RUM_SERVER_URL_PREFIX": config.get( 

686 "ELASTIC_APM", "RUM_SERVER_URL_PREFIX", fallback=None 

687 ), 

688 } 

689 

690 script_options = [ 

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

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

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

694 ] 

695 

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

697 

698 if rum_server_url is None: 

699 script_options.append( 

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

701 ) 

702 elif rum_server_url: 

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

704 else: 

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

706 

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

708 script_options.append( 

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

710 ) 

711 

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

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

714 ) 

715 

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

717 sha256( 

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

719 ).digest() 

720 ).decode("ASCII") 

721 

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

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

724 

725 

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

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

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)