Coverage for an_website/utils/base_request_handler.py: 79.032%
496 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-01 07:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-01 07:01 +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=too-many-lines
15"""
16The base request handler used by other modules.
18This should only contain the BaseRequestHandler class.
19"""
21import contextlib
22import inspect
23import logging
24import secrets
25import sys
26import traceback
27import uuid
28from asyncio import Future
29from base64 import b64decode
30from collections.abc import Awaitable, Callable, Coroutine, Mapping
31from contextvars import ContextVar
32from datetime import date, datetime, timedelta, timezone, tzinfo
33from functools import cached_property, partial, reduce
34from random import Random, choice as random_choice
35from types import TracebackType
36from typing import Any, ClassVar, Final, cast, override
37from urllib.parse import SplitResult, urlsplit, urlunsplit
38from zoneinfo import ZoneInfo
40import elasticapm
41import html2text
42import orjson as json
43import regex
44import tornado.web
45import yaml
46from accept_types import get_best_match # type: ignore[import-untyped]
47from ansi2html import Ansi2HTMLConverter
48from bs4 import BeautifulSoup
49from dateutil.easter import easter
50from elastic_transport import ApiError, TransportError
51from elasticsearch import AsyncElasticsearch
52from openmoji_dist import VERSION as OPENMOJI_VERSION
53from redis.asyncio import Redis
54from tornado.httputil import HTTPServerRequest
55from tornado.iostream import StreamClosedError
56from tornado.web import (
57 Finish,
58 GZipContentEncoding,
59 HTTPError,
60 MissingArgumentError,
61 OutputTransform,
62)
64from .. import (
65 EVENT_ELASTICSEARCH,
66 EVENT_REDIS,
67 GH_ORG_URL,
68 GH_PAGES_URL,
69 GH_REPO_URL,
70 NAME,
71 ORJSON_OPTIONS,
72 pytest_is_running,
73)
74from .decorators import is_authorized
75from .options import ColourScheme, Options
76from .static_file_handling import FILE_HASHES_DICT, fix_static_path
77from .themes import RANDOM_THEMES
78from .utils import (
79 ModuleInfo,
80 Permission,
81 add_args_to_url,
82 ansi_replace,
83 apply,
84 backspace_replace,
85 bool_to_str,
86 emoji2html,
87 geoip,
88 hash_bytes,
89 is_prime,
90 ratelimit,
91 str_to_bool,
92)
94LOGGER: Final = logging.getLogger(__name__)
96TEXT_CONTENT_TYPES: Final[set[str]] = {
97 "application/javascript",
98 "application/json",
99 "application/vnd.asozial.dynload+json",
100 "application/x-ndjson",
101 "application/xml",
102 "application/yaml",
103}
105CLACKS_OVERHEADS = (
106 "GNU Aaron Swartz",
107 "GNU Carol Angie Deborah Maltesi",
108 "GNU Charlotte Angie",
109 "GNU Terry Pratchett",
110)
112request_ctx_var: ContextVar[HTTPServerRequest] = ContextVar("current_request")
115class _RequestHandler(tornado.web.RequestHandler):
116 """Base for Tornado request handlers."""
118 crawler: bool = False
120 @override
121 async def _execute(
122 self, transforms: list[OutputTransform], *args: bytes, **kwargs: bytes
123 ) -> None:
124 request_ctx_var.set(self.request)
126 self.now = await self.get_time()
128 return await super()._execute(transforms, *args, **kwargs)
130 # pylint: disable-next=protected-access
131 _execute.__doc__ = tornado.web.RequestHandler._execute.__doc__
133 @property
134 def apm_client(self) -> None | elasticapm.Client:
135 """Get the APM client from the settings."""
136 return self.settings.get("ELASTIC_APM", {}).get("CLIENT") # type: ignore[no-any-return]
138 @property
139 def apm_enabled(self) -> bool:
140 """Return whether APM is enabled."""
141 return bool(self.settings.get("ELASTIC_APM", {}).get("ENABLED"))
143 @override
144 def data_received( # noqa: D102
145 self, chunk: bytes
146 ) -> None | Awaitable[None]:
147 pass
149 data_received.__doc__ = tornado.web.RequestHandler.data_received.__doc__
151 @property
152 def elasticsearch(self) -> AsyncElasticsearch:
153 """
154 Get the Elasticsearch client from the settings.
156 This is None if Elasticsearch is not enabled.
157 """
158 return cast(AsyncElasticsearch, self.settings.get("ELASTICSEARCH"))
160 @property
161 def elasticsearch_prefix(self) -> str:
162 """Get the Elasticsearch prefix from the settings."""
163 return self.settings.get( # type: ignore[no-any-return]
164 "ELASTICSEARCH_PREFIX", NAME
165 )
167 def geoip(
168 self,
169 ip: None | str = None,
170 database: str = geoip.__defaults__[0], # type: ignore[index]
171 *,
172 allow_fallback: bool = True,
173 ) -> Coroutine[None, None, None | dict[str, Any]]:
174 """Get GeoIP information."""
175 if not ip:
176 ip = self.request.remote_ip
177 if not EVENT_ELASTICSEARCH.is_set():
178 return geoip(ip, database)
179 return geoip(
180 ip, database, self.elasticsearch, allow_fallback=allow_fallback
181 )
183 async def get_time(self) -> datetime:
184 """Get the start time of the request in the users' timezone."""
185 tz: tzinfo = timezone.utc
186 try:
187 geoip = await self.geoip() # pylint: disable=redefined-outer-name
188 except ApiError, TransportError:
189 LOGGER.exception("Elasticsearch request failed")
190 if self.apm_client:
191 self.apm_client.capture_exception() # type: ignore[no-untyped-call]
192 else:
193 if geoip and "timezone" in geoip:
194 tz = ZoneInfo(geoip["timezone"])
195 return datetime.fromtimestamp(
196 self.request._start_time, tz=tz # pylint: disable=protected-access
197 )
199 def is_authorized(
200 self, permission: Permission, allow_cookie_auth: bool = True
201 ) -> bool | None:
202 """Check whether the request is authorized."""
203 return is_authorized(self, permission, allow_cookie_auth)
205 @override
206 def log_exception(
207 self,
208 typ: None | type[BaseException],
209 value: None | BaseException,
210 tb: None | TracebackType,
211 ) -> None:
212 if isinstance(value, HTTPError):
213 super().log_exception(typ, value, tb)
214 elif typ is StreamClosedError:
215 LOGGER.debug(
216 "Stream closed %s",
217 self._request_summary(),
218 exc_info=(typ, value, tb), # type: ignore[arg-type]
219 )
220 else:
221 LOGGER.error(
222 "Uncaught exception %s",
223 self._request_summary(),
224 exc_info=(typ, value, tb), # type: ignore[arg-type]
225 )
227 log_exception.__doc__ = tornado.web.RequestHandler.log_exception.__doc__
229 @cached_property
230 def now(self) -> datetime:
231 """Get the current time."""
232 if pytest_is_running():
233 raise AssertionError("Now accessed before it was set")
234 # if self.request.method in self.SUPPORTED_METHODS: # Why?
235 LOGGER.error("Now accessed before it was set", stacklevel=3)
236 return self.now_utc
238 @cached_property
239 def now_utc(self) -> datetime:
240 """Get the current time in the correct timezone."""
241 return datetime.fromtimestamp(
242 self.request._start_time, # pylint: disable=protected-access
243 tz=timezone.utc,
244 )
246 @override # pylint: disable-next=invalid-overridden-method
247 async def prepare(self) -> None:
248 """Check authorization and call self.ratelimit()."""
249 if crawler_secret := self.settings.get("CRAWLER_SECRET"):
250 self.crawler = crawler_secret in self.request.headers.get(
251 "User-Agent", ""
252 )
254 if (
255 self.request.method in {"GET", "HEAD"}
256 and self.redirect_to_canonical_domain()
257 ):
258 return
260 if self.request.method != "OPTIONS" and not await self.ratelimit(True):
261 await self.ratelimit()
263 async def ratelimit(self, global_ratelimit: bool = False) -> bool:
264 """Take b1nzy to space using Redis."""
265 if (
266 not self.settings.get("RATELIMITS")
267 or self.request.method == "OPTIONS"
268 or self.is_authorized(Permission.RATELIMITS)
269 or self.crawler
270 ):
271 return False
273 if not EVENT_REDIS.is_set():
274 LOGGER.warning(
275 (
276 "Ratelimits are enabled, but Redis is not available. "
277 "This can happen shortly after starting the website."
278 ),
279 )
280 raise HTTPError(503)
282 if global_ratelimit: # TODO: add to _RequestHandler
283 ratelimited, headers = await ratelimit(
284 self.redis,
285 self.redis_prefix,
286 str(self.request.remote_ip),
287 bucket=None,
288 max_burst=99, # limit = 100
289 count_per_period=20, # 20 requests per second
290 period=1,
291 tokens=10 if self.settings.get("UNDER_ATTACK") else 1,
292 )
293 else:
294 method = (
295 "GET" if self.request.method == "HEAD" else self.request.method
296 )
297 if not (limit := getattr(self, f"RATELIMIT_{method}_LIMIT", 0)):
298 return False
299 ratelimited, headers = await ratelimit(
300 self.redis,
301 self.redis_prefix,
302 str(self.request.remote_ip),
303 bucket=getattr(
304 self,
305 f"RATELIMIT_{method}_BUCKET",
306 self.__class__.__name__.lower(),
307 ),
308 max_burst=limit - 1,
309 count_per_period=getattr( # request count per period
310 self,
311 f"RATELIMIT_{method}_COUNT_PER_PERIOD",
312 30,
313 ),
314 period=getattr(
315 self, f"RATELIMIT_{method}_PERIOD", 60 # period in seconds
316 ),
317 tokens=1 if self.request.method != "HEAD" else 0,
318 )
320 for header, value in headers.items():
321 self.set_header(header, value)
323 if ratelimited:
324 if self.now.date() == date(self.now.year, 4, 20):
325 self.set_status(420)
326 self.write_error(420)
327 else:
328 self.set_status(429)
329 self.write_error(429)
331 return ratelimited
333 def redirect_to_canonical_domain(self) -> bool:
334 """Redirect to the canonical domain."""
335 if (
336 not (domain := self.settings.get("DOMAIN"))
337 or not self.request.headers.get("Host")
338 or self.request.host_name == domain
339 or self.request.host_name.endswith((".onion", ".i2p"))
340 or regex.fullmatch(r"/[\u2800-\u28FF]+/?", self.request.path)
341 ):
342 return False
343 port = urlsplit(f"//{self.request.headers['Host']}").port
344 self.redirect(
345 urlsplit(self.request.full_url())
346 ._replace(netloc=f"{domain}:{port}" if port else domain)
347 .geturl(),
348 permanent=True,
349 )
350 return True
352 @property
353 def redis(self) -> Redis[str]:
354 """
355 Get the Redis client from the settings.
357 This is None if Redis is not enabled.
358 """
359 return cast("Redis[str]", self.settings.get("REDIS"))
361 @property
362 def redis_prefix(self) -> str:
363 """Get the Redis prefix from the settings."""
364 return self.settings.get( # type: ignore[no-any-return]
365 "REDIS_PREFIX", NAME
366 )
369class BaseRequestHandler(_RequestHandler):
370 """The base request handler used by every page and API."""
372 # pylint: disable=too-many-instance-attributes, too-many-public-methods
374 ELASTIC_RUM_URL: ClassVar[str] = (
375 f"/@apm-rum/elastic-apm-rum.umd{'' if sys.flags.dev_mode else '.min'}.js"
376 "?v=5.12.0"
377 )
379 COMPUTE_ETAG: ClassVar[bool] = True
380 ALLOW_COMPRESSION: ClassVar[bool] = True
381 MAX_BODY_SIZE: ClassVar[None | int] = None
382 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET",)
383 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
385 module_info: ModuleInfo
386 # info about page, can be overridden in module_info
387 title: str = "Das Asoziale Netzwerk"
388 short_title: str = "Asoziales Netzwerk"
389 description: str = "Die tolle Webseite des Asozialen Netzwerks"
391 used_render: bool = False
393 active_origin_trials: set[str]
394 content_type: None | str = None
395 apm_script: None | str
396 nonce: str
398 def _finish(
399 self, chunk: None | str | bytes | dict[str, Any] = None
400 ) -> Future[None]:
401 if self._finished:
402 raise RuntimeError("finish() called twice")
404 if chunk is not None:
405 self.write(chunk)
407 if ( # pylint: disable=too-many-boolean-expressions
408 (content_type := self.content_type)
409 and (
410 content_type in TEXT_CONTENT_TYPES
411 or content_type.startswith("text/")
412 or content_type.endswith(("+xml", "+json"))
413 )
414 and self._write_buffer
415 and not self._write_buffer[-1].endswith(b"\n")
416 ):
417 self.write(b"\n")
419 return super().finish()
421 @override
422 def compute_etag(self) -> None | str:
423 """Compute ETag with Base85 encoding."""
424 if not self.COMPUTE_ETAG:
425 return None
426 return f'"{hash_bytes(*self._write_buffer)}"' # noqa: B907
428 @override
429 def decode_argument( # noqa: D102
430 self, value: bytes, name: str | None = None
431 ) -> str:
432 try:
433 return value.decode("UTF-8", "replace")
434 except UnicodeDecodeError as exc:
435 err_msg = f"Invalid unicode in {name or 'url'}: {value[:40]!r}"
436 LOGGER.exception(err_msg, exc_info=exc)
437 raise HTTPError(400, err_msg) from exc
439 @property
440 def dump(self) -> Callable[[Any], str | bytes]:
441 """Get the function for dumping the output."""
442 yaml_subset = self.content_type in {
443 "application/json",
444 "application/vnd.asozial.dynload+json",
445 }
447 if self.content_type == "application/yaml":
448 if self.now.timetuple()[2:0:-1] == (1, 4):
449 yaml_subset = True
450 else:
451 return lambda spam: yaml.dump(
452 spam,
453 width=self.get_int_argument("yaml_width", 80, min_=80),
454 )
456 if yaml_subset:
457 option = ORJSON_OPTIONS
458 if self.get_bool_argument("pretty", False):
459 option |= json.OPT_INDENT_2
460 return lambda spam: json.dumps(spam, option=option)
462 return lambda spam: spam
464 @override
465 def finish( # noqa: D102
466 self, chunk: None | str | bytes | dict[Any, Any] = None
467 ) -> Future[None]:
468 as_json = self.content_type == "application/vnd.asozial.dynload+json"
469 as_plain_text = self.content_type == "text/plain"
470 as_markdown = self.content_type == "text/markdown"
472 if (
473 not isinstance(chunk, bytes | str)
474 or self.content_type == "text/html"
475 or not self.used_render
476 or not (as_json or as_plain_text or as_markdown)
477 ):
478 return self._finish(chunk)
480 chunk = chunk.decode("UTF-8") if isinstance(chunk, bytes) else chunk
482 if as_markdown:
483 return self._finish(
484 f"# {self.title}\n\n"
485 + html2text.html2text(chunk, self.request.full_url()).strip()
486 )
488 soup = BeautifulSoup(chunk, features="lxml")
490 if as_plain_text:
491 return self._finish(soup.get_text("\n", True))
493 dictionary: dict[str, object] = {
494 "url": self.fix_url(include_protocol_and_host=True),
495 "title": self.title,
496 "short_title": (
497 self.short_title if self.title != self.short_title else None
498 ),
499 "body": "".join(
500 str(element)
501 for element in soup.find_all(name="main")[0].contents
502 ).strip(),
503 "scripts": [
504 {"script": script.string} | script.attrs
505 for script in soup.find_all("script")
506 ],
507 "stylesheets": [
508 stylesheet.get("href").strip()
509 for stylesheet in soup.find_all("link", rel="stylesheet")
510 ],
511 "css": "\n".join(style.string for style in soup.find_all("style")),
512 }
514 return self._finish(dictionary)
516 finish.__doc__ = _RequestHandler.finish.__doc__
518 def finish_dict(self, **kwargs: Any) -> Future[None]:
519 """Finish the request with a dictionary."""
520 return self.finish(kwargs)
522 def fix_url(
523 self,
524 url: None | str | SplitResult = None,
525 new_path: None | str = None,
526 include_protocol_and_host: bool | str = False,
527 query_args: Mapping[str, None | str | bool | float] | None = None,
528 ) -> str:
529 """
530 Fix a URL and return it.
532 If the URL is from another website, link to it with the redirect page,
533 otherwise just return the URL with no_3rd_party appended.
534 """
535 query_args_d = dict(query_args or {})
536 del query_args
537 if url is None:
538 url = self.request.full_url()
539 if isinstance(url, str):
540 url = urlsplit(url)
541 if url.netloc and url.netloc.lower() != self.request.host.lower():
542 if (
543 not self.user_settings.ask_before_leaving
544 or not self.settings.get("REDIRECT_MODULE_LOADED")
545 ):
546 return url.geturl()
547 path = "/redirect"
548 query_args_d["to"] = url.geturl()
549 url = urlsplit(self.request.full_url())
550 else:
551 path = url.path if new_path is None else new_path
552 path = f"/{path.strip('/')}".lower()
553 if path == "/lolwut":
554 path = path.upper()
555 if path.startswith("/soundboard/files/") or path in FILE_HASHES_DICT:
556 query_args_d.update(
557 dict.fromkeys(self.user_settings.iter_option_names())
558 )
559 else:
560 for (
561 key,
562 value,
563 ) in self.user_settings.as_dict_with_str_values().items():
564 query_args_d.setdefault(key, value)
565 for key, value in self.user_settings.as_dict_with_str_values(
566 include_query_argument=False,
567 include_body_argument=self.request.path == "/einstellungen"
568 and self.get_bool_argument("save_in_cookie", False),
569 ).items():
570 if value == query_args_d[key]:
571 query_args_d[key] = None
573 result = add_args_to_url(
574 urlunsplit(
575 (
576 self.request.protocol,
577 self.request.host,
578 path,
579 url.query,
580 url.fragment,
581 )
582 ),
583 **query_args_d,
584 )
586 return (
587 result
588 if include_protocol_and_host
589 else result.removeprefix(
590 f"{self.request.protocol}://{self.request.host}"
591 )
592 )
594 @classmethod
595 def get_allowed_methods(cls) -> list[str]:
596 """Get allowed methods."""
597 methods = {"OPTIONS", *cls.ALLOWED_METHODS}
598 if "GET" in cls.ALLOWED_METHODS and cls.supports_head():
599 methods.add("HEAD")
600 return sorted(methods)
602 def get_bool_argument(
603 self,
604 name: str,
605 default: None | bool = None,
606 ) -> bool:
607 """Get an argument parsed as boolean."""
608 if default is not None:
609 return str_to_bool(self.get_argument(name, ""), default)
610 value = str(self.get_argument(name))
611 try:
612 return str_to_bool(value)
613 except ValueError as err:
614 raise HTTPError(400, f"{value} is not a boolean") from err
616 def get_display_scheme(self) -> ColourScheme:
617 """Get the scheme currently displayed."""
618 scheme = self.user_settings.scheme
619 if scheme == "random":
620 return ("light", "dark")[self.now.microsecond & 1]
621 return scheme
623 def get_display_theme(self) -> str:
624 """Get the theme currently displayed."""
625 theme = self.user_settings.theme
627 if theme == "default" and self.now.month == 12:
628 return "christmas"
630 if theme != "random":
631 return theme
633 return random_choice(RANDOM_THEMES) # nosec: B311
635 def get_error_message(self, **kwargs: Any) -> str:
636 """
637 Get the error message and return it.
639 If the serve_traceback setting is true (debug mode is activated),
640 the traceback gets returned.
641 """
642 if "exc_info" in kwargs and not issubclass(
643 kwargs["exc_info"][0], HTTPError
644 ):
645 if self.settings.get("serve_traceback") or self.is_authorized(
646 Permission.TRACEBACK
647 ):
648 return "".join(
649 traceback.format_exception(*kwargs["exc_info"])
650 ).strip()
651 return "".join(
652 traceback.format_exception_only(*kwargs["exc_info"][:2])
653 ).strip()
654 if "exc_info" in kwargs and issubclass(
655 kwargs["exc_info"][0], MissingArgumentError
656 ):
657 return cast(str, kwargs["exc_info"][1].log_message)
658 return str(self._reason)
660 def get_error_page_description(self, status_code: int) -> str:
661 """Get the description for the error page."""
662 # pylint: disable=too-many-return-statements
663 # https://developer.mozilla.org/docs/Web/HTTP/Status
664 if 100 <= status_code <= 199:
665 return "Hier gibt es eine total wichtige Information."
666 if 200 <= status_code <= 299:
667 return "Hier ist alles super! 🎶🎶"
668 if 300 <= status_code <= 399:
669 return "Eine Umleitung ist eingerichtet."
670 if 400 <= status_code <= 499:
671 if status_code == 404:
672 return f"{self.request.path} wurde nicht gefunden."
673 if status_code == 451:
674 return "Hier wäre bestimmt geiler Scheiß."
675 return "Ein Client-Fehler ist aufgetreten."
676 if 500 <= status_code <= 599:
677 return "Ein Server-Fehler ist aufgetreten."
678 raise ValueError(
679 f"{status_code} is not a valid HTTP response status code."
680 )
682 def get_int_argument(
683 self,
684 name: str,
685 default: None | int = None,
686 *,
687 max_: None | int = None,
688 min_: None | int = None,
689 ) -> int:
690 """Get an argument parsed as integer."""
691 if default is None:
692 str_value = self.get_argument(name)
693 try:
694 value = int(str_value, base=0)
695 except ValueError as err:
696 raise HTTPError(400, f"{str_value} is not an integer") from err
697 elif self.get_argument(name, ""):
698 try:
699 value = int(self.get_argument(name), base=0)
700 except ValueError:
701 value = default
702 else:
703 value = default
705 if max_ is not None:
706 value = min(max_, value)
707 if min_ is not None:
708 value = max(min_, value)
710 return value
712 def get_module_infos(self) -> tuple[ModuleInfo, ...]:
713 """Get the module infos."""
714 return self.settings.get("MODULE_INFOS") or ()
716 def get_reporting_api_endpoint(self) -> None | str:
717 """Get the endpoint for the Reporting API™️."""
718 if not self.settings.get("REPORTING"):
719 return None
720 endpoint = self.settings.get("REPORTING_ENDPOINT")
722 if not endpoint or not endpoint.startswith("/"):
723 return endpoint
725 return f"{self.request.protocol}://{self.request.host}{endpoint}"
727 @override
728 def get_template_namespace(self) -> dict[str, Any]:
729 """
730 Add useful things to the template namespace and return it.
732 They are mostly needed by most of the pages (like title,
733 description and no_3rd_party).
734 """
735 namespace = super().get_template_namespace()
736 ansi2html = partial(
737 Ansi2HTMLConverter(inline=True, scheme="xterm").convert, full=False
738 )
739 namespace.update(self.user_settings.as_dict())
740 namespace.update(
741 ansi2html=partial(
742 reduce, apply, (ansi2html, ansi_replace, backspace_replace)
743 ),
744 apm_script=(
745 self.settings["ELASTIC_APM"].get("INLINE_SCRIPT")
746 if self.apm_enabled
747 else None
748 ),
749 as_html=self.content_type == "text/html",
750 c=self.now.date() == date(self.now.year, 4, 1)
751 or str_to_bool(self.get_cookie("c", "f") or "f", False),
752 canonical_url=self.request.protocol
753 + "://"
754 + (self.settings["DOMAIN"] or self.request.host)
755 + self.fix_url(
756 self.request.full_url().upper()
757 if self.request.path.upper().startswith("/LOLWUT")
758 else self.request.full_url().lower()
759 )
760 .split("?")[0]
761 .removesuffix("/"),
762 description=self.description,
763 display_theme=self.get_display_theme(),
764 display_scheme=self.get_display_scheme(),
765 elastic_rum_url=self.ELASTIC_RUM_URL,
766 fix_static=lambda path: self.fix_url(fix_static_path(path)),
767 fix_url=self.fix_url,
768 emoji2html=(
769 emoji2html
770 if self.user_settings.openmoji == "img"
771 else (
772 (lambda emoji: f'<span class="openmoji">{emoji}</span>')
773 if self.user_settings.openmoji
774 else (lambda emoji: f"<span>{emoji}</span>")
775 )
776 ),
777 form_appendix=self.user_settings.get_form_appendix(),
778 GH_ORG_URL=GH_ORG_URL,
779 GH_PAGES_URL=GH_PAGES_URL,
780 GH_REPO_URL=GH_REPO_URL,
781 keywords="Asoziales Netzwerk, Känguru-Chroniken"
782 + (
783 f", {self.module_info.get_keywords_as_str(self.request.path)}"
784 if self.module_info # type: ignore[truthy-bool]
785 else ""
786 ),
787 lang="de", # TODO: add language support
788 nonce=self.nonce,
789 now=self.now,
790 openmoji_version=OPENMOJI_VERSION,
791 settings=self.settings,
792 short_title=self.short_title,
793 testing=pytest_is_running(),
794 title=self.title,
795 )
796 namespace.update(
797 {
798 "🥚": timedelta()
799 <= self.now.date() - easter(self.now.year)
800 < timedelta(days=2),
801 "🦘": is_prime(self.now.microsecond),
802 }
803 )
804 return namespace
806 def get_user_id(self) -> str:
807 """Get the user id saved in the cookie or create one."""
808 cookie = self.get_secure_cookie(
809 "user_id",
810 max_age_days=90,
811 min_version=2,
812 )
814 user_id = cookie.decode("UTF-8") if cookie else str(uuid.uuid4())
816 if not self.get_secure_cookie( # save it in cookie or reset expiry date
817 "user_id", max_age_days=30, min_version=2
818 ):
819 self.set_secure_cookie(
820 "user_id",
821 user_id,
822 expires_days=90,
823 path="/",
824 samesite="Strict",
825 )
827 return user_id
829 def handle_accept_header( # pylint: disable=inconsistent-return-statements
830 self, possible_content_types: tuple[str, ...], strict: bool = True
831 ) -> None:
832 """Handle the Accept header and set `self.content_type`."""
833 if not possible_content_types:
834 return
835 content_type = get_best_match(
836 self.request.headers.get("Accept") or "*/*",
837 possible_content_types,
838 )
839 if content_type is None:
840 if strict:
841 return self.handle_not_acceptable(possible_content_types)
842 content_type = possible_content_types[0]
843 self.content_type = content_type
844 self.set_content_type_header()
846 def handle_not_acceptable(
847 self, possible_content_types: tuple[str, ...]
848 ) -> None:
849 """Only call this if we cannot respect the Accept header."""
850 self.clear_header("Content-Type")
851 self.set_status(406)
852 raise Finish("\n".join(possible_content_types) + "\n")
854 def head(self, *args: Any, **kwargs: Any) -> None | Awaitable[None]:
855 """Handle HEAD requests."""
856 if self.get.__module__ == "tornado.web":
857 raise HTTPError(405)
858 if not self.supports_head():
859 raise HTTPError(501)
861 kwargs["head"] = True
862 return self.get(*args, **kwargs)
864 @override
865 def initialize(
866 self,
867 *,
868 module_info: ModuleInfo,
869 # default is true, because then empty args dicts are
870 # enough to specify that the defaults should be used
871 default_title: bool = True,
872 default_description: bool = True,
873 ) -> None:
874 """
875 Get title and description from the kwargs.
877 If title and description are present in the kwargs,
878 then they override self.title and self.description.
879 """
880 self.module_info = module_info
881 if not default_title:
882 page_info = self.module_info.get_page_info(self.request.path)
883 self.title = page_info.name
884 self.short_title = page_info.short_name or self.title
885 if not default_description:
886 self.description = self.module_info.get_page_info(
887 self.request.path
888 ).description
890 @override
891 async def options(self, *args: Any, **kwargs: Any) -> None:
892 """Handle OPTIONS requests."""
893 # pylint: disable=unused-argument
894 self.set_header("Allow", ", ".join(self.get_allowed_methods()))
895 self.set_status(204)
896 await self.finish()
898 def origin_trial(self, token: bytes | str) -> bool:
899 """Enable an experimental feature."""
900 # pylint: disable=protected-access
901 payload = json.loads(b64decode(token)[69:])
902 if payload["feature"] in self.active_origin_trials:
903 return True
904 origin = urlsplit(payload["origin"])
905 url = urlsplit(self.request.full_url())
906 if url.port is None and url.scheme in {"http", "https"}:
907 url = url._replace(
908 netloc=f"{url.hostname}:{443 if url.scheme == 'https' else 80}"
909 )
910 if self.request._start_time > payload["expiry"]:
911 return False
912 if url.scheme != origin.scheme:
913 return False
914 if url.netloc != origin.netloc and not (
915 payload.get("isSubdomain")
916 and url.netloc.endswith(f".{origin.netloc}")
917 ):
918 return False
919 self.add_header("Origin-Trial", token)
920 self.active_origin_trials.add(payload["feature"])
921 return True
923 @override
924 async def prepare(self) -> None:
925 """Check authorization and call self.ratelimit()."""
926 await super().prepare()
928 if self._finished:
929 return
931 if not self.ALLOW_COMPRESSION:
932 for transform in self._transforms:
933 if isinstance(transform, GZipContentEncoding):
934 # pylint: disable=protected-access
935 transform._gzipping = False
937 self.handle_accept_header(self.POSSIBLE_CONTENT_TYPES)
939 if self.request.method == "GET" and (
940 days := Random(self.now.timestamp()).randint(0, 31337)
941 ) in {
942 69,
943 420,
944 1337,
945 31337,
946 }:
947 self.set_cookie("c", "s", expires_days=days / 24, path="/")
949 if (
950 self.request.method != "OPTIONS"
951 and self.MAX_BODY_SIZE is not None
952 and len(self.request.body) > self.MAX_BODY_SIZE
953 ):
954 LOGGER.warning(
955 "%s > MAX_BODY_SIZE (%s)",
956 len(self.request.body),
957 self.MAX_BODY_SIZE,
958 )
959 raise HTTPError(413)
961 @override
962 def render( # noqa: D102
963 self, template_name: str, **kwargs: Any
964 ) -> Future[None]:
965 self.used_render = True
966 return super().render(template_name, **kwargs)
968 render.__doc__ = _RequestHandler.render.__doc__
970 def set_content_type_header(self) -> None:
971 """Set the Content-Type header based on `self.content_type`."""
972 if str(self.content_type).startswith("text/"): # RFC 2616 (3.7.1)
973 self.set_header(
974 "Content-Type", f"{self.content_type};charset=utf-8"
975 )
976 elif self.content_type is not None:
977 self.set_header("Content-Type", self.content_type)
979 @override
980 def set_cookie( # noqa: D102 # pylint: disable=too-many-arguments
981 self,
982 name: str,
983 value: str | bytes,
984 domain: None | str = None,
985 expires: None | float | tuple[int, ...] | datetime = None,
986 path: str = "/",
987 expires_days: None | float = 400, # changed
988 *,
989 secure: bool | None = None,
990 httponly: bool = True,
991 **kwargs: Any,
992 ) -> None:
993 if "samesite" not in kwargs:
994 # default for same site should be strict
995 kwargs["samesite"] = "Strict"
997 super().set_cookie(
998 name,
999 value,
1000 domain,
1001 expires,
1002 path,
1003 expires_days,
1004 secure=(
1005 self.request.protocol == "https" if secure is None else secure
1006 ),
1007 httponly=httponly,
1008 **kwargs,
1009 )
1011 set_cookie.__doc__ = _RequestHandler.set_cookie.__doc__
1013 def set_csp_header(self) -> None:
1014 """Set the Content-Security-Policy header."""
1015 self.nonce = secrets.token_urlsafe(16)
1017 script_src = ["'self'", f"'nonce-{self.nonce}'"]
1019 if (
1020 self.apm_enabled
1021 and "INLINE_SCRIPT_HASH" in self.settings["ELASTIC_APM"]
1022 ):
1023 script_src.extend(
1024 (
1025 f"'sha256-{self.settings['ELASTIC_APM']['INLINE_SCRIPT_HASH']}'",
1026 "'unsafe-inline'", # for browsers that don't support hash
1027 )
1028 )
1030 connect_src = ["'self'"]
1032 if self.apm_enabled and "SERVER_URL" in self.settings["ELASTIC_APM"]:
1033 rum_server_url = self.settings["ELASTIC_APM"].get("RUM_SERVER_URL")
1034 if rum_server_url:
1035 # the RUM agent needs to connect to rum_server_url
1036 connect_src.append(rum_server_url)
1037 elif rum_server_url is None:
1038 # the RUM agent needs to connect to ["ELASTIC_APM"]["SERVER_URL"]
1039 connect_src.append(self.settings["ELASTIC_APM"]["SERVER_URL"])
1041 connect_src.append( # fix for older browsers
1042 ("wss" if self.request.protocol == "https" else "ws")
1043 + f"://{self.request.host}"
1044 )
1046 self.set_header(
1047 "Content-Security-Policy",
1048 "default-src 'self';"
1049 f"script-src {' '.join(script_src)};"
1050 f"connect-src {' '.join(connect_src)};"
1051 "style-src 'self' 'unsafe-inline';"
1052 "img-src 'self' https://img.zeit.de https://github.asozial.org;"
1053 "frame-ancestors 'self';"
1054 "sandbox allow-downloads allow-same-origin allow-modals"
1055 " allow-popups-to-escape-sandbox allow-scripts allow-popups"
1056 " allow-top-navigation-by-user-activation allow-forms;"
1057 "report-to default;"
1058 "base-uri 'none';"
1059 + (
1060 f"report-uri {self.get_reporting_api_endpoint()};"
1061 if self.settings.get("REPORTING")
1062 else ""
1063 ),
1064 )
1066 @override
1067 def set_default_headers(self) -> None:
1068 """Set default headers."""
1069 self.set_csp_header()
1070 self.active_origin_trials = set()
1071 if self.settings.get("REPORTING"):
1072 endpoint = self.get_reporting_api_endpoint()
1073 self.set_header(
1074 "Reporting-Endpoints",
1075 f'default="{endpoint}"', # noqa: B907
1076 )
1077 self.set_header(
1078 "Report-To",
1079 json.dumps(
1080 {
1081 "group": "default",
1082 "max_age": 2592000,
1083 "endpoints": [{"url": endpoint}],
1084 },
1085 option=ORJSON_OPTIONS,
1086 ),
1087 )
1088 self.set_header("NEL", '{"report_to":"default","max_age":2592000}')
1089 self.set_header("X-Content-Type-Options", "nosniff")
1090 self.set_header("Access-Control-Max-Age", "7200")
1091 self.set_header("Access-Control-Allow-Origin", "*")
1092 self.set_header("Access-Control-Allow-Headers", "*")
1093 self.set_header(
1094 "Access-Control-Allow-Methods",
1095 ", ".join(self.get_allowed_methods()),
1096 )
1097 self.set_header("Cross-Origin-Resource-Policy", "cross-origin")
1098 self.set_header(
1099 "Permissions-Policy",
1100 "browsing-topics=(),"
1101 "identity-credentials-get=(),"
1102 "join-ad-interest-group=(),"
1103 "private-state-token-issuance=(),"
1104 "private-state-token-redemption=(),"
1105 "run-ad-auction=()",
1106 )
1107 self.set_header("Referrer-Policy", "same-origin")
1108 self.set_header(
1109 "Cross-Origin-Opener-Policy", "same-origin;report-to=default"
1110 )
1111 if self.request.path == "/kaenguru-comics-alt": # TODO: improve this
1112 self.set_header(
1113 "Cross-Origin-Embedder-Policy",
1114 "credentialless;report-to=default",
1115 )
1116 else:
1117 self.set_header(
1118 "Cross-Origin-Embedder-Policy",
1119 "require-corp;report-to=default",
1120 )
1121 if self.settings.get("HSTS"):
1122 self.set_header("Strict-Transport-Security", "max-age=63072000")
1123 if (
1124 onion_address := self.settings.get("ONION_ADDRESS")
1125 ) and not self.request.host_name.endswith(".onion"):
1126 self.set_header(
1127 "Onion-Location",
1128 onion_address
1129 + self.request.path
1130 + (f"?{self.request.query}" if self.request.query else ""),
1131 )
1132 if self.settings.get("debug"):
1133 self.set_header("X-Debug", bool_to_str(True))
1134 for permission in Permission:
1135 if permission.name:
1136 self.set_header(
1137 f"X-Permission-{permission.name}",
1138 bool_to_str(bool(self.is_authorized(permission))),
1139 )
1140 self.set_header(
1141 "X-Clacks-Overhead",
1142 CLACKS_OVERHEADS[
1143 int(self.now_utc.microsecond) % len(CLACKS_OVERHEADS)
1144 ],
1145 )
1146 self.set_header("Accept-CH", "Sec-CH-Prefers-Reduced-Motion")
1147 self.set_header("Critical-CH", "Sec-CH-Prefers-Reduced-Motion")
1148 self.set_header(
1149 "Vary", "Accept,Authorization,Cookie,Sec-CH-Prefers-Reduced-Motion"
1150 )
1152 set_default_headers.__doc__ = _RequestHandler.set_default_headers.__doc__
1154 def stanley(self) -> bool:
1155 """Stanley."""
1156 return self.user_settings.stanley is not False and (
1157 self.now.date() == date(self.now.year, 4, 27)
1158 or self.user_settings.stanley is True
1159 )
1161 def sub_stanley(self, text: str) -> str:
1162 """Sub Stanley."""
1163 return regex.sub(
1164 r"\b\p{Lu}\p{Ll}{4}\p{Ll}*\b",
1165 lambda match: (
1166 "Stanley"
1167 if Random(match[0]).randrange(5) == self.now.year % 5
1168 else match[0]
1169 ),
1170 text,
1171 )
1173 @classmethod
1174 def supports_head(cls) -> bool:
1175 """Check whether this request handler supports HEAD requests."""
1176 signature = inspect.signature(cls.get)
1177 return (
1178 "head" in signature.parameters
1179 and signature.parameters["head"].kind
1180 == inspect.Parameter.KEYWORD_ONLY
1181 )
1183 @cached_property
1184 def user_settings(self) -> Options:
1185 """Get the user settings."""
1186 return Options(self)
1188 @override
1189 def write(self, chunk: str | bytes | dict[str, Any]) -> None: # noqa: D102
1190 if self._finished:
1191 raise RuntimeError("Cannot write() after finish()")
1193 self.set_content_type_header()
1195 if isinstance(chunk, dict):
1196 chunk = self.dump(chunk)
1198 if self.stanley():
1199 if isinstance(chunk, bytes):
1200 with contextlib.suppress(UnicodeDecodeError):
1201 chunk = chunk.decode("UTF-8")
1202 if isinstance(chunk, str):
1203 chunk = self.sub_stanley(chunk)
1205 super().write(chunk)
1207 write.__doc__ = _RequestHandler.write.__doc__
1209 @override
1210 def write_error(self, status_code: int, **kwargs: Any) -> None:
1211 """Render the error page."""
1212 dict_content_types: tuple[str, str] = (
1213 "application/json",
1214 "application/yaml",
1215 )
1216 all_error_content_types: tuple[str, ...] = (
1217 # text/plain as first (default), to not screw up output in terminals
1218 "text/plain",
1219 "text/html",
1220 "text/markdown",
1221 *dict_content_types,
1222 "application/vnd.asozial.dynload+json",
1223 )
1225 if self.content_type not in all_error_content_types:
1226 # don't send 406, instead default with text/plain
1227 self.handle_accept_header(all_error_content_types, strict=False)
1229 if self.content_type == "text/html":
1230 self.render( # type: ignore[unused-awaitable]
1231 "error.html",
1232 status=status_code,
1233 reason=self.get_error_message(**kwargs),
1234 description=self.get_error_page_description(status_code),
1235 is_traceback="exc_info" in kwargs
1236 and not issubclass(kwargs["exc_info"][0], HTTPError)
1237 and (
1238 self.settings.get("serve_traceback")
1239 or self.is_authorized(Permission.TRACEBACK)
1240 ),
1241 )
1242 return
1244 if self.content_type in dict_content_types:
1245 self.finish( # type: ignore[unused-awaitable]
1246 {
1247 "status": status_code,
1248 "reason": self.get_error_message(**kwargs),
1249 }
1250 )
1251 return
1253 self.finish( # type: ignore[unused-awaitable]
1254 f"{status_code} {self.get_error_message(**kwargs)}\n"
1255 )
1257 write_error.__doc__ = _RequestHandler.write_error.__doc__