diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index fff534c..4374db8 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -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() diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 2f3ac67..87fdb91 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -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_] diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 1c42a71..089c5d5 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -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 diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 3874f79..9c08463 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -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() diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 29659bf..697e972 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -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!") diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index a9f24a5..4db87d8 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -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") diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index d0f929e..75f2f56 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -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//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/", 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()