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