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
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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_]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
from PIL import Image
|
# Make sure converted to RGB, e.g. png support RGBA but jpeg does not
|
||||||
|
image = Image.open(file).convert("RGB")
|
||||||
image = Image.open(io.BytesIO(avatar.binary))
|
|
||||||
image_bytes = io.BytesIO()
|
|
||||||
image.save(image_bytes, format="JPEG")
|
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:
|
except IOError:
|
||||||
logger.debug(f"Could not convert avatar from '{avatar.mimetype}' to JPEG")
|
logger.debug(f"Could not convert avatar from '{file.mimetype}' to JPEG")
|
||||||
raise BadRequest("Unsupported image format")
|
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!")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue