Merge pull request 'Avatar vcard field - show / delete / upload + avatar serving improvements' (#2) from avatar-upload into master
Reviewed-on: #2master
commit
0ae77291cc
|
@ -16,7 +16,7 @@ from PIL import Image, ImageDraw
|
|||
import flask
|
||||
import ldap
|
||||
|
||||
from webapp import context, ldaputils
|
||||
from webapp import context, ldaputils, config
|
||||
|
||||
bp = flask.Blueprint('avatar', __name__)
|
||||
log = logging.getLogger('ldap-web.avatar')
|
||||
|
@ -70,6 +70,12 @@ def resize_image(image: Image, length: int) -> Image:
|
|||
# We now have a length*length pixels image.
|
||||
return resized_image
|
||||
|
||||
def process_upload(data: bytes) -> bytes:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = resize_image(img, 256)
|
||||
res = io.BytesIO()
|
||||
img.save(res, 'PNG')
|
||||
return base64.b64encode(res.getvalue())
|
||||
|
||||
syrenka = Image.open("syrenka.png")
|
||||
|
||||
|
@ -99,7 +105,7 @@ def default_avatar(uid: str) -> Image:
|
|||
|
||||
# Crop to headshot.
|
||||
overlay = overlay.crop(box=(0, 0, w, w))
|
||||
|
||||
|
||||
# Give it a little nudge.
|
||||
overlay = overlay.rotate((rng.random() - 0.5) * 100)
|
||||
|
||||
|
@ -130,7 +136,7 @@ class AvatarCacheEntry:
|
|||
|
||||
def __init__(self, uid: str, data: bytes):
|
||||
self.uid = uid
|
||||
self.deadline = time.time() + 3600
|
||||
self.deadline = time.time() + config.avatar_cache_timeout
|
||||
self.data = data
|
||||
self._converted = b""
|
||||
|
||||
|
@ -162,6 +168,13 @@ class AvatarCache:
|
|||
def __init__(self):
|
||||
self.entries = {}
|
||||
|
||||
def reset(self):
|
||||
self.entries = {}
|
||||
|
||||
def reset_user(self, uid: str):
|
||||
if uid in self.entries:
|
||||
del self.entries[uid]
|
||||
|
||||
def get(self, uid: str, bust: bool = False) -> AvatarCacheEntry:
|
||||
"""
|
||||
Get avatar, either from cache or from LDAP on cache miss. If 'bust' is
|
||||
|
@ -174,6 +187,9 @@ class AvatarCache:
|
|||
entry = self.entries[uid]
|
||||
if entry.deadline > now:
|
||||
return entry.serve()
|
||||
else:
|
||||
# Entry expired, remove it.
|
||||
del self.entries[uid]
|
||||
|
||||
# Otherwise, retrieve from LDAP.
|
||||
conn = context.get_admin_connection()
|
||||
|
@ -184,10 +200,16 @@ class AvatarCache:
|
|||
res = []
|
||||
|
||||
avatar = None
|
||||
if len(res) == 1:
|
||||
is_user_found = len(res) == 1
|
||||
if is_user_found:
|
||||
for attr, vs in res[0][1].items():
|
||||
if attr == 'jpegPhoto':
|
||||
for v in vs:
|
||||
# Temporary workaround: treat empty jpegPhoto as no avatar.
|
||||
if v == b'':
|
||||
avatar = None
|
||||
break
|
||||
|
||||
try:
|
||||
avatar = base64.b64decode(v)
|
||||
except binascii.Error as e:
|
||||
|
@ -199,7 +221,9 @@ class AvatarCache:
|
|||
# If nothing was found in LDAP (either uid doesn't exist or uid doesn't
|
||||
# have an avatar attached), serve default avatar.
|
||||
if avatar is None:
|
||||
avatar = default_avatar(uid)
|
||||
# don't generate avatars for non-users to reduce DoS potential
|
||||
# (note: capacifier already leaks existence of users, so whatever)
|
||||
avatar = default_avatar(uid if is_user_found else 'default')
|
||||
|
||||
# Save avatar in cache.
|
||||
entry = AvatarCacheEntry(uid, avatar)
|
||||
|
|
|
@ -28,9 +28,13 @@ ldap_active_groups = os.getenv('LDAPWEB_ACTIVE_GROUPS', 'fatty,starving,potato')
|
|||
ldap_admin_dn = os.getenv('LDAPWEB_ADMIN_DN', 'cn=ldapweb,ou=services,dc=hackerspace,dc=pl')
|
||||
ldap_admin_password = os.getenv('LDAPWEB_ADMIN_PASSWORD', 'unused')
|
||||
|
||||
# avatar server
|
||||
avatar_cache_timeout = int(os.getenv('LDAPWEB_AVATAR_CACHE_TIMEOUT', '1800'))
|
||||
|
||||
# LDAP attribute configuration
|
||||
|
||||
readable_names = {
|
||||
'jpegphoto': 'Avatar',
|
||||
'commonname': 'Common Name',
|
||||
'givenname': 'Given Name',
|
||||
'gecos': 'GECOS (public name)',
|
||||
|
@ -53,12 +57,14 @@ full_name = {
|
|||
}
|
||||
|
||||
can_add = set([
|
||||
'jpegphoto',
|
||||
'telephonenumber',
|
||||
'mobiletelephonenumber',
|
||||
'sshpublickey',
|
||||
])
|
||||
can_delete = can_add
|
||||
can_modify = can_add | set([
|
||||
'jpegphoto',
|
||||
'givenname',
|
||||
'surname',
|
||||
'commonname',
|
||||
|
@ -69,6 +75,7 @@ admin_required = set()
|
|||
|
||||
default_field = (wtforms.fields.StringField, {})
|
||||
fields = {
|
||||
'jpegphoto': (wtforms.fields.FileField, {'validators': []}),
|
||||
'mobiletelephonenumber': (wtforms.fields.StringField, {'validators': [wtforms.validators.Regexp(r'[+0-9 ]+')]}),
|
||||
'telephonenumber': (wtforms.fields.StringField, {'validators': [wtforms.validators.Regexp(r'[+0-9 ]+')]}),
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import hashlib
|
|||
import flask
|
||||
import ldap
|
||||
|
||||
from webapp import app, config, validation
|
||||
from webapp import app, config, validation, avatar
|
||||
|
||||
class Attr(object):
|
||||
def __init__(self, name, value):
|
||||
|
@ -45,5 +45,12 @@ def refresh_profile(dn=None):
|
|||
for v in vs:
|
||||
a = Attr(attr, v)
|
||||
profile[a.uid] = a
|
||||
if attr == 'uid':
|
||||
user_uid = v.decode('utf-8')
|
||||
app.profiles[dn] = profile
|
||||
|
||||
# bust avatar cache
|
||||
if user_uid:
|
||||
avatar.cache.reset_user(user_uid)
|
||||
|
||||
return profile
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<div class="modal fade" tabindex="-1" role="dialog">
|
||||
<form action="/vcard/add/{{ attr_name }}" method="POST" class="form-signin">
|
||||
<form action="/vcard/add/{{ attr_name }}" method="POST" class="form-signin"
|
||||
{% if attr_name == 'jpegphoto' %}
|
||||
enctype="multipart/form-data"
|
||||
{% endif %}
|
||||
>
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
|
|
@ -7,7 +7,12 @@
|
|||
<h4 class="modal-title">Deleting {{ form.attr_data.readable_name }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
You are about to delete {{ form.attr_data }} from {{ form.attr_data.readable_name }}.
|
||||
{% if attr_name == 'jpegphoto' %}
|
||||
{% set value = 'avatar' %}
|
||||
{% else %}
|
||||
{% set value = form.attr_data %}
|
||||
{% endif %}
|
||||
You are about to delete {{ value }} from {{ form.attr_data.readable_name }}.
|
||||
{{ form.csrf_token }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
|
|
@ -1,22 +1,18 @@
|
|||
{% macro field(name, width=4) -%}
|
||||
{% if profile[name] %}
|
||||
{% with field = profile[name]|first %}
|
||||
<div class="col-md-{{width}}" style="margin-bottom: 20px;">
|
||||
<div style="background-color: #eee; padding: 10px 30px; border-radius: 12px;">
|
||||
<h4>{{ name|readable }}</h4>
|
||||
<h2 style="margin-top: 0">{{ field }}</h2>
|
||||
{% if field.name in can_delete %}delete{% endif %}
|
||||
{% if name in can_modify %}
|
||||
<a class="modalLink" href="/vcard/modify/{{ field.uid }}"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit</a>
|
||||
{% endif %}
|
||||
<h4>{{ name|readable }}</h4>
|
||||
{% if profile[name] %}
|
||||
{% with field = profile[name]|first %}
|
||||
<h2 style="margin-top: 0">{{ field }}</h2>
|
||||
{% if field.name in can_delete %}delete{% endif %}
|
||||
{% if name in can_modify %}
|
||||
<a class="modalLink" href="/vcard/modify/{{ field.uid }}"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="col-md-6">
|
||||
{{ name|readable }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro multifield(name, code=False, width=4) -%}
|
||||
|
@ -43,15 +39,37 @@
|
|||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro avatarfield(name, width=4) -%}
|
||||
<div class="col-md-{{width}}" style="margin-bottom: 20px;">
|
||||
<div style="background-color: #eee; padding: 10px 30px; border-radius: 12px; display: flex; flex-direction: row; gap: 20px">
|
||||
<div>
|
||||
<img src="/avatar/{{ profile.uid[0] }}" style="width: 70px; height: 70px; margin: 10px 0" />
|
||||
</div>
|
||||
<div>
|
||||
<h4>{{ name|readable }}</h4>
|
||||
{% set field = profile[name]|first %}
|
||||
{% if field and (field.value | length) %}
|
||||
{% if name in can_delete %}
|
||||
<a class="modalLink" href="/vcard/delete/{{ field.uid }}"><span class="glyphicon glyphicon-minus-sign" aria-hidden="true"></span> Remove</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a class="modalLink" href="/vcard/add/{{ name }}"><span class="glyphicon glyphicon-plus-sign" aria-hidden="true"></span> Upload</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% extends 'basic.html' %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{{ avatarfield('jpegphoto') }}
|
||||
{{ field('givenname') }}
|
||||
{{ field('gecos', width=8) }}
|
||||
{{ field('surname') }}
|
||||
{{ field('commonname', width=8) }}
|
||||
{{ field('gecos', width=8) }}
|
||||
{{ field('loginshell') }}
|
||||
{{ field('commonname', width=8) }}
|
||||
{{ multifield('telephonenumber') }}
|
||||
{{ multifield('mobiletelephonenumber') }}
|
||||
{{ multifield('mailroutingaddress', width=8) }}
|
||||
|
|
|
@ -2,14 +2,11 @@ import ldap
|
|||
import flask
|
||||
import flask_wtf
|
||||
|
||||
from webapp import app, context, config, validation
|
||||
from webapp import app, context, config, validation, avatar
|
||||
from webapp.auth import login_required
|
||||
|
||||
bp = flask.Blueprint('vcard', __name__)
|
||||
|
||||
def str_to_ldap(s):
|
||||
return s.encode('utf-8')
|
||||
|
||||
perm_errors = {
|
||||
'add': 'You cannot add this attribute!',
|
||||
'mod': 'You cannot change this attribute!',
|
||||
|
@ -46,13 +43,33 @@ def attr_op(op, attrName, uid = None, success_redirect='/vcard',
|
|||
if op == 'mod' and attrName not in ['commonname']:
|
||||
op = 'modreadd'
|
||||
|
||||
if attrName == 'jpegphoto':
|
||||
# Temporary workaround: deleting jpegPhoto doesn't work, set to empty instead
|
||||
if op == 'del':
|
||||
op = 'mod'
|
||||
new_value = ''
|
||||
else:
|
||||
try:
|
||||
op = 'mod'
|
||||
processed_avatar = avatar.process_upload(new_value.read())
|
||||
new_value = processed_avatar
|
||||
print(f'Uplading avatar (size: {len(processed_avatar)}) for {dn}')
|
||||
except Exception as e:
|
||||
flask.flash('Could not process avatar: {}'.format(e), 'danger')
|
||||
return flask.redirect(fatal_redirect)
|
||||
|
||||
def to_ldap(s):
|
||||
if isinstance(s, bytes):
|
||||
return s
|
||||
return s.encode('utf-8')
|
||||
|
||||
if op in ['del', 'modreadd']:
|
||||
conn.modify_s(dn, [(ldap.MOD_DELETE, attrName, str_to_ldap(old_value))])
|
||||
conn.modify_s(dn, [(ldap.MOD_DELETE, attrName, to_ldap(old_value))])
|
||||
if op in ['add', 'modreadd']:
|
||||
conn.modify_s(dn, [(ldap.MOD_ADD, attrName, str_to_ldap(new_value))])
|
||||
conn.modify_s(dn, [(ldap.MOD_ADD, attrName, to_ldap(new_value))])
|
||||
|
||||
if op in ['mod']:
|
||||
conn.modify_s(dn, [(ldap.MOD_REPLACE, attrName, str_to_ldap(new_value))])
|
||||
conn.modify_s(dn, [(ldap.MOD_REPLACE, attrName, to_ldap(new_value))])
|
||||
|
||||
context.refresh_profile()
|
||||
return flask.redirect(success_redirect)
|
||||
|
|
Loading…
Reference in New Issue