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