Coverage for an_website / utils / request_handler.py: 92.500%

80 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-22 18:49 +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 

14""" 

15Useful request handlers used by other modules. 

16 

17This should only contain request handlers and the get_module_info function. 

18""" 

19 

20 

21import logging 

22from collections.abc import Mapping 

23from http.client import responses 

24from typing import Any, ClassVar, Final, override 

25from urllib.parse import unquote 

26 

27import regex 

28from tornado.web import HTTPError 

29 

30from .base_request_handler import BaseRequestHandler 

31from .utils import ( 

32 SUS_PATHS, 

33 get_close_matches, 

34 remove_suffix_ignore_case, 

35 replace_umlauts, 

36) 

37 

38LOGGER: Final = logging.getLogger(__name__) 

39 

40 

41class HTMLRequestHandler(BaseRequestHandler): 

42 """A request handler that serves HTML.""" 

43 

44 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

45 "text/html", 

46 "text/plain", 

47 "text/markdown", 

48 "application/vnd.asozial.dynload+json", 

49 ) 

50 

51 

52class APIRequestHandler(BaseRequestHandler): 

53 """The base API request handler.""" 

54 

55 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

56 "application/json", 

57 "application/yaml", 

58 ) 

59 

60 

61class NotFoundHandler(BaseRequestHandler): 

62 """Show a 404 page if no other RequestHandler is used.""" 

63 

64 @override 

65 def initialize(self, *args: Any, **kwargs: Any) -> None: 

66 """Do nothing to have default title and description.""" 

67 if "module_info" not in kwargs: 

68 kwargs["module_info"] = None 

69 super().initialize(*args, **kwargs) 

70 

71 @override 

72 async def prepare(self) -> None: 

73 """Throw a 404 HTTP error or redirect to another page.""" 

74 self.now = await self.get_time() 

75 

76 if self.request.method not in {"GET", "HEAD"}: 

77 raise HTTPError(404) 

78 

79 new_path = regex.sub(r"/+", "/", self.request.path.rstrip("/")).replace( 

80 "_", "-" 

81 ) 

82 

83 for ext in (".html", ".htm", ".php"): 

84 new_path = remove_suffix_ignore_case(new_path, f"/index{ext}") 

85 new_path = remove_suffix_ignore_case(new_path, ext) 

86 

87 new_path = replace_umlauts(new_path) 

88 

89 if new_path.lower() in SUS_PATHS: 

90 self.set_status(469, reason="Nice Try") 

91 return self.write_error(469) 

92 

93 if new_path and new_path != self.request.path: 

94 return self.redirect(self.fix_url(new_path=new_path), True) 

95 

96 this_path_normalized = unquote(new_path).strip("/").lower() 

97 

98 paths: Mapping[str, str] = self.settings.get("NORMED_PATHS") or {} 

99 

100 if p := paths.get(this_path_normalized): 

101 return self.redirect(self.fix_url(new_path=p), False) 

102 

103 if len(this_path_normalized) <= 1 and self.request.path != "/": 

104 return self.redirect(self.fix_url(new_path="/")) 

105 

106 prefixes = tuple( 

107 (p, repl) 

108 for p, repl in paths.items() 

109 if this_path_normalized.startswith(f"{p}/") 

110 if f"/{p}" != repl.lower() 

111 if p != "api" # api should not be a prefix 

112 ) 

113 

114 if len(prefixes) == 1: 

115 ((prefix, replacement),) = prefixes 

116 return self.redirect( 

117 self.fix_url( 

118 new_path=f"{replacement.strip('/')}" 

119 f"{this_path_normalized.removeprefix(prefix)}" 

120 ), 

121 False, 

122 ) 

123 if prefixes: 

124 LOGGER.error( 

125 "Too many prefixes %r for path %s", prefixes, self.request.path 

126 ) 

127 

128 matches = get_close_matches(this_path_normalized, paths, count=1) 

129 if matches: 

130 return self.redirect( 

131 self.fix_url(new_path=paths[matches[0]]), False 

132 ) 

133 

134 self.set_status(404) 

135 self.write_error(404) 

136 

137 

138class ErrorPage(HTMLRequestHandler): 

139 """A request handler that shows the error page.""" 

140 

141 _success_status: int = 200 

142 """The status code that is expected to be returned.""" 

143 

144 @override 

145 def clear(self) -> None: 

146 """Reset all headers and content for this response.""" 

147 super().clear() 

148 self._success_status = 200 

149 

150 @override 

151 async def get(self, code: str, *, head: bool = False) -> None: 

152 """Show the error page.""" 

153 # pylint: disable=unused-argument 

154 status_code = int(code) 

155 reason = ( 

156 "Nice Try" if status_code == 469 else responses.get(status_code, "") 

157 ) 

158 # set the status code if it is allowed 

159 if status_code not in (204, 304) and not 100 <= status_code < 200: 

160 self.set_status(status_code, reason) 

161 self._success_status = status_code 

162 return await self.render( 

163 "error.html", 

164 status=status_code, 

165 reason=reason, 

166 description=self.get_error_page_description(status_code), 

167 is_traceback=False, 

168 ) 

169 

170 @override 

171 def get_status(self) -> int: 

172 """Hack the status code. 

173 

174 This hacks the status code to be 200 if the status code is expected. 

175 This avoids sending error logs to APM or Webhooks in case of success. 

176 

177 This depends on the fact that Tornado internally uses self._status_code 

178 to set the status code in the response and self.get_status() when 

179 deciding how to log the request. 

180 """ 

181 status = super().get_status() 

182 if status == self._success_status: 

183 return 200 

184 return status 

185 

186 

187class ZeroDivision(BaseRequestHandler): 

188 """A request handler that raises an error.""" 

189 

190 @override 

191 async def prepare(self) -> None: 

192 """Divide by zero and raise an error.""" 

193 self.now = await self.get_time() 

194 self.handle_accept_header(self.POSSIBLE_CONTENT_TYPES) 

195 if self.request.method != "OPTIONS": 

196 420 / 0 # pylint: disable=pointless-statement