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 import secrets
from io import BytesIO
from flask import current_app from flask import current_app
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger from flaschengeist import logger
@ -10,7 +12,7 @@ from flaschengeist.models.notification import Notification
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.models.user import User, Role, _PasswordReset 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): def _generate_password_reset(user):
@ -79,7 +81,7 @@ def update_user(user):
db.session.commit() 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() user.roles_.clear()
for role_name in roles: for role_name in roles:
role = Role.query.filter(Role.name == role_name).one_or_none() role = Role.query.filter(Role.name == role_name).one_or_none()
@ -201,11 +203,17 @@ def register(data):
def load_avatar(user: User): 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): def save_avatar(user, file):
current_app.config["FG_AUTH_BACKEND"].set_avatar(user, avatar) current_app.config["FG_AUTH_BACKEND"].set_avatar(user, file)
db.session.commit() db.session.commit()

View File

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

View File

@ -1,10 +1,12 @@
import sqlalchemy import sqlalchemy
import pkg_resources import pkg_resources
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.controller import imageController
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.notification import Notification 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.models.setting import _PluginSetting
from flaschengeist.utils.hook import HookBefore, HookAfter from flaschengeist.utils.hook import HookBefore, HookAfter
@ -171,9 +173,13 @@ class AuthPlugin(Plugin):
""" """
raise MethodNotAllowed 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) """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: Args:
user: User to retrieve the avatar for user: User to retrieve the avatar for
Raises: Raises:
@ -181,24 +187,29 @@ class AuthPlugin(Plugin):
""" """
raise NotFound 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) """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: Args:
user: User to set the avatar for user: User to set the avatar for
avatar: Avatar to set file: FileStorage object uploaded by the user
Raises: Raises:
MethodNotAllowed: If not supported by Backend 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) """Delete the avatar for given user (if supported by auth backend)
Default behavior is to use the imageController and native Image objects.
Args: Args:
user: Uset to delete the avatar for user: Uset to delete the avatar for
Raises: Raises:
MethodNotAllowed: If not supported by Backend MethodNotAllowed: If not supported by Backend
""" """
raise MethodNotAllowed user.avatar_ = None

View File

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

View File

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

View File

@ -56,17 +56,6 @@ class AuthPlain(AuthPlugin):
def delete_user(self, user): def delete_user(self, user):
pass 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 @staticmethod
def _hash_password(password): def _hash_password(password):
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii") salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii")

View File

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