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)
|
||||
image_bytes = BytesIO()
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
image = Image.open(io.BytesIO(avatar.binary))
|
||||
image_bytes = io.BytesIO()
|
||||
# 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")
|
||||
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")
|
||||
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