]> git.ipfire.org Git - ipfire.org.git/blame - src/web/people.py
donation: Fix typo
[ipfire.org.git] / src / web / people.py
CommitLineData
2cd9af74
MT
1#!/usr/bin/python
2
3210adef 3import PIL
bdaf6b46 4import datetime
3210adef
MT
5import imghdr
6import io
e96e445b 7import ldap
2cd9af74 8import logging
3210adef 9import os.path
2cd9af74 10import tornado.web
2dac7110 11import urllib.parse
2cd9af74 12
0099c2a7
MT
13from .. import countries
14
9b8ff27d 15from . import auth
124a8404 16from . import base
786e9ca8
MT
17from . import ui_modules
18
3210adef
MT
19COLOUR_LIGHT = (237,232,232)
20COLOUR_DARK = (49,53,60)
21
9b8ff27d 22class 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 38class 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 135class 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 157class 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 177class 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
183class 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
193class 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 208class 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
224class 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 234class 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
243class 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 259class 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 273class 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 283class 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 293class 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 # Email
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 351class 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 398class 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
453class 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
466class 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
474class AgentModule(ui_modules.UIModule):
475 def render(self, account):
476 return self.render_string("people/modules/agent.html", account=account)
477
478
786e9ca8 479class 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
487class 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
493class MOSModule(ui_modules.UIModule):
494 def render(self, call):
495 return self.render_string("people/modules/mos.html", call=call)
496
497
b5e2077f 498class 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
509class RegistrationsModule(ui_modules.UIModule):
510 def render(self, account):
511 return self.render_string("people/modules/registrations.html", account=account)
7afd64bb
MT
512
513
514class SIPStatusModule(ui_modules.UIModule):
515 def render(self, account):
516 return self.render_string("people/modules/sip-status.html", account=account)