]> git.ipfire.org Git - ipfire.org.git/blame - src/web/people.py
users: Add index page
[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):
15bb44ee 25 self.render("people/index.html")
786e9ca8 26
2cd9af74 27
c4f1a618 28class 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 125class 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 147class 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 167class 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
173class 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
183class 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 198class 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
214class 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 224class 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
233class 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 249class 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 263class 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 # Email
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 321class 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 368class 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
423class 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
431class AgentModule(ui_modules.UIModule):
432 def render(self, account):
433 return self.render_string("people/modules/agent.html", account=account)
434
435
786e9ca8 436class 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
444class 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
450class MOSModule(ui_modules.UIModule):
451 def render(self, call):
452 return self.render_string("people/modules/mos.html", call=call)
453
454
b5e2077f 455class 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
466class RegistrationsModule(ui_modules.UIModule):
467 def render(self, account):
468 return self.render_string("people/modules/registrations.html", account=account)