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 24 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: 0 additions & 25 deletions mslib/mscolab/chat_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@
limitations under the License.
"""
import datetime
import os
import time

import fs
from werkzeug.utils import secure_filename

from mslib.mscolab.conf import mscolab_settings
from mslib.mscolab.models import db, Message, MessageType
Expand Down Expand Up @@ -96,24 +92,3 @@ def delete_message(self, message_id):
upload_dir.remove(fs.path.join(str(message.op_id), file_name))
db.session.delete(message)
db.session.commit()

def add_attachment(self, op_id, upload_folder, file, file_token):
with fs.open_fs('/') as home_fs:
file_dir = fs.path.join(upload_folder, str(op_id))
if '\\' not in file_dir:
if not home_fs.exists(file_dir):
home_fs.makedirs(file_dir)
else:
file_dir = file_dir.replace('\\', '/')
if not os.path.exists(file_dir):
os.makedirs(file_dir)
file_name, file_ext = file.filename.rsplit('.', 1)
file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}'
file_name = secure_filename(file_name)
file_path = fs.path.join(file_dir, file_name)
file.save(file_path)
static_dir = fs.path.basename(upload_folder)
static_dir = static_dir.replace('\\', '/')
static_file_path = os.path.join(static_dir, str(op_id), file_name)
if os.path.exists(file_path):
return static_file_path
2 changes: 1 addition & 1 deletion mslib/mscolab/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class default_mscolab_settings:

# mscolab file upload settings
UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads')
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MiB

workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
# used to generate and parse tokens
SECRET_KEY = secrets.token_urlsafe(16)
Expand Down
73 changes: 73 additions & 0 deletions mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import secrets
import time
import datetime
import fs
import difflib
import logging
import git
import threading
import mimetypes
from werkzeug.utils import secure_filename
from sqlalchemy.exc import IntegrityError
from mslib.mscolab.models import db, Operation, Permission, User, Change, Message
from mslib.mscolab.conf import mscolab_settings
Expand Down Expand Up @@ -225,6 +230,9 @@ def modify_user(self, user, attribute=None, value=None, action=None):
elif action == "delete":
user_query = User.query.filter_by(id=user.id).first()
if user_query is not None:
# Delete profile image if it exists
if user.profile_image_path:
self.delete_user_profile_images(user.profile_image_path)
db.session.delete(user)
db.session.commit()
user_query = User.query.filter_by(id=user.id).first()
Expand All @@ -250,6 +258,71 @@ def modify_user(self, user, attribute=None, value=None, action=None):
db.session.commit()
return True

def delete_user_profile_images(self, image_to_be_deleted):
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
'''
This function is called when deleting account or updating the profile picture
'''
upload_folder = mscolab_settings.UPLOAD_FOLDER
with fs.open_fs(upload_folder) as profile_fs:
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
if profile_fs.exists(image_to_be_deleted):
profile_fs.remove(image_to_be_deleted)
logging.debug(f"Successfully deleted image: {image_to_be_deleted}")

def upload_file(self, file, subfolder=None, identifier=None, include_prefix=False):
"""
Generic function to save files securely in any specified directory with unique filename
and return the relative file path.
"""
upload_folder = mscolab_settings.UPLOAD_FOLDER
if sys.platform.startswith('win'):
upload_folder = upload_folder.replace('\\', '/')

subfolder_path = fs.path.join(upload_folder, str(subfolder) if subfolder else "")
with fs.open_fs(subfolder_path, create=True) as _fs:
# Creating unique and secure filename
file_name, _ = file.filename.rsplit('.', 1)
mime_type, _ = mimetypes.guess_type(file.filename)
file_ext = mimetypes.guess_extension(mime_type) if mime_type else '.unknown'
token = secrets.token_urlsafe()
timestamp = time.strftime("%Y%m%dT%H%M%S")

if identifier:
file_name = f'{identifier}-{timestamp}-{token}{file_ext}'
else:
file_name = f'{file_name}-{timestamp}-{token}{file_ext}'
file_name = secure_filename(file_name)

# Saving the file
with _fs.open(file_name, mode="wb") as f:
file.save(f)

# Relative File path
if include_prefix:
static_dir = fs.path.basename(upload_folder)
static_file_path = fs.path.join(static_dir, str(subfolder), file_name)
else:
static_file_path = fs.path.relativefrom(upload_folder, fs.path.join(subfolder_path, file_name))

logging.debug(f'Relative Path: {static_file_path}')
return static_file_path

def save_user_profile_image(self, user_id, image_file):
"""
Save the user's profile image path to the database.
"""
relative_file_path = self.upload_file(image_file, subfolder='profile', identifier=user_id)
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved

user = User.query.get(user_id)
if user:
if user.profile_image_path:
# Delete the previous image
self.delete_user_profile_images(user.profile_image_path)
user.profile_image_path = relative_file_path
db.session.commit()
return True, "Image uploaded successfully"
else:
return False, "User not found"

def update_operation(self, op_id, attribute, value, user):
"""
op_id: operation id
Expand Down
5 changes: 4 additions & 1 deletion mslib/mscolab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,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_path = db.Column(db.String(255), nullable=True) # relative path
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_path=None, confirmed=False,
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
confirmed_on=None, authentication_backend='local'):
self.username = username
self.emailid = emailid
self.hash_password(password)
self.profile_image_path = profile_image_path
self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc)
self.confirmed = confirmed
self.confirmed_on = confirmed_on
Expand Down
35 changes: 33 additions & 2 deletions mslib/mscolab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,38 @@ def get_user():
return json.dumps({'user': {'id': g.user.id, 'username': g.user.username}})


@APP.route('/upload_profile_image', methods=["POST"])
@verify_user
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 not file:
return jsonify({'message': 'No file provided or invalid file type'}), 400
if not file.mimetype.startswith('image/'):
return jsonify({'message': 'Invalid file type'}), 400
if file.content_length > mscolab_settings.MAX_UPLOAD_SIZE:
return jsonify({'message': 'File too large'}), 413

success, message = fm.save_user_profile_image(user_id, file)
if success:
return jsonify({'message': message}), 200
else:
return jsonify({'message': message}), 400


@APP.route('/fetch_profile_image', methods=["GET"])
@verify_user
def fetch_profile_image():
user_id = request.form['user_id']
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
user = User.query.get(user_id)
if user and user.profile_image_path:
base_path = mscolab_settings.UPLOAD_FOLDER
filename = user.profile_image_path
return send_from_directory(base_path, filename) # todo : Supply os path semantics in this and def uploads()
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
else:
abort(404)


@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 Expand Up @@ -381,15 +413,14 @@ def message_attachment():
user = g.user
op_id = request.form.get("op_id", None)
if fm.is_member(user.id, op_id):
file_token = secrets.token_urlsafe(16)
file = request.files['file']
message_type = MessageType(int(request.form.get("message_type")))
user = g.user
users = fm.fetch_users_without_permission(int(op_id), user.id)
if users is False:
return jsonify({"success": False, "message": "Could not send message. No file uploaded."})
if file is not None:
static_file_path = cm.add_attachment(op_id, APP.config['UPLOAD_FOLDER'], file, file_token)
static_file_path = fm.upload_file(file, subfolder=str(op_id), include_prefix=True)
if static_file_path is not None:
new_message = cm.add_message(user, static_file_path, op_id, message_type)
new_message_dict = get_message_dict(new_message)
Expand Down
90 changes: 85 additions & 5 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 @@ -39,11 +40,12 @@
import requests
import re
import webbrowser
import mimetypes
import urllib.request
from urllib.parse import urljoin

from fs import open_fs
from PIL import Image
from PIL import Image, UnidentifiedImageError
from keyring.errors import NoKeyringError, PasswordSetError, InitError

from mslib.msui import flighttrack as ft
Expand All @@ -54,6 +56,8 @@

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 @@ -675,7 +679,7 @@ def after_login(self, emailid, url, r):
self.ui.usernameLabel.setText(f"{self.user['username']}")
self.ui.usernameLabel.show()
self.ui.userOptionsTb.show()
self.fetch_gravatar()
self.fetch_profile_image()
# enable add operation menu action
self.ui.actionAddOperation.setEnabled(True)

Expand All @@ -693,7 +697,35 @@ def after_login(self, emailid, url, r):

self.signal_login_mscolab.emit(self.mscolab_server_url, self.token)

def fetch_gravatar(self, refresh=False):
def set_profile_pixmap(self, img_data):
pixmap = QPixmap()
pixmap.loadFromData(img_data)
resized_pixmap = pixmap.scaled(64, 64)

if (hasattr(self, 'profile_dialog') and self.profile_dialog is not None and
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
hasattr(self.profile_dialog, 'gravatarLabel') and self.profile_dialog.gravatarLabel is not None):
self.profile_dialog.gravatarLabel.setPixmap(resized_pixmap)

if hasattr(self, 'ui'):
icon = QtGui.QIcon()
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
icon.addPixmap(resized_pixmap, QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.ui.userOptionsTb.setIcon(icon)

def fetch_profile_image(self, refresh=False):
# Display custom profile picture if exists
url = urljoin(self.mscolab_server_url, 'fetch_profile_image')
data = {
"user_id": str(self.user["id"]),
"token": self.token
}
response = requests.get(url, data=data)
if response.status_code == 200:
self.set_profile_pixmap(response.content)
else:
self.fetch_gravatar(refresh)

def fetch_gravatar(self, refresh):
# Display default gravatar if custom profile image 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,16 +830,60 @@ 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()
self.gravatar_menu.addAction('Fetch Gravatar', lambda: self.fetch_gravatar(refresh=True))
self.gravatar_menu.addAction('Fetch Gravatar', lambda: self.fetch_profile_image(refresh=True))
self.gravatar_menu.addAction('Remove Gravatar', lambda: self.remove_gravatar())
self.profile_dialog.gravatarLabel.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.profile_dialog.gravatarLabel.customContextMenuRequested.connect(on_context_menu)

self.prof_diag.show()
self.fetch_gravatar()
self.fetch_profile_image()

def upload_image(self):
file_name, _ = QFileDialog.getOpenFileName(self.prof_diag, "Open Image", "", "Image Files (*.png *.jpg *.jpeg)")
if file_name:
workaryangupta marked this conversation as resolved.
Show resolved Hide resolved
# Determine the image format
mime_type, _ = mimetypes.guess_type(file_name)
file_format = mime_type.split('/')[1].upper()
try:
# Resize the image and set profile image pixmap
image = Image.open(file_name)
image = image.resize((64, 64), Image.ANTIALIAS)
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format=file_format)
img_byte_arr.seek(0)
self.set_profile_pixmap(img_byte_arr.getvalue())

# Prepare the file data for upload
try:
img_byte_arr.seek(0) # Reset buffer position
files = {'image': (os.path.basename(file_name), img_byte_arr, mime_type)}
data = {
"user_id": str(self.user["id"]),
"token": self.token
}
url = urljoin(self.mscolab_server_url, 'upload_profile_image')
response = requests.post(url, files=files, data=data)

# Check response status
if response.status_code == 200:
QMessageBox.information(self.prof_diag, "Success", "Image uploaded successfully")
self.fetch_profile_image(refresh=True)
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}")

except UnidentifiedImageError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error : {e}')
except OSError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error: {e}')

def delete_account(self):
# ToDo rename to delete_own_account
Expand Down Expand Up @@ -2084,6 +2160,10 @@ def logout(self):

self.operation_archive_browser.hide()

if hasattr(self, 'profile_dialog'):
del self.profile_dialog
self.profile_dialog = None

# activate first local flighttrack after logging out
self.ui.listFlightTracks.setCurrentRow(0)
self.ui.activate_selected_flight_track()
Expand Down
Loading