]>
Commit | Line | Data |
---|---|---|
940227cb MT |
1 | #!/usr/bin/python |
2 | ||
0056fdbf | 3 | import asyncio |
cc3b928d | 4 | import datetime |
66862195 | 5 | import dateutil.parser |
cfe7d74c | 6 | import functools |
11347e46 | 7 | import http.client |
a69e87a1 | 8 | import ipaddress |
47ed77ed | 9 | import logging |
c6653ffc MT |
10 | import magic |
11 | import mimetypes | |
940227cb MT |
12 | import time |
13 | import tornado.locale | |
14 | import tornado.web | |
15 | ||
f110a9ff | 16 | from ..decorators import * |
a95c2f97 | 17 | from .. import util |
60024cc8 | 18 | |
28e09035 MT |
19 | # Setup logging |
20 | log = logging.getLogger(__name__) | |
21 | ||
372ef119 | 22 | class ratelimit(object): |
0056fdbf MT |
23 | """ |
24 | A decorator class which limits how often a function can be called | |
25 | """ | |
26 | def __init__(self, *, minutes, requests): | |
27 | self.minutes = minutes | |
372ef119 MT |
28 | self.requests = requests |
29 | ||
30 | def __call__(self, method): | |
31 | @functools.wraps(method) | |
0056fdbf | 32 | async def wrapper(handler, *args, **kwargs): |
372ef119 MT |
33 | # Pass the request to the rate limiter and get a request object |
34 | req = handler.backend.ratelimiter.handle_request(handler.request, | |
35 | handler, minutes=self.minutes, limit=self.requests) | |
36 | ||
37 | # If the rate limit has been reached, we won't allow | |
38 | # processing the request and therefore send HTTP error code 429. | |
0056fdbf | 39 | if await req.is_ratelimited(): |
372ef119 MT |
40 | raise tornado.web.HTTPError(429, "Rate limit exceeded") |
41 | ||
0056fdbf MT |
42 | # Call the wrapped method |
43 | result = method(handler, *args, **kwargs) | |
44 | ||
45 | # Await it if it is a coroutine | |
46 | if asyncio.iscoroutine(result): | |
47 | return await result | |
48 | ||
49 | # Return the result | |
50 | return result | |
372ef119 MT |
51 | |
52 | return wrapper | |
53 | ||
cfe7d74c | 54 | |
940227cb | 55 | class BaseHandler(tornado.web.RequestHandler): |
90199689 MT |
56 | def prepare(self): |
57 | # Mark this as private when someone is logged in | |
58 | if self.current_user: | |
59 | self.set_header("Cache-Control", "private") | |
60 | ||
61 | # Always send Vary: Cookie | |
62 | self.set_header("Vary", "Cookie") | |
63 | ||
f6ed3d4d MT |
64 | def set_expires(self, seconds): |
65 | # For HTTP/1.1 | |
b592d7c8 | 66 | self.add_header("Cache-Control", "max-age=%s, must-revalidate" % seconds) |
f6ed3d4d MT |
67 | |
68 | # For HTTP/1.0 | |
69 | expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) | |
90199689 | 70 | self.set_header("Expires", expires) |
f6ed3d4d | 71 | |
37ed7c3c MT |
72 | def write_error(self, status_code, **kwargs): |
73 | # Translate code into message | |
74 | try: | |
11347e46 | 75 | message = http.client.responses[status_code] |
37ed7c3c MT |
76 | except KeyError: |
77 | message = None | |
78 | ||
79 | self.render("error.html", status_code=status_code, message=message, **kwargs) | |
80 | ||
e6340233 MT |
81 | def browser_accepts(self, t): |
82 | """ | |
83 | Checks if type is in Accept: header | |
84 | """ | |
85 | accepts = [] | |
86 | ||
87 | for elem in self.request.headers.get("Accept", "").split(","): | |
88 | # Remove q=N | |
89 | type, delim, q = elem.partition(";") | |
90 | ||
91 | accepts.append(type) | |
92 | ||
93 | # Check if the filetype is in the list of accepted ones | |
94 | return t in accepts | |
95 | ||
4ca1a601 MT |
96 | @property |
97 | def hostname(self): | |
242d11d5 MT |
98 | # Return hostname in production |
99 | if self.request.host.endswith("ipfire.org"): | |
100 | return self.request.host | |
101 | ||
4ca1a601 | 102 | # Remove the development prefix |
242d11d5 MT |
103 | subdomain, delimier, domain = self.request.host.partition(".") |
104 | if subdomain: | |
105 | return "%s.ipfire.org" % subdomain | |
106 | ||
107 | # Return whatever it is | |
108 | return self.request.host | |
4ca1a601 | 109 | |
bff08cb0 MT |
110 | def get_template_namespace(self): |
111 | ns = tornado.web.RequestHandler.get_template_namespace(self) | |
112 | ||
911064cf | 113 | now = datetime.date.today() |
cc3b928d | 114 | |
bff08cb0 | 115 | ns.update({ |
0fb1a1fc MT |
116 | "backend" : self.backend, |
117 | "debug" : self.application.settings.get("debug", False), | |
a95c2f97 MT |
118 | "format_size" : util.format_size, |
119 | "format_time" : util.format_time, | |
0fb1a1fc MT |
120 | "hostname" : self.hostname, |
121 | "now" : now, | |
122 | "q" : None, | |
123 | "year" : now.year, | |
bff08cb0 | 124 | }) |
940227cb | 125 | |
bff08cb0 | 126 | return ns |
940227cb | 127 | |
9068dba1 MT |
128 | def get_remote_ip(self): |
129 | # Fix for clients behind a proxy that sends "X-Forwarded-For". | |
66862195 | 130 | remote_ips = self.request.remote_ip.split(", ") |
494d80e6 | 131 | |
66862195 MT |
132 | for remote_ip in remote_ips: |
133 | try: | |
a69e87a1 | 134 | addr = ipaddress.ip_address(remote_ip) |
66862195 MT |
135 | except ValueError: |
136 | # Skip invalid IP addresses. | |
137 | continue | |
9068dba1 | 138 | |
66862195 MT |
139 | # Check if the given IP address is from a |
140 | # private network. | |
141 | if addr.is_private: | |
142 | continue | |
9068dba1 | 143 | |
66862195 | 144 | return remote_ip |
9068dba1 | 145 | |
494d80e6 MT |
146 | # Return the last IP if nothing else worked |
147 | return remote_ips.pop() | |
148 | ||
cfe7d74c | 149 | @lazy_property |
440aba92 | 150 | def current_address(self): |
cfe7d74c MT |
151 | address = self.get_remote_ip() |
152 | ||
153 | if address: | |
440aba92 | 154 | return util.Address(self.backend, address) |
cfe7d74c | 155 | |
f110a9ff MT |
156 | @lazy_property |
157 | def current_country_code(self): | |
440aba92 MT |
158 | if self.current_address: |
159 | return self.current_address.country_code | |
9068dba1 | 160 | |
28e09035 MT |
161 | @property |
162 | def user_agent(self): | |
163 | """ | |
164 | Returns the HTTP user agent | |
165 | """ | |
166 | return self.request.headers.get("User-Agent", None) | |
167 | ||
168 | @property | |
169 | def referrer(self): | |
170 | return self.request.headers.get("Referer", None) | |
171 | ||
bd2723d4 MT |
172 | def get_argument_int(self, *args, **kwargs): |
173 | arg = self.get_argument(*args, **kwargs) | |
174 | ||
175 | if arg is None or arg == "": | |
176 | return | |
177 | ||
178 | try: | |
179 | return int(arg) | |
180 | except ValueError: | |
a5f94966 MT |
181 | raise tornado.web.HTTPError(400, "Could not convert integer: %s" % arg) |
182 | ||
183 | def get_argument_float(self, *args, **kwargs): | |
184 | arg = self.get_argument(*args, **kwargs) | |
185 | ||
186 | if arg is None or arg == "": | |
187 | return | |
188 | ||
189 | try: | |
190 | return float(arg) | |
191 | except ValueError: | |
192 | raise tornado.web.HTTPError(400, "Could not convert float: %s" % arg) | |
bd2723d4 | 193 | |
66862195 MT |
194 | def get_argument_date(self, arg, *args, **kwargs): |
195 | value = self.get_argument(arg, *args, **kwargs) | |
196 | if value is None: | |
197 | return | |
198 | ||
199 | try: | |
200 | return dateutil.parser.parse(value) | |
201 | except ValueError: | |
202 | raise tornado.web.HTTPError(400) | |
203 | ||
5cc10421 MT |
204 | def get_file(self, name): |
205 | try: | |
206 | file = self.request.files[name][0] | |
207 | ||
208 | return file["filename"], file["body"], file["content_type"] | |
209 | except KeyError: | |
210 | return None | |
211 | ||
c6653ffc MT |
212 | # Initialize libmagic |
213 | magic = magic.Magic(mime=True, uncompress=True) | |
214 | ||
215 | # File | |
216 | ||
217 | def _deliver_file(self, data, filename=None, prefix=None): | |
218 | # Guess content type | |
219 | mimetype = self.magic.from_buffer(data) | |
220 | ||
221 | # Send the mimetype | |
222 | self.set_header("Content-Type", mimetype or "application/octet-stream") | |
223 | ||
224 | # Fetch the file extension | |
225 | if not filename and prefix: | |
226 | ext = mimetypes.guess_extension(mimetype) | |
227 | ||
228 | # Compose a new filename | |
229 | filename = "%s%s" % (prefix, ext) | |
230 | ||
231 | # Set filename | |
232 | if filename: | |
233 | self.set_header("Content-Disposition", "inline; filename=\"%s\"" % filename) | |
234 | ||
235 | # Set size | |
236 | if data: | |
237 | self.set_header("Content-Length", len(data)) | |
238 | ||
239 | # Deliver payload | |
240 | self.finish(data) | |
241 | ||
66862195 MT |
242 | # Login stuff |
243 | ||
244 | def get_current_user(self): | |
245 | session_id = self.get_cookie("session_id") | |
246 | if not session_id: | |
247 | return | |
248 | ||
249 | # Get account from the session object | |
250 | account = self.backend.accounts.get_by_session(session_id, self.request.host) | |
251 | ||
252 | # If the account was not found or the session was not valid | |
253 | # any more, we will remove the cookie. | |
254 | if not account: | |
255 | self.clear_cookie("session_id") | |
256 | ||
257 | return account | |
258 | ||
a6dc0bad MT |
259 | @property |
260 | def backend(self): | |
261 | return self.application.backend | |
262 | ||
9068dba1 MT |
263 | @property |
264 | def db(self): | |
265 | return self.backend.db | |
266 | ||
940227cb MT |
267 | @property |
268 | def accounts(self): | |
a6dc0bad | 269 | return self.backend.accounts |
940227cb MT |
270 | |
271 | @property | |
9068dba1 MT |
272 | def downloads(self): |
273 | return self.backend.downloads | |
274 | ||
66862195 MT |
275 | @property |
276 | def fireinfo(self): | |
277 | return self.backend.fireinfo | |
278 | ||
9068dba1 MT |
279 | @property |
280 | def iuse(self): | |
281 | return self.backend.iuse | |
940227cb MT |
282 | |
283 | @property | |
284 | def mirrors(self): | |
9068dba1 MT |
285 | return self.backend.mirrors |
286 | ||
287 | @property | |
288 | def netboot(self): | |
289 | return self.backend.netboot | |
940227cb | 290 | |
940227cb MT |
291 | @property |
292 | def releases(self): | |
9068dba1 | 293 | return self.backend.releases |
940227cb | 294 | |
66862195 | 295 | |
28e09035 MT |
296 | class AnalyticsMixin(object): |
297 | def on_finish(self): | |
298 | """ | |
299 | Collect some data about this request | |
300 | """ | |
301 | # Log something | |
302 | log.debug("Analytics for %s:" % self) | |
303 | log.debug(" User-Agent: %s" % self.user_agent) | |
304 | log.debug(" Referrer : %s" % self.referrer) | |
305 | ||
306 | # Do nothing if this requst should be ignored | |
307 | if self._ignore_analytics(): | |
308 | return | |
309 | ||
310 | with self.db.transaction(): | |
311 | # Log unique visits | |
312 | self.backend.analytics.log_unique_visit( | |
313 | address=self.current_address, | |
314 | referrer=self.referrer, | |
315 | country_code=self.current_country_code, | |
316 | user_agent=self.user_agent, | |
317 | host=self.request.host, | |
318 | uri=self.request.uri, | |
319 | ||
320 | # UTMs | |
321 | source=self.get_argument("utm_source", None), | |
322 | medium=self.get_argument("utm_medium", None), | |
323 | campaign=self.get_argument("utm_campaign", None), | |
324 | content=self.get_argument("utm_content", None), | |
325 | term=self.get_argument("utm_term", None), | |
326 | ||
327 | # Search queries | |
328 | q=self.get_argument("q", None), | |
329 | ) | |
330 | ||
331 | def _ignore_analytics(self): | |
332 | """ | |
333 | Checks if this request should be ignored | |
334 | """ | |
335 | ignored_user_agents = ( | |
336 | "LWP::Simple", | |
337 | "check_http", | |
338 | ) | |
339 | ||
340 | # Only log GET requests | |
341 | if not self.request.method == "GET": | |
342 | return True | |
343 | ||
344 | # Ignore everything from matching user agents | |
7fe7af18 MT |
345 | if self.user_agent: |
346 | for ignored_user_agent in ignored_user_agents: | |
347 | if self.user_agent.startswith(ignored_user_agent): | |
348 | return True | |
28e09035 MT |
349 | |
350 | ||
689effd0 MT |
351 | class APIHandler(BaseHandler): |
352 | def check_xsrf_cookie(self): | |
353 | """ | |
354 | Do nothing here, because we cannot verify the XSRF token | |
355 | """ | |
356 | pass | |
357 | ||
4f84ed71 MT |
358 | def prepare(self): |
359 | # Do not cache any API communication | |
360 | self.set_header("Cache-Control", "no-cache") | |
361 | ||
689effd0 | 362 | |
3403dc5e MT |
363 | class NotFoundHandler(BaseHandler): |
364 | def prepare(self): | |
365 | # Raises 404 as soon as it is called | |
366 | raise tornado.web.HTTPError(404) | |
367 | ||
3403dc5e | 368 | |
37ed7c3c MT |
369 | class ErrorHandler(BaseHandler): |
370 | """ | |
371 | Raises any error we want | |
372 | """ | |
373 | def get(self, code): | |
374 | try: | |
375 | code = int(code) | |
376 | except: | |
377 | raise tornado.web.HTTPError(400) | |
378 | ||
379 | raise tornado.web.HTTPError(code) |