]> git.ipfire.org Git - ipfire.org.git/blame - src/web/base.py
web: Don't try to check an empty User-Agent
[ipfire.org.git] / src / web / base.py
CommitLineData
940227cb
MT
1#!/usr/bin/python
2
0056fdbf 3import asyncio
cc3b928d 4import datetime
66862195 5import dateutil.parser
cfe7d74c 6import functools
11347e46 7import http.client
a69e87a1 8import ipaddress
47ed77ed 9import logging
c6653ffc
MT
10import magic
11import mimetypes
940227cb
MT
12import time
13import tornado.locale
14import tornado.web
15
f110a9ff 16from ..decorators import *
a95c2f97 17from .. import util
60024cc8 18
28e09035
MT
19# Setup logging
20log = logging.getLogger(__name__)
21
372ef119 22class 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 55class 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
296class 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
351class 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
363class 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
369class 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)