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 flask
|
||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
from webapp import context, ldaputils
|
from webapp import context, ldaputils, config
|
||||||
|
|
||||||
bp = flask.Blueprint('avatar', __name__)
|
bp = flask.Blueprint('avatar', __name__)
|
||||||
log = logging.getLogger('ldap-web.avatar')
|
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.
|
# We now have a length*length pixels image.
|
||||||
return resized_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")
|
syrenka = Image.open("syrenka.png")
|
||||||
|
|
||||||
|
@ -99,7 +105,7 @@ def default_avatar(uid: str) -> Image:
|
||||||
|
|
||||||
# Crop to headshot.
|
# Crop to headshot.
|
||||||
overlay = overlay.crop(box=(0, 0, w, w))
|
overlay = overlay.crop(box=(0, 0, w, w))
|
||||||
|
|
||||||
# Give it a little nudge.
|
# Give it a little nudge.
|
||||||
overlay = overlay.rotate((rng.random() - 0.5) * 100)
|
overlay = overlay.rotate((rng.random() - 0.5) * 100)
|
||||||
|
|
||||||
|
@ -130,7 +136,7 @@ class AvatarCacheEntry:
|
||||||
|
|
||||||
def __init__(self, uid: str, data: bytes):
|
def __init__(self, uid: str, data: bytes):
|
||||||
self.uid = uid
|
self.uid = uid
|
||||||
self.deadline = time.time() + 3600
|
self.deadline = time.time() + config.avatar_cache_timeout
|
||||||
self.data = data
|
self.data = data
|
||||||
self._converted = b""
|
self._converted = b""
|
||||||
|
|
||||||
|
@ -162,6 +168,13 @@ class AvatarCache:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.entries = {}
|
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:
|
def get(self, uid: str, bust: bool = False) -> AvatarCacheEntry:
|
||||||
"""
|
"""
|
||||||
Get avatar, either from cache or from LDAP on cache miss. If 'bust' is
|
Get avatar, either from cache or from LDAP on cache miss. If 'bust' is
|
||||||
|
@ -174,6 +187,9 @@ class AvatarCache:
|
||||||
entry = self.entries[uid]
|
entry = self.entries[uid]
|
||||||
if entry.deadline > now:
|
if entry.deadline > now:
|
||||||
return entry.serve()
|
return entry.serve()
|
||||||
|
else:
|
||||||
|
# Entry expired, remove it.
|
||||||
|
del self.entries[uid]
|
||||||
|
|
||||||
# Otherwise, retrieve from LDAP.
|
# Otherwise, retrieve from LDAP.
|
||||||
conn = context.get_admin_connection()
|
conn = context.get_admin_connection()
|
||||||
|
@ -184,10 +200,16 @@ class AvatarCache:
|
||||||
res = []
|
res = []
|
||||||
|
|
||||||
avatar = None
|
avatar = None
|
||||||
if len(res) == 1:
|
is_user_found = len(res) == 1
|
||||||
|
if is_user_found:
|
||||||
for attr, vs in res[0][1].items():
|
for attr, vs in res[0][1].items():
|
||||||
if attr == 'jpegPhoto':
|
if attr == 'jpegPhoto':
|
||||||
for v in vs:
|
for v in vs:
|
||||||
|
# Temporary workaround: treat empty jpegPhoto as no avatar.
|
||||||
|
if v == b'':
|
||||||
|
avatar = None
|
||||||
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
avatar = base64.b64decode(v)
|
avatar = base64.b64decode(v)
|
||||||
except binascii.Error as e:
|
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
|
# If nothing was found in LDAP (either uid doesn't exist or uid doesn't
|
||||||
# have an avatar attached), serve default avatar.
|
# have an avatar attached), serve default avatar.
|
||||||
if avatar is None:
|
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.
|
# Save avatar in cache.
|
||||||
entry = AvatarCacheEntry(uid, avatar)
|
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_dn = os.getenv('LDAPWEB_ADMIN_DN', 'cn=ldapweb,ou=services,dc=hackerspace,dc=pl')
|
||||||
ldap_admin_password = os.getenv('LDAPWEB_ADMIN_PASSWORD', 'unused')
|
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
|
# LDAP attribute configuration
|
||||||
|
|
||||||
readable_names = {
|
readable_names = {
|
||||||
|
'jpegphoto': 'Avatar',
|
||||||
'commonname': 'Common Name',
|
'commonname': 'Common Name',
|
||||||
'givenname': 'Given Name',
|
'givenname': 'Given Name',
|
||||||
'gecos': 'GECOS (public name)',
|
'gecos': 'GECOS (public name)',
|
||||||
|
@ -53,12 +57,14 @@ full_name = {
|
||||||
}
|
}
|
||||||
|
|
||||||
can_add = set([
|
can_add = set([
|
||||||
|
'jpegphoto',
|
||||||
'telephonenumber',
|
'telephonenumber',
|
||||||
'mobiletelephonenumber',
|
'mobiletelephonenumber',
|
||||||
'sshpublickey',
|
'sshpublickey',
|
||||||
])
|
])
|
||||||
can_delete = can_add
|
can_delete = can_add
|
||||||
can_modify = can_add | set([
|
can_modify = can_add | set([
|
||||||
|
'jpegphoto',
|
||||||
'givenname',
|
'givenname',
|
||||||
'surname',
|
'surname',
|
||||||
'commonname',
|
'commonname',
|
||||||
|
@ -69,6 +75,7 @@ admin_required = set()
|
||||||
|
|
||||||
default_field = (wtforms.fields.StringField, {})
|
default_field = (wtforms.fields.StringField, {})
|
||||||
fields = {
|
fields = {
|
||||||
|
'jpegphoto': (wtforms.fields.FileField, {'validators': []}),
|
||||||
'mobiletelephonenumber': (wtforms.fields.StringField, {'validators': [wtforms.validators.Regexp(r'[+0-9 ]+')]}),
|
'mobiletelephonenumber': (wtforms.fields.StringField, {'validators': [wtforms.validators.Regexp(r'[+0-9 ]+')]}),
|
||||||
'telephonenumber': (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 flask
|
||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
from webapp import app, config, validation
|
from webapp import app, config, validation, avatar
|
||||||
|
|
||||||
class Attr(object):
|
class Attr(object):
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
|
@ -45,5 +45,12 @@ def refresh_profile(dn=None):
|
||||||
for v in vs:
|
for v in vs:
|
||||||
a = Attr(attr, v)
|
a = Attr(attr, v)
|
||||||
profile[a.uid] = a
|
profile[a.uid] = a
|
||||||
|
if attr == 'uid':
|
||||||
|
user_uid = v.decode('utf-8')
|
||||||
app.profiles[dn] = profile
|
app.profiles[dn] = profile
|
||||||
|
|
||||||
|
# bust avatar cache
|
||||||
|
if user_uid:
|
||||||
|
avatar.cache.reset_user(user_uid)
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
<div class="modal fade" tabindex="-1" role="dialog">
|
<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-dialog" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|
|
@ -7,7 +7,12 @@
|
||||||
<h4 class="modal-title">Deleting {{ form.attr_data.readable_name }}</h4>
|
<h4 class="modal-title">Deleting {{ form.attr_data.readable_name }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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 }}
|
{{ form.csrf_token }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|
|
@ -1,22 +1,18 @@
|
||||||
{% macro field(name, width=4) -%}
|
{% macro field(name, width=4) -%}
|
||||||
{% if profile[name] %}
|
|
||||||
{% with field = profile[name]|first %}
|
|
||||||
<div class="col-md-{{width}}" style="margin-bottom: 20px;">
|
<div class="col-md-{{width}}" style="margin-bottom: 20px;">
|
||||||
<div style="background-color: #eee; padding: 10px 30px; border-radius: 12px;">
|
<div style="background-color: #eee; padding: 10px 30px; border-radius: 12px;">
|
||||||
<h4>{{ name|readable }}</h4>
|
<h4>{{ name|readable }}</h4>
|
||||||
<h2 style="margin-top: 0">{{ field }}</h2>
|
{% if profile[name] %}
|
||||||
{% if field.name in can_delete %}delete{% endif %}
|
{% with field = profile[name]|first %}
|
||||||
{% if name in can_modify %}
|
<h2 style="margin-top: 0">{{ field }}</h2>
|
||||||
<a class="modalLink" href="/vcard/modify/{{ field.uid }}"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit</a>
|
{% if field.name in can_delete %}delete{% endif %}
|
||||||
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
|
||||||
{% else %}
|
|
||||||
<div class="col-md-6">
|
|
||||||
{{ name|readable }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro multifield(name, code=False, width=4) -%}
|
{% macro multifield(name, code=False, width=4) -%}
|
||||||
|
@ -43,15 +39,37 @@
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- 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' %}
|
{% extends 'basic.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
{{ avatarfield('jpegphoto') }}
|
||||||
{{ field('givenname') }}
|
{{ field('givenname') }}
|
||||||
{{ field('gecos', width=8) }}
|
|
||||||
{{ field('surname') }}
|
{{ field('surname') }}
|
||||||
{{ field('commonname', width=8) }}
|
{{ field('gecos', width=8) }}
|
||||||
{{ field('loginshell') }}
|
{{ field('loginshell') }}
|
||||||
|
{{ field('commonname', width=8) }}
|
||||||
{{ multifield('telephonenumber') }}
|
{{ multifield('telephonenumber') }}
|
||||||
{{ multifield('mobiletelephonenumber') }}
|
{{ multifield('mobiletelephonenumber') }}
|
||||||
{{ multifield('mailroutingaddress', width=8) }}
|
{{ multifield('mailroutingaddress', width=8) }}
|
||||||
|
|
|
@ -2,14 +2,11 @@ import ldap
|
||||||
import flask
|
import flask
|
||||||
import flask_wtf
|
import flask_wtf
|
||||||
|
|
||||||
from webapp import app, context, config, validation
|
from webapp import app, context, config, validation, avatar
|
||||||
from webapp.auth import login_required
|
from webapp.auth import login_required
|
||||||
|
|
||||||
bp = flask.Blueprint('vcard', __name__)
|
bp = flask.Blueprint('vcard', __name__)
|
||||||
|
|
||||||
def str_to_ldap(s):
|
|
||||||
return s.encode('utf-8')
|
|
||||||
|
|
||||||
perm_errors = {
|
perm_errors = {
|
||||||
'add': 'You cannot add this attribute!',
|
'add': 'You cannot add this attribute!',
|
||||||
'mod': 'You cannot change 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']:
|
if op == 'mod' and attrName not in ['commonname']:
|
||||||
op = 'modreadd'
|
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']:
|
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']:
|
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']:
|
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()
|
context.refresh_profile()
|
||||||
return flask.redirect(success_redirect)
|
return flask.redirect(success_redirect)
|
||||||
|
|
Loading…
Reference in New Issue