feat(plugins) Plugins use native Image objects as default avatar, but can still implement their own stuff.

This commit is contained in:
Ferdinand Thiessen 2021-11-29 18:15:21 +01:00
parent 06caec86e7
commit 0ce52de8cd
7 changed files with 61 additions and 68 deletions

View File

@ -1,6 +1,8 @@
import secrets
from io import BytesIO
from flask import current_app
from datetime import datetime, timedelta, timezone
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger
@ -10,7 +12,7 @@ from flaschengeist.models.notification import Notification
from flaschengeist.utils.hook import Hook
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.models.user import User, Role, _PasswordReset
from flaschengeist.controller import messageController, sessionController
from flaschengeist.controller import imageController, messageController, sessionController
def _generate_password_reset(user):
@ -79,7 +81,7 @@ def update_user(user):
db.session.commit()
def set_roles(user: User, roles: [str], create=False):
def set_roles(user: User, roles: list[str], create=False):
user.roles_.clear()
for role_name in roles:
role = Role.query.filter(Role.name == role_name).one_or_none()
@ -201,11 +203,17 @@ def register(data):
def load_avatar(user: User):
return current_app.config["FG_AUTH_BACKEND"].get_avatar(user)
if user.avatar_ is not None:
return imageController.send_image(image=user.avatar_)
else:
avatar = current_app.config["FG_AUTH_BACKEND"].get_avatar(user)
if len(avatar.binary) > 0:
return send_file(BytesIO(avatar.binary), avatar.mimetype)
raise NotFound
def save_avatar(user, avatar):
current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar)
def save_avatar(user, file):
current_app.config["FG_AUTH_BACKEND"].set_avatar(user, file)
db.session.commit()

View File

@ -5,6 +5,8 @@ from typing import Optional
from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection
from flaschengeist.models.image import Image
from ..database import db
from . import ModelSerializeMixin, UtcDateTime, Serial
@ -60,22 +62,19 @@ class User(db.Model, ModelSerializeMixin):
birthday: Optional[date] = db.Column(db.Date)
roles: list[str] = []
permissions: Optional[list[str]] = None
avatar_url: Optional[str] = ""
id_ = db.Column("id", Serial, primary_key=True)
roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_ = db.relationship("Session", back_populates="user_")
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
_avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id"))
_attributes = db.relationship(
"_UserAttribute",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete",
)
@property
def avatar_url(self):
return url_for("users.get_avatar", userid=self.userid) if self.userid else None
@property
def roles(self):
return [role.name for role in self.roles_]

View File

@ -1,10 +1,12 @@
import sqlalchemy
import pkg_resources
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.controller import imageController
from flaschengeist.database import db
from flaschengeist.models.notification import Notification
from flaschengeist.models.user import _Avatar
from flaschengeist.models.user import _Avatar, User
from flaschengeist.models.setting import _PluginSetting
from flaschengeist.utils.hook import HookBefore, HookAfter
@ -171,9 +173,13 @@ class AuthPlugin(Plugin):
"""
raise MethodNotAllowed
def get_avatar(self, user) -> _Avatar:
def get_avatar(self, user: User) -> _Avatar:
"""Retrieve avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects,
so by default this function is never called, as the userController checks
native Image objects first.
Args:
user: User to retrieve the avatar for
Raises:
@ -181,24 +187,29 @@ class AuthPlugin(Plugin):
"""
raise NotFound
def set_avatar(self, user, avatar: _Avatar):
def set_avatar(self, user: User, file: FileStorage):
"""Set the avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects stored on the Flaschengeist server
Args:
user: User to set the avatar for
avatar: Avatar to set
file: FileStorage object uploaded by the user
Raises:
MethodNotAllowed: If not supported by Backend
Any valid HTTP exception
"""
raise MethodNotAllowed
user.avatar_ = imageController.upload_image(file)
def delete_avatar(self, user):
def delete_avatar(self, user: User):
"""Delete the avatar for given user (if supported by auth backend)
Default behavior is to use the imageController and native Image objects.
Args:
user: Uset to delete the avatar for
Raises:
MethodNotAllowed: If not supported by Backend
"""
raise MethodNotAllowed
user.avatar_ = None

View File

@ -37,13 +37,13 @@ def login():
except (KeyError, ValueError, TypeError):
raise BadRequest("Missing parameter(s)")
logger.debug("search user {{ {} }} in database".format(userid))
logger.debug(f"search user {userid} in database")
user = userController.login_user(userid, password)
if not user:
raise Unauthorized
session = sessionController.create(user, user_agent=request.user_agent)
logger.debug("token is {{ {} }}".format(session.token))
logger.info("User {{ {} }} success login.".format(userid))
logger.debug(f"token is {session.token}")
logger.info(f"User {userid} logged in.")
# Lets cleanup the DB
sessionController.clear_expired()

View File

@ -1,18 +1,19 @@
"""LDAP Authentication Provider Plugin"""
import io
import os
import ssl
from typing import Optional
from PIL import Image
from io import BytesIO
from flask_ldapconn import LDAPConn
from flask import current_app as app
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from werkzeug.datastructures import FileStorage
from flaschengeist import logger
from flaschengeist.plugins import AuthPlugin, before_role_updated
from flaschengeist.controller import userController
from flaschengeist.models.user import User, Role, _Avatar
import flaschengeist.controller.userController as userController
from flaschengeist.plugins import AuthPlugin, before_role_updated
class AuthLDAP(AuthPlugin):
@ -164,31 +165,23 @@ class AuthLDAP(AuthPlugin):
else:
raise NotFound
def set_avatar(self, user, avatar: _Avatar):
def set_avatar(self, user: User, file: FileStorage):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
raise InternalServerError
if avatar.mimetype != "image/jpeg":
# Try converting using Pillow (if installed)
try:
from PIL import Image
image = Image.open(io.BytesIO(avatar.binary))
image_bytes = io.BytesIO()
image.save(image_bytes, format="JPEG")
avatar.binary = image_bytes.getvalue()
avatar.mimetype = "image/jpeg"
except ImportError:
logger.debug("Pillow not installed for image conversion")
raise BadRequest("Unsupported image format")
except IOError:
logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG")
raise BadRequest("Unsupported image format")
image_bytes = BytesIO()
try:
# Make sure converted to RGB, e.g. png support RGBA but jpeg does not
image = Image.open(file).convert("RGB")
image.save(image_bytes, format="JPEG")
except IOError:
logger.debug(f"Could not convert avatar from '{file.mimetype}' to JPEG")
raise BadRequest("Unsupported image format")
dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [image_bytes.getvalue()])]})
def delete_avatar(self, user):
if self.root_dn is None:
@ -225,7 +218,7 @@ class AuthLDAP(AuthPlugin):
def __modify_role(
self,
role: Role,
new_name: Optional[str],
new_name,
):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")

View File

@ -56,17 +56,6 @@ class AuthPlain(AuthPlugin):
def delete_user(self, user):
pass
def get_avatar(self, user):
if not user.has_attribute("avatar"):
raise NotFound
return user.get_attribute("avatar")
def set_avatar(self, user, avatar):
user.set_attribute("avatar", avatar)
def delete_avatar(self, user):
user.delete_attribute("avatar")
@staticmethod
def _hash_password(password):
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii")

View File

@ -2,8 +2,9 @@
Provides routes used to manage users
"""
from io import BytesIO
from http.client import NO_CONTENT, CREATED
from flask import Blueprint, request, jsonify, make_response, Response
from flask import Blueprint, request, jsonify, make_response, Response, send_file
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound
from . import permissions
@ -12,7 +13,7 @@ from flaschengeist.config import config
from flaschengeist.plugins import Plugin
from flaschengeist.models.user import User, _Avatar
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.controller import userController, imageController as image_controller
from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format
@ -118,12 +119,7 @@ def frontend(userid, current_session):
@headers({"Cache-Control": "public, max-age=604800"})
def get_avatar(userid):
user = userController.get_user(userid)
avatar = userController.load_avatar(user)
if len(avatar.binary) > 0:
response = Response(avatar.binary, mimetype=avatar.mimetype)
response.add_etag()
return response.make_conditional(request)
raise NotFound
return userController.load_avatar(user)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@ -135,10 +131,7 @@ def set_avatar(userid, current_session):
file = request.files.get("file")
if file:
avatar = _Avatar()
avatar.mimetype = file.content_type
avatar.binary = bytearray(file.stream.read())
userController.save_avatar(user, avatar)
userController.save_avatar(user, file)
return created()
else:
raise BadRequest
@ -238,7 +231,7 @@ def notifications(current_session):
@UsersPlugin.blueprint.route("/notifications/<nid>", methods=["DELETE"])
@login_required()
def remove_notifications(nid, current_session):
def remove_notification(nid, current_session):
userController.delete_notification(nid, current_session.user_)
return no_content()