Merge pull request 'Gravatar etc.' (#6) from gravatar into master

Reviewed-on: #6
master
radex 2023-10-29 18:36:06 +00:00
commit 34d897ff78
5 changed files with 82 additions and 4 deletions

View File

@ -11,6 +11,7 @@ import time
import io
import logging
import random
import hashlib
from PIL import Image, ImageDraw
import flask
@ -234,6 +235,68 @@ class AvatarCache:
cache = AvatarCache()
@bp.route('/avatar/<uid>', methods=['GET'])
def hash_for_uid(uid):
# NOTE: Gravatar documentation says to use SHA256, but everyone passes MD5 instead
email = f'{uid}@hackerspace.pl'.strip().lower()
hasher = hashlib.md5()
hasher.update(email.encode())
return hasher.hexdigest()
def get_all_user_uids(conn):
all_uids = []
results = conn.search_s(config.ldap_people, ldap.SCOPE_SUBTREE, 'uid=*', attrlist=['uid'])
for user, attrs in results:
uid = attrs['uid'][0].decode()
all_uids.append(uid)
return all_uids
class HashCache:
# email hash -> uid mapping
entries: dict[str, str] = {}
# deadline when this cache expires
deadline: float = 0
def get(self, email_hash: str) -> str:
self.rebuild_if_needed()
return self.entries.get(email_hash, 'default')
def reset(self):
self.entries = {}
self.deadline = 0
def rebuild_if_needed(self):
now = time.time()
if now > self.deadline:
self.rebuild()
def rebuild(self):
log.info("Rebuilding email hash cache")
conn = context.get_admin_connection()
users = get_all_user_uids(conn)
self.deadline = time.time() + config.avatar_cache_timeout
self.entries = { hash_for_uid(uid): uid for uid in users }
hash_cache = HashCache()
def sanitize_email_hash(hash: str):
"""
lowercases, removes file extension (probably)
"""
hash = hash.lower()
if hash.endswith('.png') or hash.endswith('.jpg'):
hash = hash[:-4]
return hash
@bp.route('/avatar/<email_hash>', methods=['GET'])
def gravatar_serve(email_hash):
"""
Serves avatar in a Gravatar-compatible(ish) way, i.e. by email hash, not user name.
"""
uid = hash_cache.get(sanitize_email_hash(email_hash))
return cache.get(uid)
@bp.route('/avatar/user/<uid>', methods=['GET'])
def avatar_serve(uid):
return cache.get(uid)

View File

@ -52,5 +52,6 @@ def refresh_profile(dn=None):
# bust avatar cache
if user_uid:
avatar.cache.reset_user(user_uid)
avatar.hash_cache.reset()
return profile

View File

@ -4,7 +4,7 @@ from webapp import config
def is_valid_name(name):
"""`true` if `name` is a safe ldap uid/cn"""
return re.match(r'^[a-zA-Z_][a-zA-Z0-9-_]*\Z', name) is not None
return re.match(r'^[a-zA-Z_][a-zA-Z0-9-_\.]*\Z', name) is not None
def validate_name(name):
"""Raises `RuntimeError` if `name` is not a safe ldap uid/cn"""

View File

@ -1,5 +1,7 @@
{% extends 'basic.html' %}
{% block content %}
<img src="/avatar/user/{{ uid }}" class="profile-avatar-lmao" />
<h1>User: {{ uid }}</h1>
<div style="margin-bottom: 10px">
@ -27,7 +29,7 @@
<tr>
<td>{{ attr }}</td>
<td>{{ attr_readable if attr_readable else '' }}</td>
<td class="profile-table-value">{{ value }}</td>
<td class="profile-table-value">{{ '(...omitted...)' if attr == 'jpegPhoto' else value }}</td>
</tr>
{% endfor %}
</table>
@ -45,5 +47,17 @@
.profile-table th.profile-table-value {
width: max-content;
}
.profile-avatar-lmao {
width: 120px;
height: 120px;
margin: 10px 0;
border-radius: 10px;
float: right;
box-shadow: 5px 5px 0 #ddd;
transition: transform 0.2s ease-in-out;
}
.profile-avatar-lmao:hover {
transform: scale(1.2) rotate(359deg);
}
</style>
{% endblock %}

View File

@ -43,7 +43,7 @@
<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" />
<img src="/avatar/user/{{ profile.uid[0] }}" style="width: 70px; height: 70px; margin: 10px 0" />
</div>
<div>
<h4>{{ name|readable }}</h4>