]>
Commit | Line | Data |
---|---|---|
2cd9af74 MT |
1 | #!/usr/bin/python |
2 | ||
3210adef | 3 | import PIL |
bdaf6b46 | 4 | import datetime |
3210adef MT |
5 | import imghdr |
6 | import io | |
e96e445b | 7 | import ldap |
2cd9af74 | 8 | import logging |
3210adef | 9 | import os.path |
2cd9af74 | 10 | import tornado.web |
2dac7110 | 11 | import urllib.parse |
2cd9af74 | 12 | |
0099c2a7 MT |
13 | from .. import countries |
14 | ||
9b8ff27d | 15 | from . import auth |
124a8404 | 16 | from . import base |
786e9ca8 MT |
17 | from . import ui_modules |
18 | ||
3210adef MT |
19 | COLOUR_LIGHT = (237,232,232) |
20 | COLOUR_DARK = (49,53,60) | |
21 | ||
9b8ff27d | 22 | class IndexHandler(auth.CacheMixin, base.BaseHandler): |
786e9ca8 MT |
23 | @tornado.web.authenticated |
24 | def get(self): | |
5d42f49b MT |
25 | hints = [] |
26 | ||
27 | # Suggest uploading an avatar if this user does not have one | |
28 | if not self.current_user.has_avatar(): | |
29 | hints.append("avatar") | |
30 | ||
67fc00bc MT |
31 | # Suggest adding a description |
32 | if not self.current_user.description: | |
33 | hints.append("description") | |
34 | ||
5d42f49b | 35 | self.render("people/index.html", hints=hints) |
786e9ca8 | 36 | |
2cd9af74 | 37 | |
c4f1a618 | 38 | class AvatarHandler(base.BaseHandler): |
0cddf220 | 39 | def get(self, uid): |
2cd9af74 | 40 | # Get the desired size of the avatar file |
a0f767a7 | 41 | size = self.get_argument("size", None) |
2cd9af74 MT |
42 | |
43 | try: | |
44 | size = int(size) | |
45 | except (TypeError, ValueError): | |
46 | size = None | |
47 | ||
0cddf220 | 48 | logging.debug("Querying for avatar of %s" % uid) |
2cd9af74 | 49 | |
f6ed3d4d | 50 | # Fetch user account |
0cddf220 | 51 | account = self.backend.accounts.get_by_uid(uid) |
f6ed3d4d | 52 | if not account: |
0cddf220 | 53 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) |
2cd9af74 | 54 | |
e2998441 MT |
55 | # Allow downstream to cache this for a year |
56 | self.set_expires(31536000) | |
2cd9af74 | 57 | |
f6ed3d4d MT |
58 | # Resize avatar |
59 | avatar = account.get_avatar(size) | |
2cd9af74 | 60 | |
f6ed3d4d MT |
61 | # If there is no avatar, we serve a default image |
62 | if not avatar: | |
0cddf220 MT |
63 | logging.debug("No avatar uploaded for %s" % account) |
64 | ||
3210adef MT |
65 | # Generate a random avatar with only one letter |
66 | avatar = self._get_avatar(account, size=size) | |
2cd9af74 | 67 | |
020de883 MT |
68 | # Guess content type |
69 | type = imghdr.what(None, avatar) | |
70 | ||
f6ed3d4d | 71 | # Set headers about content |
020de883 MT |
72 | self.set_header("Content-Disposition", "inline; filename=\"%s.%s\"" % (account.uid, type)) |
73 | self.set_header("Content-Type", "image/%s" % type) | |
2cd9af74 | 74 | |
f6ed3d4d | 75 | # Deliver payload |
2cd9af74 | 76 | self.finish(avatar) |
786e9ca8 | 77 | |
3210adef MT |
78 | def _get_avatar(self, account, size=None, **args): |
79 | letter = ("%s" % account)[0].upper() | |
80 | ||
81 | if size is None: | |
82 | size = 256 | |
83 | ||
84 | # The generated avatar cannot be larger than 1024px | |
85 | if size >= 2048: | |
86 | size = 2048 | |
87 | ||
88 | # Cache key | |
89 | key = "avatar:letter:%s:%s" % (letter, size) | |
90 | ||
91 | # Fetch avatar from the cache | |
92 | avatar = self.memcached.get(key) | |
93 | if not avatar: | |
94 | avatar = self._make_avatar(letter, size=size, **args) | |
95 | ||
96 | # Cache for forever | |
97 | self.memcached.set(key, avatar) | |
98 | ||
99 | return avatar | |
100 | ||
101 | def _make_avatar(self, letter, format="PNG", size=None, **args): | |
102 | # Generate an image of the correct size | |
103 | image = PIL.Image.new("RGBA", (size, size), COLOUR_LIGHT) | |
104 | ||
105 | # Have a canvas | |
106 | draw = PIL.ImageDraw.Draw(image) | |
107 | ||
108 | # Load font | |
109 | font = PIL.ImageFont.truetype(os.path.join( | |
110 | self.application.settings.get("static_path", ""), | |
111 | "fonts/Mukta-Bold.ttf" | |
112 | ), size, encoding="unic") | |
113 | ||
114 | # Determine size of the printed letter | |
115 | w, h = font.getsize(letter) | |
116 | ||
117 | # Mukta seems to be very broken and the height needs to be corrected | |
118 | h //= 0.7 | |
119 | ||
120 | # Draw the letter in the center | |
121 | draw.text(((size - w) / 2, (size - h) / 2), letter, | |
122 | font=font, fill=COLOUR_DARK) | |
123 | ||
124 | with io.BytesIO() as f: | |
125 | # If writing out the image does not work with optimization, | |
126 | # we try to write it out without any optimization. | |
127 | try: | |
128 | image.save(f, format, optimize=True, **args) | |
129 | except: | |
130 | image.save(f, format, **args) | |
131 | ||
132 | return f.getvalue() | |
133 | ||
786e9ca8 | 134 | |
9b8ff27d | 135 | class CallsHandler(auth.CacheMixin, base.BaseHandler): |
bdaf6b46 MT |
136 | @tornado.web.authenticated |
137 | def get(self, uid, date=None): | |
138 | account = self.backend.accounts.get_by_uid(uid) | |
139 | if not account: | |
140 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
141 | ||
d561d931 MT |
142 | # Check for permissions |
143 | if not account.can_be_managed_by(self.current_user): | |
144 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
145 | ||
bdaf6b46 | 146 | if date: |
44b4640b MT |
147 | try: |
148 | date = datetime.datetime.strptime(date, "%Y-%m-%d").date() | |
149 | except ValueError: | |
150 | raise tornado.web.HTTPError(400, "Invalid date: %s" % date) | |
bdaf6b46 MT |
151 | else: |
152 | date = datetime.date.today() | |
153 | ||
154 | self.render("people/calls.html", account=account, date=date) | |
155 | ||
156 | ||
9b8ff27d | 157 | class CallHandler(auth.CacheMixin, base.BaseHandler): |
68ece434 MT |
158 | @tornado.web.authenticated |
159 | def get(self, uid, uuid): | |
d09d554b MT |
160 | account = self.backend.accounts.get_by_uid(uid) |
161 | if not account: | |
162 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
163 | ||
d561d931 MT |
164 | # Check for permissions |
165 | if not account.can_be_managed_by(self.current_user): | |
166 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
167 | ||
68ece434 MT |
168 | call = self.backend.talk.freeswitch.get_call_by_uuid(uuid) |
169 | if not call: | |
170 | raise tornado.web.HTTPError(404, "Could not find call %s" % uuid) | |
171 | ||
172 | # XXX limit | |
173 | ||
d09d554b | 174 | self.render("people/call.html", account=account, call=call) |
68ece434 MT |
175 | |
176 | ||
9b8ff27d | 177 | class ConferencesHandler(auth.CacheMixin, base.BaseHandler): |
30aeccdb MT |
178 | @tornado.web.authenticated |
179 | def get(self): | |
180 | self.render("people/conferences.html", conferences=self.backend.talk.conferences) | |
181 | ||
182 | ||
18b13823 MT |
183 | class GroupsHandler(auth.CacheMixin, base.BaseHandler): |
184 | @tornado.web.authenticated | |
185 | def get(self): | |
186 | # Only staff can see other groups | |
187 | if not self.current_user.is_staff(): | |
188 | raise tornado.web.HTTPError(403) | |
189 | ||
190 | self.render("people/groups.html") | |
191 | ||
192 | ||
bef47ee8 MT |
193 | class GroupHandler(auth.CacheMixin, base.BaseHandler): |
194 | @tornado.web.authenticated | |
195 | def get(self, gid): | |
196 | # Only staff can see other groups | |
197 | if not self.current_user.is_staff(): | |
198 | raise tornado.web.HTTPError(403) | |
199 | ||
200 | # Fetch group | |
201 | group = self.backend.groups.get_by_gid(gid) | |
202 | if not group: | |
203 | raise tornado.web.HTTPError(404, "Could not find group %s" % gid) | |
204 | ||
205 | self.render("people/group.html", group=group) | |
206 | ||
207 | ||
9b8ff27d | 208 | class SearchHandler(auth.CacheMixin, base.BaseHandler): |
786e9ca8 MT |
209 | @tornado.web.authenticated |
210 | def get(self): | |
211 | q = self.get_argument("q") | |
212 | ||
213 | # Perform the search | |
51907e45 | 214 | accounts = self.backend.accounts.search(q) |
786e9ca8 MT |
215 | |
216 | # Redirect when only one result was found | |
217 | if len(accounts) == 1: | |
218 | self.redirect("/users/%s" % accounts[0].uid) | |
219 | return | |
220 | ||
221 | self.render("people/search.html", q=q, accounts=accounts) | |
222 | ||
f4672785 | 223 | |
226d2676 MT |
224 | class StatsHandler(auth.CacheMixin, base.BaseHandler): |
225 | @tornado.web.authenticated | |
226 | def get(self): | |
b8e992c4 MT |
227 | # Only staff can see stats |
228 | if not self.current_user.is_staff(): | |
229 | raise tornado.web.HTTPError(403) | |
230 | ||
226d2676 MT |
231 | self.render("people/stats.html") |
232 | ||
233 | ||
92c4b559 | 234 | class SubscribeHandler(auth.CacheMixin, base.BaseHandler): |
92c4b559 MT |
235 | @tornado.web.authenticated |
236 | def post(self): | |
237 | # Give consent | |
238 | self.current_user.consents_to_promotional_emails = True | |
239 | ||
240 | self.render("people/subscribed.html") | |
241 | ||
242 | ||
243 | class UnsubscribeHandler(auth.CacheMixin, base.BaseHandler): | |
244 | @tornado.web.authenticated | |
245 | def get(self): | |
246 | if self.current_user.consents_to_promotional_emails: | |
247 | return self.render("people/unsubscribe.html") | |
248 | ||
249 | self.render("people/unsubscribed.html") | |
250 | ||
251 | @tornado.web.authenticated | |
252 | def post(self): | |
253 | # Withdraw consent | |
254 | self.current_user.consents_to_promotional_emails = False | |
255 | ||
256 | self.render("people/unsubscribed.html") | |
257 | ||
258 | ||
9b8ff27d | 259 | class SIPHandler(auth.CacheMixin, base.BaseHandler): |
e0daee8f MT |
260 | @tornado.web.authenticated |
261 | def get(self, uid): | |
262 | account = self.backend.accounts.get_by_uid(uid) | |
263 | if not account: | |
264 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
265 | ||
266 | # Check for permissions | |
267 | if not account.can_be_managed_by(self.current_user): | |
268 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
269 | ||
270 | self.render("people/sip.html", account=account) | |
271 | ||
272 | ||
9b8ff27d | 273 | class UsersHandler(auth.CacheMixin, base.BaseHandler): |
786e9ca8 MT |
274 | @tornado.web.authenticated |
275 | def get(self): | |
71a3109c MT |
276 | # Only staff can see other users |
277 | if not self.current_user.is_staff(): | |
278 | raise tornado.web.HTTPError(403) | |
279 | ||
786e9ca8 MT |
280 | self.render("people/users.html") |
281 | ||
282 | ||
9b8ff27d | 283 | class UserHandler(auth.CacheMixin, base.BaseHandler): |
786e9ca8 MT |
284 | @tornado.web.authenticated |
285 | def get(self, uid): | |
286 | account = self.backend.accounts.get_by_uid(uid) | |
287 | if not account: | |
288 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
289 | ||
290 | self.render("people/user.html", account=account) | |
291 | ||
292 | ||
9b8ff27d | 293 | class UserEditHandler(auth.CacheMixin, base.BaseHandler): |
e96e445b MT |
294 | @tornado.web.authenticated |
295 | def get(self, uid): | |
296 | account = self.backend.accounts.get_by_uid(uid) | |
297 | if not account: | |
298 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
299 | ||
300 | # Check for permissions | |
301 | if not account.can_be_managed_by(self.current_user): | |
302 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
303 | ||
0099c2a7 | 304 | self.render("people/user-edit.html", account=account, countries=countries.get_all()) |
e96e445b MT |
305 | |
306 | @tornado.web.authenticated | |
307 | def post(self, uid): | |
308 | account = self.backend.accounts.get_by_uid(uid) | |
309 | if not account: | |
310 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
311 | ||
312 | # Check for permissions | |
313 | if not account.can_be_managed_by(self.current_user): | |
314 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
315 | ||
316 | # Unfortunately this cannot be wrapped into a transaction | |
317 | try: | |
0099c2a7 MT |
318 | account.first_name = self.get_argument("first_name") |
319 | account.last_name = self.get_argument("last_name") | |
d6e57f73 | 320 | account.nickname = self.get_argument("nickname", None) |
0099c2a7 MT |
321 | account.street = self.get_argument("street", None) |
322 | account.city = self.get_argument("city", None) | |
323 | account.postal_code = self.get_argument("postal_code", None) | |
324 | account.country_code = self.get_argument("country_code", None) | |
1c4522dc | 325 | account.description = self.get_argument("description", None) |
e96e445b | 326 | |
5cc10421 MT |
327 | # Avatar |
328 | try: | |
329 | filename, data, mimetype = self.get_file("avatar") | |
330 | ||
331 | if not mimetype.startswith("image/"): | |
332 | raise tornado.web.HTTPError(400, "Avatar is not an image file: %s" % mimetype) | |
333 | ||
334 | account.upload_avatar(data) | |
9bbf48b8 | 335 | except TypeError: |
5cc10421 MT |
336 | pass |
337 | ||
e96e445b MT |
338 | |
339 | account.mail_routing_address = self.get_argument("mail_routing_address", None) | |
340 | ||
341 | # Telephone | |
342 | account.phone_numbers = self.get_argument("phone_numbers", "").splitlines() | |
343 | account.sip_routing_address = self.get_argument("sip_routing_address", None) | |
344 | except ldap.STRONG_AUTH_REQUIRED as e: | |
345 | raise tornado.web.HTTPError(403, "%s" % e) from e | |
346 | ||
347 | # Redirect back to user page | |
348 | self.redirect("/users/%s" % account.uid) | |
349 | ||
350 | ||
9b8ff27d | 351 | class UserPasswdHandler(auth.CacheMixin, base.BaseHandler): |
3ea97943 MT |
352 | @tornado.web.authenticated |
353 | def get(self, uid): | |
354 | account = self.backend.accounts.get_by_uid(uid) | |
355 | if not account: | |
356 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
357 | ||
358 | # Check for permissions | |
359 | if not account.can_be_managed_by(self.current_user): | |
360 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
361 | ||
362 | self.render("people/passwd.html", account=account) | |
363 | ||
364 | @tornado.web.authenticated | |
365 | def post(self, uid): | |
366 | account = self.backend.accounts.get_by_uid(uid) | |
367 | if not account: | |
368 | raise tornado.web.HTTPError(404, "Could not find account %s" % uid) | |
369 | ||
370 | # Check for permissions | |
371 | if not account.can_be_managed_by(self.current_user): | |
372 | raise tornado.web.HTTPError(403, "%s cannot manage %s" % (self.current_user, account)) | |
373 | ||
374 | # Get current password | |
375 | password = self.get_argument("password") | |
376 | ||
377 | # Get new password | |
378 | password1 = self.get_argument("password1") | |
379 | password2 = self.get_argument("password2") | |
380 | ||
381 | # Passwords must match | |
382 | if not password1 == password2: | |
383 | raise tornado.web.HTTPError(400, "Passwords do not match") | |
384 | ||
385 | # XXX Check password complexity | |
386 | ||
387 | # Check if old password matches | |
388 | if not account.check_password(password): | |
389 | raise tornado.web.HTTPError(403, "Incorrect password for %s" % account) | |
390 | ||
391 | # Save new password | |
392 | account.passwd(password1) | |
393 | ||
394 | # Redirect back to user's page | |
395 | self.redirect("/users/%s" % account.uid) | |
396 | ||
397 | ||
2dac7110 | 398 | class SSODiscourse(auth.CacheMixin, base.BaseHandler): |
880fd132 MT |
399 | @base.ratelimit(minutes=24*60, requests=100) |
400 | @tornado.web.authenticated | |
401 | def get(self): | |
2dac7110 MT |
402 | # Fetch Discourse's parameters |
403 | sso = self.get_argument("sso") | |
404 | sig = self.get_argument("sig") | |
405 | ||
406 | # Decode payload | |
407 | try: | |
880fd132 | 408 | params = self.accounts.decode_discourse_payload(sso, sig) |
2dac7110 MT |
409 | |
410 | # Raise bad request if the signature is invalid | |
411 | except ValueError: | |
412 | raise tornado.web.HTTPError(400) | |
413 | ||
880fd132 | 414 | # Redirect back if user is already logged in |
2dac7110 | 415 | args = { |
880fd132 MT |
416 | "nonce" : params.get("nonce"), |
417 | "external_id" : self.current_user.uid, | |
2dac7110 MT |
418 | |
419 | # Pass email address | |
880fd132 | 420 | "email" : self.current_user.email, |
2dac7110 MT |
421 | "require_activation" : "false", |
422 | ||
423 | # More details about the user | |
880fd132 MT |
424 | "username" : self.current_user.uid, |
425 | "name" : "%s" % self.current_user, | |
426 | "bio" : self.current_user.description or "", | |
2dac7110 MT |
427 | |
428 | # Avatar | |
880fd132 | 429 | "avatar_url" : self.current_user.avatar_url(), |
2dac7110 MT |
430 | "avatar_force_update" : "true", |
431 | ||
432 | # Send a welcome message | |
433 | "suppress_welcome_message" : "false", | |
434 | ||
435 | # Group memberships | |
880fd132 MT |
436 | "admin" : "true" if self.current_user.is_admin() else "false", |
437 | "moderator" : "true" if self.current_user.is_moderator() else "false", | |
2dac7110 MT |
438 | } |
439 | ||
440 | # Format payload and sign it | |
441 | payload = self.accounts.encode_discourse_payload(**args) | |
442 | signature = self.accounts.sign_discourse_payload(payload) | |
443 | ||
444 | qs = urllib.parse.urlencode({ | |
445 | "sso" : payload, | |
446 | "sig" : signature, | |
447 | }) | |
448 | ||
449 | # Redirect user | |
880fd132 | 450 | self.redirect("%s?%s" % (params.get("return_sso_url"), qs)) |
2dac7110 MT |
451 | |
452 | ||
9150881e MT |
453 | class NewAccountsModule(ui_modules.UIModule): |
454 | def render(self, days=14): | |
455 | t = datetime.datetime.utcnow() - datetime.timedelta(days=days) | |
456 | ||
457 | # Fetch all accounts created after t | |
458 | accounts = self.backend.accounts.get_created_after(t) | |
459 | ||
460 | accounts.sort(key=lambda a: a.created_at, reverse=True) | |
461 | ||
462 | return self.render_string("people/modules/accounts-new.html", | |
463 | accounts=accounts, t=t) | |
464 | ||
465 | ||
786e9ca8 MT |
466 | class AccountsListModule(ui_modules.UIModule): |
467 | def render(self, accounts=None): | |
468 | if accounts is None: | |
51907e45 | 469 | accounts = self.backend.accounts |
786e9ca8 MT |
470 | |
471 | return self.render_string("people/modules/accounts-list.html", accounts=accounts) | |
472 | ||
473 | ||
c66f2152 MT |
474 | class AgentModule(ui_modules.UIModule): |
475 | def render(self, account): | |
476 | return self.render_string("people/modules/agent.html", account=account) | |
477 | ||
478 | ||
786e9ca8 | 479 | class CDRModule(ui_modules.UIModule): |
bdaf6b46 MT |
480 | def render(self, account, date=None, limit=None): |
481 | cdr = account.get_cdr(date=date, limit=limit) | |
786e9ca8 | 482 | |
89e47299 MT |
483 | return self.render_string("people/modules/cdr.html", |
484 | account=account, cdr=list(cdr)) | |
786e9ca8 MT |
485 | |
486 | ||
487 | class ChannelsModule(ui_modules.UIModule): | |
488 | def render(self, account): | |
1f38be5a MT |
489 | return self.render_string("people/modules/channels.html", |
490 | account=account, channels=account.sip_channels) | |
786e9ca8 MT |
491 | |
492 | ||
68ece434 MT |
493 | class MOSModule(ui_modules.UIModule): |
494 | def render(self, call): | |
495 | return self.render_string("people/modules/mos.html", call=call) | |
496 | ||
497 | ||
b5e2077f | 498 | class PasswordModule(ui_modules.UIModule): |
56894a8b | 499 | def render(self, account=None): |
b5e2077f MT |
500 | return self.render_string("people/modules/password.html", account=account) |
501 | ||
502 | def javascript_files(self): | |
503 | return "js/zxcvbn.js" | |
504 | ||
505 | def embedded_javascript(self): | |
506 | return self.render_string("people/modules/password.js") | |
507 | ||
508 | ||
786e9ca8 MT |
509 | class RegistrationsModule(ui_modules.UIModule): |
510 | def render(self, account): | |
511 | return self.render_string("people/modules/registrations.html", account=account) | |
7afd64bb MT |
512 | |
513 | ||
514 | class SIPStatusModule(ui_modules.UIModule): | |
515 | def render(self, account): | |
516 | return self.render_string("people/modules/sip-status.html", account=account) |