feat(plugins) Plugins use native Image objects as default avatar, but can still implement their own stuff.
This commit is contained in:
		
							parent
							
								
									06caec86e7
								
							
						
					
					
						commit
						0ce52de8cd
					
				|  | @ -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() | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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_] | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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!") | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
|  | @ -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() | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue