Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow users to set custom profile pictures #2405

Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
aa9a93a
Implemented feature: Allow users to set custom profile pictures
workaryangupta Jun 14, 2024
b7ceb1e
optional profile image
workaryangupta Jun 17, 2024
0742c39
flake8
workaryangupta Jun 17, 2024
f2cb29a
enhanced image upload security and validation
workaryangupta Jun 18, 2024
0fbad83
storing image path in DB instead of image itself
workaryangupta Jun 21, 2024
51f0ccb
set limit for profile image uploads
workaryangupta Jun 21, 2024
254e47e
added new config variable to mscolab init
workaryangupta Jun 21, 2024
e805e4c
Updated logic to store relative paths for user profile image instead …
workaryangupta Jul 1, 2024
89af2d0
Refactored upload_profile_image to use early returns
workaryangupta Jul 1, 2024
cefba82
Used create_files to ensure needed paths exist
workaryangupta Jul 1, 2024
9c15bf8
refactored directory separator slashes logic
workaryangupta Jul 2, 2024
9d49c6b
flake8
workaryangupta Jul 2, 2024
f4321d4
fixed failing tests and reduced def upload_file signature
workaryangupta Jul 3, 2024
ab514c4
flake8
workaryangupta Jul 3, 2024
0b68218
removed accidentally pushed testing logs
workaryangupta Jul 3, 2024
69a9ecc
fixed more failing tests since ext is no longer a part of uploaded fi…
workaryangupta Jul 3, 2024
57bbd5a
using fs instead of os while making dir in def upload_file
workaryangupta Jul 4, 2024
b56ab25
mscolab.py changes
workaryangupta Jul 5, 2024
e0a038a
refactored functions in server.py and file_manager.py
workaryangupta Jul 5, 2024
d27b876
removed PROFILE_IMG_FOLDER config var
workaryangupta Jul 5, 2024
3141566
removed pytest.log
workaryangupta Jul 5, 2024
ce0a390
delete old user pfp when new is uploaded
workaryangupta Jul 6, 2024
a646774
removed pytest log
workaryangupta Jul 6, 2024
67a28da
fixed error
workaryangupta Jul 6, 2024
0bfe0ec
removed truthy check for user options icon and added some ToDo
workaryangupta Jul 7, 2024
8ca40f5
spelling mistake
workaryangupta Jul 7, 2024
e532435
pytest log
workaryangupta Jul 7, 2024
3b3744c
added a ToDo for adding namespace for chat attachments
workaryangupta Jul 7, 2024
4e49277
added my name to authors
workaryangupta Jul 7, 2024
07b06b6
refactored fetch_profile_image route
workaryangupta Jul 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,31 @@ def modify_user(self, user, attribute=None, value=None, action=None):
db.session.commit()
return True

def save_user_profile_image(self, user_id, image_data):
"""
Save the user's profile image to the database.
"""
user = User.query.get(user_id)
if user:
try:
user.profile_image = image_data
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
db.session.commit()
return True, "Image uploaded successfully"
except Exception as e:
db.session.rollback()
return False, f"An error occurred: {str(e)}"
else:
return False, "User not found"

def fetch_user_profile_image(self, user_id):
"""
Fetch the profile image for a user from the database.
"""
user = User.query.get(user_id)
if user and user.profile_image:
return user.profile_image
return None

def update_operation(self, op_id, attribute, value, user):
"""
op_id: operation id
Expand Down
6 changes: 5 additions & 1 deletion mslib/mscolab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from passlib.apps import custom_app_context as pwd_context
import sqlalchemy.types
from sqlalchemy import LargeBinary

from mslib.mscolab.app import db
from mslib.mscolab.message_type import MessageType
Expand Down Expand Up @@ -58,16 +59,19 @@ class User(db.Model):
username = db.Column(db.String(255))
emailid = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
profile_image = db.Column(LargeBinary, nullable=True)
registered_on = db.Column(AwareDateTime, nullable=False)
confirmed = db.Column(db.Boolean, nullable=False, default=False)
confirmed_on = db.Column(AwareDateTime, nullable=True)
permissions = db.relationship('Permission', cascade='all,delete,delete-orphan', backref='user')
authentication_backend = db.Column(db.String(255), nullable=False, default='local')

def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None, authentication_backend='local'):
def __init__(self, emailid, username, password, profile_image=None, confirmed=False,
confirmed_on=None, authentication_backend='local'):
self.username = username
self.emailid = emailid
self.hash_password(password)
self.profile_image = profile_image
self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc)
self.confirmed = confirmed
self.confirmed_on = confirmed_on
Expand Down
14 changes: 14 additions & 0 deletions mslib/mscolab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,20 @@ def get_user():
return json.dumps({'user': {'id': g.user.id, 'username': g.user.username}})


@APP.route('/upload_profile_image', methods=["POST"])
def upload_profile_image():
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
user_id = request.form['user_id']
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
file = request.files['image']
if file:
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
success, message = fm.save_user_profile_image(user_id, file.read())
if success:
return jsonify({'message': message}), 200
else:
return jsonify({'message': message}), 400
else:
return jsonify({'message': 'No file provided'}), 400


@APP.route("/delete_own_account", methods=["POST"])
@verify_user
def delete_own_account():
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
56 changes: 56 additions & 0 deletions mslib/msui/mscolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
limitations under the License.
"""
import os
import io
import sys
import json
import hashlib
Expand All @@ -51,9 +52,12 @@
from mslib.msui import mscolab_admin_window as maw
from mslib.msui import mscolab_version_history as mvh
from mslib.msui import socket_control as sc
from mslib.mscolab.file_manager import FileManager
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved

import PyQt5
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import QFileDialog, QMessageBox
from PyQt5.QtGui import QPixmap

from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring
from mslib.utils.verify_user_token import verify_user_token
Expand Down Expand Up @@ -694,6 +698,26 @@ def after_login(self, emailid, url, r):
self.signal_login_mscolab.emit(self.mscolab_server_url, self.token)

def fetch_gravatar(self, refresh=False):
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
# Display custom profile picture if exists
from mslib.mscolab.server import APP
with APP.app_context():
fm = FileManager(APP.config["MSCOLAB_DATA_DIR"])
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
img_data = fm.fetch_user_profile_image(self.user["id"])
if img_data:
img_byte_arr = io.BytesIO(img_data)
pixmap = QPixmap()
pixmap.loadFromData(img_byte_arr.getvalue())

if hasattr(self, 'profile_dialog'):
self.profile_dialog.gravatarLabel.setPixmap(pixmap)

# set icon for user options toolbutton
icon = QtGui.QIcon()
icon.addPixmap(pixmap, QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.ui.userOptionsTb.setIcon(icon)
return

# Display default gravatar if custom pfp is not set
email_hash = hashlib.md5(bytes(self.email.encode('utf-8')).lower()).hexdigest()
email_in_config = self.email in config_loader(dataset="gravatar_ids")
gravatar_img_path = fs.path.join(constants.GRAVATAR_DIR_PATH, f"{email_hash}.png")
Expand Down Expand Up @@ -798,6 +822,7 @@ def on_context_menu(point):
self.profile_dialog.mscolabURLLabel_2.setText(self.mscolab_server_url)
self.profile_dialog.emailLabel_2.setText(self.email)
self.profile_dialog.deleteAccountBtn.clicked.connect(self.delete_account)
self.profile_dialog.uploadImageBtn.clicked.connect(self.upload_image)

# add context menu for right click on image
self.gravatar_menu = QtWidgets.QMenu()
Expand All @@ -809,6 +834,37 @@ def on_context_menu(point):
self.prof_diag.show()
self.fetch_gravatar()

def upload_image(self):
file_name, _ = QFileDialog.getOpenFileName(self.prof_diag, "Open Image", "", "Image Files (*.png *.jpg *.bmp)")
if file_name:
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
# Load, resize and display the image
image = Image.open(file_name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs an try: except PIL.UnidentifiedImageError: because one can have wrong extensions used, e.g. renamed a mp4 to a jpg

currently this gives a traceback:

Fatal error in MSS 9.0.0 on Linux-6.5.0-10043-tuxedo-x86_64-with-glibc2.35
Python 3.11.6 | packaged by conda-forge | (main, Oct 3 2023, 10:40:35) [GCC 12.3.0]

Please report bugs in MSS to https://github.com/Open-MSS/MSS


Information about the fatal error:

Traceback (most recent call last):
  File "/home/user/PycharmProjects/2024/workaryangupta/MSS/mslib/msui/mscolab.py", line 850, in upload_image
    image = Image.open(file_name)
            ^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/Miniforge/envs/mssdev/lib/python3.11/site-packages/PIL/Image.py", line 3283, in open
    raise UnidentifiedImageError(msg)
PIL.UnidentifiedImageError: cannot identify image file '/home/user/tmp/1.jpg'

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renaming a gif to a png and uploading works, so gif can maybe added to the list of extensions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renaming a gif to a jpg and uploading gives

Please report bugs in MSS to https://github.com/Open-MSS/MSS

Information about the fatal error:

Traceback (most recent call last):
  File "/home/user/Miniforge/envs/mssdev/lib/python3.11/site-packages/PIL/JpegImagePlugin.py", line 643, in _save
    rawmode = RAWMODE[im.mode]
              ~~~~~~~^^^^^^^^^
KeyError: 'P'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/user/PycharmProjects/2024/workaryangupta/MSS/mslib/msui/mscolab.py", line 853, in upload_image
    image.save(img_byte_arr, format=file_format)
  File "/home/user/Miniforge/envs/mssdev/lib/python3.11/site-packages/PIL/Image.py", line 2431, in save
    save_handler(self, fp, filename)
  File "/home/user/Miniforge/envs/mssdev/lib/python3.11/site-packages/PIL/JpegImagePlugin.py", line 646, in _save
    raise OSError(msg) from e
OSError: cannot write mode P as JPEG

So also catch OSError

Becasue this is not server code it is not critical, we help here the user and maybe get no issues when someone has files like this.

image = image.resize((64, 64), Image.ANTIALIAS)
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)

# Display image on QLabel
pixmap = QPixmap()
pixmap.loadFromData(img_byte_arr.getvalue())
self.profile_dialog.gravatarLabel.setPixmap(pixmap)

# Prepare data for upload
img_byte_arr.seek(0)
files = {'image': ('profile_image.png', img_byte_arr, 'image/png')}
data = {'user_id': str(self.user["id"])}

# Sending the request
try:
url = urljoin(self.mscolab_server_url, 'upload_profile_image')
response = requests.post(url, files=files, data=data)
if response.status_code == 200:
QMessageBox.information(self.prof_diag, "Success", "Image uploaded successfully")
else:
QMessageBox.critical(self.prof_diag, "Error", f"Failed to upload image: {response.text}")
except requests.exceptions.RequestException as e:
QMessageBox.critical(self.prof_diag, "Error", f"Error occurred: {e}")

def delete_account(self):
# ToDo rename to delete_own_account
if verify_user_token(self.mscolab_server_url, self.token):
Expand Down
74 changes: 45 additions & 29 deletions mslib/msui/qt5/ui_mscolab_profile_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_profile_dialog.ui'
#
# Created by: PyQt5 UI code generator 5.12.3
# Created by: PyQt5 UI code generator 5.15.9
#
# WARNING! All changes made in this file will be lost!

Expand All @@ -13,42 +13,42 @@
class Ui_ProfileWindow(object):
def setupUi(self, ProfileWindow):
ProfileWindow.setObjectName("ProfileWindow")
ProfileWindow.resize(242, 146)
ProfileWindow.resize(387, 149)
self.gridLayout = QtWidgets.QGridLayout(ProfileWindow)
self.gridLayout.setObjectName("gridLayout")
self.infoGl = QtWidgets.QGridLayout()
self.infoGl.setObjectName("infoGl")
self.usernameLabel_2 = QtWidgets.QLabel(ProfileWindow)
self.usernameLabel_2.setText("")
self.usernameLabel_2.setObjectName("usernameLabel_2")
self.infoGl.addWidget(self.usernameLabel_2, 0, 2, 1, 1)
self.emailLabel_2 = QtWidgets.QLabel(ProfileWindow)
self.emailLabel_2.setText("")
self.emailLabel_2.setObjectName("emailLabel_2")
self.infoGl.addWidget(self.emailLabel_2, 1, 2, 1, 1)
self.mscolabURLLabel = QtWidgets.QLabel(ProfileWindow)
self.mscolabURLLabel.setObjectName("mscolabURLLabel")
self.infoGl.addWidget(self.mscolabURLLabel, 2, 0, 1, 1)
self.mscolabURLLabel_2 = QtWidgets.QLabel(ProfileWindow)
self.mscolabURLLabel_2.setText("")
self.mscolabURLLabel_2.setObjectName("mscolabURLLabel_2")
self.infoGl.addWidget(self.mscolabURLLabel_2, 2, 2, 1, 1)
self.label = QtWidgets.QLabel(ProfileWindow)
self.label.setObjectName("label")
self.infoGl.addWidget(self.label, 0, 1, 1, 1, QtCore.Qt.AlignLeft)
self.emailLabel = QtWidgets.QLabel(ProfileWindow)
self.emailLabel.setObjectName("emailLabel")
self.infoGl.addWidget(self.emailLabel, 1, 0, 1, 1)
self.usernameLabel = QtWidgets.QLabel(ProfileWindow)
self.usernameLabel.setObjectName("usernameLabel")
self.infoGl.addWidget(self.usernameLabel, 0, 0, 1, 1)
self.label = QtWidgets.QLabel(ProfileWindow)
self.label.setObjectName("label")
self.infoGl.addWidget(self.label, 0, 1, 1, 1, QtCore.Qt.AlignLeft)
self.usernameLabel_2 = QtWidgets.QLabel(ProfileWindow)
self.usernameLabel_2.setText("")
self.usernameLabel_2.setObjectName("usernameLabel_2")
self.infoGl.addWidget(self.usernameLabel_2, 0, 2, 1, 1)
self.label_3 = QtWidgets.QLabel(ProfileWindow)
self.label_3.setObjectName("label_3")
self.infoGl.addWidget(self.label_3, 2, 1, 1, 1)
self.mscolabURLLabel_2 = QtWidgets.QLabel(ProfileWindow)
self.mscolabURLLabel_2.setText("")
self.mscolabURLLabel_2.setObjectName("mscolabURLLabel_2")
self.infoGl.addWidget(self.mscolabURLLabel_2, 2, 2, 1, 1)
self.label_2 = QtWidgets.QLabel(ProfileWindow)
self.label_2.setObjectName("label_2")
self.infoGl.addWidget(self.label_2, 1, 1, 1, 1, QtCore.Qt.AlignLeft)
self.label_3 = QtWidgets.QLabel(ProfileWindow)
self.label_3.setObjectName("label_3")
self.infoGl.addWidget(self.label_3, 2, 1, 1, 1, QtCore.Qt.AlignLeft)
self.gridLayout.addLayout(self.infoGl, 0, 0, 1, 1)
self.mscolabURLLabel = QtWidgets.QLabel(ProfileWindow)
self.mscolabURLLabel.setObjectName("mscolabURLLabel")
self.infoGl.addWidget(self.mscolabURLLabel, 2, 0, 1, 1)
self.gridLayout.addLayout(self.infoGl, 0, 0, 1, 2)
self.gravatarVl = QtWidgets.QVBoxLayout()
self.gravatarVl.setObjectName("gravatarVl")
self.gravatarLabel = QtWidgets.QLabel(ProfileWindow)
Expand All @@ -57,28 +57,44 @@ def setupUi(self, ProfileWindow):
self.gravatarLabel.setScaledContents(True)
self.gravatarLabel.setObjectName("gravatarLabel")
self.gravatarVl.addWidget(self.gravatarLabel, 0, QtCore.Qt.AlignHCenter|QtCore.Qt.AlignVCenter)
self.gridLayout.addLayout(self.gravatarVl, 0, 1, 2, 1)
self.buttonBox = QtWidgets.QDialogButtonBox(ProfileWindow)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Ok)
self.buttonBox.setObjectName("buttonBox")
self.gridLayout.addWidget(self.buttonBox, 2, 1, 1, 1, QtCore.Qt.AlignRight)
self.gridLayout.addLayout(self.gravatarVl, 0, 2, 1, 1)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.deleteAccountBtn = QtWidgets.QPushButton(ProfileWindow)
self.deleteAccountBtn.setIconSize(QtCore.QSize(40, 20))
self.deleteAccountBtn.setAutoDefault(False)
self.deleteAccountBtn.setObjectName("deleteAccountBtn")
self.gridLayout.addWidget(self.deleteAccountBtn, 2, 0, 1, 1, QtCore.Qt.AlignLeft)
self.horizontalLayout.addWidget(self.deleteAccountBtn)
self.uploadImageBtn = QtWidgets.QPushButton(ProfileWindow)
self.uploadImageBtn.setObjectName("uploadImageBtn")
self.horizontalLayout.addWidget(self.uploadImageBtn)
self.buttonBox = QtWidgets.QDialogButtonBox(ProfileWindow)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.buttonBox.sizePolicy().hasHeightForWidth())
self.buttonBox.setSizePolicy(sizePolicy)
self.buttonBox.setMouseTracking(False)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Ok)
self.buttonBox.setCenterButtons(False)
self.buttonBox.setObjectName("buttonBox")
self.horizontalLayout.addWidget(self.buttonBox, 0, QtCore.Qt.AlignHCenter)
self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 3)

self.retranslateUi(ProfileWindow)
QtCore.QMetaObject.connectSlotsByName(ProfileWindow)

def retranslateUi(self, ProfileWindow):
_translate = QtCore.QCoreApplication.translate
ProfileWindow.setWindowTitle(_translate("ProfileWindow", "MSColab Profile"))
self.mscolabURLLabel.setText(_translate("ProfileWindow", "Mscolab"))
self.label.setText(_translate("ProfileWindow", ":"))
self.emailLabel.setText(_translate("ProfileWindow", "Email"))
self.usernameLabel.setText(_translate("ProfileWindow", "Name"))
self.label.setText(_translate("ProfileWindow", ":"))
self.label_2.setText(_translate("ProfileWindow", ":"))
self.label_3.setText(_translate("ProfileWindow", ":"))
self.label_2.setText(_translate("ProfileWindow", ":"))
self.mscolabURLLabel.setText(_translate("ProfileWindow", "Mscolab"))
self.deleteAccountBtn.setText(_translate("ProfileWindow", "Delete Account"))
self.uploadImageBtn.setText(_translate("ProfileWindow", "Change Avatar"))


from . import resources_rc
Loading
Loading