Merge pull request 'Avatar vcard field - show / delete / upload + avatar serving improvements' (#2) from avatar-upload into master

Reviewed-on: #2
master
radex 2023-10-20 19:58:12 +00:00
commit 0ae77291cc
7 changed files with 113 additions and 31 deletions

View File

@ -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)

View File

@ -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 ]+')]}),
}

View File

@ -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

View File

@ -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">

View File

@ -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">

View File

@ -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) }}

View File

@ -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)