From 09a654213e21bd2dc2b9ad92cb1569c25e2e356e Mon Sep 17 00:00:00 2001 From: Jennifer Lindgren <jennifer.lindgren93@gmail.com> Date: Mon, 27 May 2019 09:57:33 +0200 Subject: [PATCH] Backend: Create and update project files locally when someone is viewing a project to allow for faster syntax validation. --- backend/19/Folder2/nested.py | 1 + backend/19/app.py | 14 + backend/19/database_helper.py | 441 +++++++++++++++++++++++++ backend/19/napp.py | 0 backend/2/Folder/app.py | 1 + backend/2/Folder/heh.py | 0 backend/2/Folder/hello.py | 0 backend/2/app.py | 592 ++++++++++++++++++++++++++++++++++ backend/2/database_helper.py | 439 +++++++++++++++++++++++++ backend/2/socketio_helper.py | 301 +++++++++++++++++ backend/2/test.py | 1 + backend/Pipfile | 1 + backend/Pipfile.lock | 33 +- backend/app.py | 86 +++-- backend/db.sqlite | Bin 282624 -> 282624 bytes backend/socketio_helper.py | 39 ++- 16 files changed, 1915 insertions(+), 34 deletions(-) create mode 100644 backend/19/Folder2/nested.py create mode 100644 backend/19/app.py create mode 100644 backend/19/database_helper.py create mode 100644 backend/19/napp.py create mode 100644 backend/2/Folder/app.py create mode 100644 backend/2/Folder/heh.py create mode 100644 backend/2/Folder/hello.py create mode 100644 backend/2/app.py create mode 100644 backend/2/database_helper.py create mode 100644 backend/2/socketio_helper.py create mode 100644 backend/2/test.py diff --git a/backend/19/Folder2/nested.py b/backend/19/Folder2/nested.py new file mode 100644 index 00000000..7ddfc8f8 --- /dev/null +++ b/backend/19/Folder2/nested.py @@ -0,0 +1 @@ +print('hello world!') \ No newline at end of file diff --git a/backend/19/app.py b/backend/19/app.py new file mode 100644 index 00000000..f579e6f8 --- /dev/null +++ b/backend/19/app.py @@ -0,0 +1,14 @@ +""" Doc string """ + +print('Hello world!') + +for i in range(0, 1): + print('Hello world!') +print('Hello!') + +print('Hello!') + +for i in range(0, 1): + test_var = '10' + +TST = 2 diff --git a/backend/19/database_helper.py b/backend/19/database_helper.py new file mode 100644 index 00000000..023d63f9 --- /dev/null +++ b/backend/19/database_helper.py @@ -0,0 +1,441 @@ +import os + +from __init__ import app + +import json +from datetime import datetime +from flask import Flask, request, jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow, fields + +basedir = os.path.abspath(os.path.dirname(__file__)) + +# Database +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'db.sqlite') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +db = SQLAlchemy(app) +ma = Marshmallow(app) + +# EditorSettings +class EditorSettings(db.Model): + id = db.Column(db.Integer, primary_key=True) + fontSize = db.Column(db.Integer, nullable=False) + margin = db.Column(db.Integer, nullable=False) + tabSize = db.Column(db.Integer, nullable=False) + + def __init__(self, font_size, margin, tab_size): + self.fontSize = font_size + self.margin = margin + self.tabSize = tab_size + +class EditorSettingsSchema(ma.Schema): + class Meta: + model = EditorSettings + fields = ('id', 'fontSize', 'margin', 'tabSize') + +editor_settings_schema = EditorSettingsSchema(strict=True) + +# User +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True, nullable=False) + username = db.Column(db.String(50), unique=True, nullable=False) + password = db.Column(db.Text, nullable=False) + editorSettingsId = db.Column(db.Integer, db.ForeignKey(EditorSettings.id)) + editorSettings = db.relationship("EditorSettings") + + def __init__(self, email, username, password): + self.email = email + self.username = username + self.password = password + +class UserSchema(ma.Schema): + editorSettings = ma.Nested(EditorSettingsSchema) + class Meta: + model = User + fields = ('id', 'email', 'username', 'editorSettingsId', 'editorSettings') + +user_schema = UserSchema(strict=True) +users_schema = UserSchema(many=True, strict=True) + +# Project +class Project(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(50), nullable=False) + creatorId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + creator = db.relationship('User') + created = db.Column(db.String(50), default=datetime.now, nullable=False) + edited = db.Column(db.String(50), onupdate=datetime.now) + archived = db.Column(db.Boolean, default=False, nullable=False) + + def __init__(self, title, creator_id, created=None, edited=None, archived=False): + self.title = title + self.creatorId = creator_id + self.created = created + self.edited = edited + self.archived = archived + +class ProjectSchema(ma.Schema): + creator = ma.Nested(UserSchema) + class Meta: + model = Project + fields = ('id', 'title', 'creatorId', 'creator', 'created', 'edited', 'archived') + +project_schema = ProjectSchema(strict=True) +projects_schema = ProjectSchema(many=True, strict=True) + +# File +class File(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project', lazy='subquery') + name = db.Column(db.String(100), nullable=False) + isFolder = db.Column(db.Boolean, nullable=False) + parent = db.Column(db.String(100)) + content = db.Column(db.Text) + + def __init__(self, project_id, name, is_folder, parent, content): + self.projectId = project_id + self.name = name + self.isFolder = is_folder + self.parent = parent + self.content = content + +class FileSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + class Meta: + model = File + fields = ('id', 'projectId', 'project', 'name', 'isFolder', 'parent', 'content') + +file_schema = FileSchema(strict=True) +files_schema = FileSchema(many=True, strict=True) + +# Collaborator +class Collaborator(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project', lazy='subquery') + userId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + user = db.relationship('User', lazy='subquery') + permission = db.Column(db.Text, nullable=False) + + def __init__(self, project_id, user_id, permission): + self.projectId = project_id + self.userId = user_id + self.permission = permission + +class CollaboratorSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + user = ma.Nested(UserSchema) + class Meta: + fields = ('id', 'projectId', 'project', 'userId', 'user', 'permission') + +collaborator_schema = CollaboratorSchema(strict=True) +collaborators_schema = CollaboratorSchema(many=True, strict=True) + +# Message +class Message(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project') + authorId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + author = db.relationship('User') + message = db.Column(db.Text) + time = db.Column(db.String(50), default=datetime.now, nullable=False) + + def __init__(self, project_id, author_id, message, time=None): + self.projectId = project_id + self.authorId = author_id + self.message = message + self.time = time + +class MessageSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + author = ma.Nested(UserSchema) + class Meta: + fields = ('id', 'projectId', 'project', 'authorId', 'author', 'message', 'time') + +message_schema = MessageSchema(strict=True) +messages_schema = MessageSchema(many=True, strict=True) + +### USER ### +def add_user(new_user): + db.session.add(new_user) + db.session.commit() + +def get_users(): + all_users = User.query.all() + result = users_schema.dump(all_users) + return result.data + +def get_user(id): + user = User.query.get(id) + return user + +def update_user(id, updated_user): + user = User.query.get(id) + user.email = updated_user.email + user.username = updated_user.username + user.password = updated_user.password + user.editorSettingsId = updated_user.editorSettingsId + db.session.commit() + return user + +def delete_user(id): + user = User.query.get(id) + db.session.delete(user) + db.session.commit() + return user + +def delete_all_users(): + num_users_deleted = db.session.query(User).delete() + db.session.commit() + return num_users_deleted + +def is_user(user): + return user != None and 'id' in user.keys() and bool(User.query.get(user['id'])) + +def is_user_id(userId): + return userId != None and bool(User.query.get(userId)) + +def is_unregistered_email(email): + return not bool(get_user_from_email(email)) + +def is_unregistered_username(username): + return not bool(get_user_from_username(username)) + +def get_user_from_email(email): + return User.query.filter_by(email=email).first() + +def get_user_from_username(username): + return User.query.filter_by(username=username).first() + +def get_username_from_email(email): + return get_user_from_email(email).username + +def is_valid_email_password(email, password): + user = get_user_from_email(email) + valid_password = user.password if user else None + return password != None and password == valid_password + +def is_valid_username_password(username, password): + user = get_user_from_username(username) + valid_password = user.password if user else None + return password != None and password == valid_password + +def change_password(username, newPassword): + user = get_user_from_username(username) + user.password = newPassword + db.session.commit() + return user + +### EDITORSETTINGS ### +def add_editor_settings(editor_settings): + db.session.add(editor_settings) + db.session.commit() + +def get_editor_settings(user_id): + return EditorSettings.query.filter_by(userId=user_id).first() + +def update_editor_settings(updated_editor_settings): + editor_settings = EditorSettings.query.get(updated_editor_settings.id) + editor_settings.fontSize = updated_editor_settings.fontSize + editor_settings.margin = updated_editor_settings.margin + editor_settings.tabSize = updated_editor_settings.tabSize + db.session.commit() + return editor_settings + +def delete_editor_settings(id): + editor_settings = EditorSettings.query.get(id) + db.session.delete(editor_settings) + db.session.commit() + return editor_settings + +def delete_all_editor_settings(): + num_editor_settings_deleted = db.session.query(EditorSettings).delete() + db.session.commit() + return num_editor_settings_deleted + +### PROJECT ### +def add_project(project): + db.session.add(project) + db.session.commit() + +def get_projects(): + all_projects = Project.query.all() + result = projects_schema.dump(all_projects) + return result.data + +def get_user_projects(user_id): + """ Returns all projects where user is the creator or a collaborator """ + user_projects = Project.query.filter_by(creatorId=user_id).all() + collaborator_project_ids = [id for id, in db.session.query(Collaborator.projectId).filter_by(userId=user_id)] + user_projects += Project.query.filter(Project.id.in_(collaborator_project_ids)).all() + result = projects_schema.dump(user_projects) + return result.data + +def get_project(id): + project = Project.query.get(id) + return project + +def update_project(updated_project): + project = Project.query.get(updated_project.id) + project.title = updated_project.title + project.creatorId = updated_project.creatorId + project.archived = updated_project.archived + db.session.commit() + return project + +def delete_project(id): + project = Project.query.get(id) + db.session.delete(project) + db.session.commit() + return project + +def delete_user_projects(user_id): + """ Deletes all projects owned by user with id. """ + user = User.query.get(user_id) + user_projects_query = Project.query.filter_by(creatorId=user_id) + + user_projects = user_projects_query.all() + for project in user_projects: + delete_project_files(project.id) + delete_project_collaborators(project.id) + delete_project_messages(project.id) + delete_project(project.id) + + num_projects_deleted = user_projects_query.delete() + db.session.commit() + return num_projects_deleted + +def delete_all_projects(): + num_projects_deleted = db.session.query(Project).delete() + db.session.commit() + return num_projects_deleted + +### FILE ### +def add_file(file): + db.session.add(file) + db.session.commit() + +def get_file(id): + return File.query.get(id) + +def get_files(): + all_files = File.query.all() + result = files_schema.dump(all_files) + return result.data + +def get_project_files(project_id): + project_files = File.query.filter_by(projectId=project_id).all() + result = files_schema.dump(project_files) + return result.data + +def update_file(updated_file): + file = File.query.get(updated_file.id) + file.projectId = updated_file.projectId + file.name = updated_file.name + file.isFolder = updated_file.isFolder + file.parent = updated_file.parent + file.content = updated_file.content + db.session.commit() + return file + +def delete_file(file_id): + file = File.query.filter_by(id=file_id).first() + db.session.delete(file) + db.session.commit() + return file + +def delete_project_files(project_id): + num_project_files_deleted = File.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_files_deleted + +def delete_all_files(): + num_files_deleted = db.session.query(File).delete() + db.session.commit() + return num_files_deleted + +### COLLABORATOR ### +def add_collaborator(collaborator): + db.session.add(collaborator) + db.session.commit() + +def get_collaborator(id): + collaborator = Collaborator.query.get(id) + return collaborator + +def get_collaborators(): + all_collaborators = Collaborator.query.all() + result = collaborators_schema.dump(all_collaborators) + return result.data + +def get_project_collaborators(project_id): + project_collaborators = Collaborator.query.filter_by(projectId=project_id).all() + result = collaborators_schema.dump(project_collaborators) + return result.data + +def update_collaborator(updated_collaborator): + collaborator = Collaborator.query.get(updated_collaborator.id) + collaborator.projectId = updated_collaborator.projectId + collaborator.userId = updated_collaborator.userId + collaborator.permission = updated_collaborator.permission + db.session.commit() + return collaborator + +def delete_collaborator(collaborator_id): + collaborator = Collaborator.query.filter_by(id=collaborator_id).first() + db.session.delete(collaborator) + db.session.commit() + return collaborator + +def delete_project_collaborators(project_id): + num_project_collaborators_deleted = Collaborator.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_collaborators_deleted + +def delete_all_collaborators(): + num_collaborators_deleted = db.session.query(Collaborator).delete() + db.session.commit() + return num_collaborators_deleted + +### Message ### +def add_message(message): + db.session.add(message) + db.session.commit() + +def get_message(id): + message = Message.query.get(id) + return message + +def get_messages(): + all_messages = Message.query.all() + result = messages_schema.dump(all_messages) + return result.data + +def get_project_messages(project_id): + project_messages = Message.query.filter_by(projectId=project_id).all() + result = messages_schema.dump(project_messages) + return result.data + +def update_message(updated_message): + message = Message.query.get(updated_message.id) + message.projectId = updated_message.projectId + message.autorId = updated_message.autorId + message.time = updated_message.time + db.session.commit() + return message + +def delete_project_messages(project_id): + num_project_messages_deleted = Message.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_messages_deleted + +def delete_all_messages(): + num_messages_deleted = db.session.query(Message).delete() + db.session.commit() + return num_messages_deleted + +print('Hello world!') \ No newline at end of file diff --git a/backend/19/napp.py b/backend/19/napp.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/2/Folder/app.py b/backend/2/Folder/app.py new file mode 100644 index 00000000..f9204ee5 --- /dev/null +++ b/backend/2/Folder/app.py @@ -0,0 +1 @@ +print('Hello!') \ No newline at end of file diff --git a/backend/2/Folder/heh.py b/backend/2/Folder/heh.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/2/Folder/hello.py b/backend/2/Folder/hello.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/2/app.py b/backend/2/app.py new file mode 100644 index 00000000..63fe4765 --- /dev/null +++ b/backend/2/app.py @@ -0,0 +1,592 @@ +import os, sys, subprocess, shutil +import random + +import logging +from subprocess import Popen, PIPE +from datetime import datetime +from flask import Flask, request, jsonify, Response, json +from flask_jwt_extended import JWTManager +from flask_jwt_extended import (create_access_token, create_refresh_token, + jwt_required, jwt_refresh_token_required, get_jwt_identity) +from flask_bcrypt import Bcrypt +from flask_socketio import emit +from base64 import b64decode +from simpleflock import SimpleFlock + +from __init__ import app +import database_helper as db +from socketio_helper import socketio, SIMPLE_FLOCK_TIMEOUT + +flask_bcrypt = Bcrypt(app) +jwt = JWTManager(app) + +# Http status codes +OK_STATUS_CODE = 200 +BAD_REQUEST_STATUS_CODE = 400 +CONFLICT_STATUS_CODE = 409 +UNAUTHORIZED_STATUS_CODE = 401 + +DEFAULT_EDITOR_SETTINGS = {'fontSize': 16, 'margin': 80, 'tabSize': 4} + +### GENERAL ### +def generate_response(response, status, headers={}): + response.status_code = status + response.headers = {**{ + 'Content-Type': 'application/json', + 'Access-Control-Expose-Headers' : 'X-Auth-Token', + }, **headers} + return response + +### Authentication ### +def is_valid_credentials(usernameEmail, password): + if '@' in usernameEmail: + user = db.get_user_from_email(usernameEmail) + else: + user = db.get_user_from_username(usernameEmail) + return user and flask_bcrypt.check_password_hash(user.password, + b64decode(password).decode("utf-8")) + +def is_valid_password(password): + return len(password) >= 8 and len(password) <= 100 + +def is_valid_collaborators(collaborators): + if not collaborators: + return True + acceptedPermissions = ['View-only', 'May edit'] + for collaborator in collaborators: + if (not collaborator or not db.is_user_id(collaborator['userId']) or + not collaborator['permission'] in acceptedPermissions): + return False + return True + +@jwt.unauthorized_loader +def unauthorized_response(callback): + return jsonify({ + 'ok': False, + 'message': 'Missing Authorization Header' + }), 401 + +@app.route('/api/auth', methods=["GET"]) +@jwt_required +def auth(): + return generate_response(jsonify({ + 'success': True, + 'message': 'Authorization successsful' + }), OK_STATUS_CODE) + +@app.route('/api/refresh', methods=['POST']) +@jwt_refresh_token_required +def refresh(): + current_user = get_jwt_identity() + return generate_response(jsonify({ + 'success': True, + 'message': 'Successfully refreshed token', + 'jwt': create_access_token(identity=current_user, fresh=False) + }), OK_STATUS_CODE) + +### USER ### +@app.route('/api/sign_up', methods=['POST']) +def sign_up(): + email = request.json['email'] + username = request.json['username'] + password = b64decode(request.json['password']).decode("utf-8") + if (not email or not username or not password): + return generate_response(jsonify({ + 'success': False, + 'message': 'Bad request.' + }), BAD_REQUEST_STATUS_CODE) + elif (not db.is_unregistered_email(email.lower())): + return generate_response(jsonify({ + 'success': False, + 'message': 'User with that email already exists' + }), CONFLICT_STATUS_CODE) + elif (not db.is_unregistered_username(username.lower())): + return generate_response(jsonify({ + 'success': False, + 'message': 'User with that username already exists' + }), CONFLICT_STATUS_CODE) + elif (not is_valid_password(password)): + return generate_response(jsonify({ + 'success': False, + 'message': 'Password does not fulfill requirements' + }), BAD_REQUEST_STATUS_CODE) + else: + new_user = db.User( + email.lower(), + username.lower(), + flask_bcrypt.generate_password_hash(password).decode('utf-8')) + db.add_user(new_user) + + new_editor_settings = db.EditorSettings( + DEFAULT_EDITOR_SETTINGS['fontSize'], + DEFAULT_EDITOR_SETTINGS['margin'], + DEFAULT_EDITOR_SETTINGS['tabSize']) + db.add_editor_settings(new_editor_settings) + + new_user.editorSettingsId = new_editor_settings.id + db.update_user(new_user.id, new_user) + return generate_response(db.user_schema.jsonify(new_user), + OK_STATUS_CODE) + +@app.route('/api/sign_in', methods=['GET']) +def sign_in(): + auth = request.authorization + usernameEmail = auth.username + password = auth.password + + if not usernameEmail or not password: + message = 'Missing credentials!' + status = BAD_REQUEST_STATUS_CODE + elif not is_valid_credentials(usernameEmail.lower(), password): + message = 'Incorrect username/email or password' + status = UNAUTHORIZED_STATUS_CODE + else: + if '@' in usernameEmail: + username = db.get_username_from_email(usernameEmail.lower()) + else: + username = usernameEmail + + db_user = db.get_user_from_username(username.lower()) + user = { + 'id': db_user.id, + 'email': db_user.email, + 'username': db_user.username, + 'editorSettingsId': db_user.editorSettingsId, + 'editorSettings': db.editor_settings_schema.dumps(db_user.editorSettings), + 'jwt': create_access_token(identity=username), + 'refreshToken': create_refresh_token(identity=username) + } + return generate_response(jsonify(user), OK_STATUS_CODE) + + return generate_response(jsonify({ + 'success': False, + 'message': message + }), status) + +@app.route('/api/change_password', methods=['POST', 'PUT']) +@jwt_required +def change_password(): + username = request.json['username'] + user = db.get_user_from_username(username) + oldPassword = request.json['oldPassword'] + newPassword = request.json['newPassword'] + if (not user): + return generate_response(jsonify({ + 'success': False, + 'message': 'No user with that username' + }), BAD_REQUEST_STATUS_CODE) + elif not is_valid_credentials(username, oldPassword): + return generate_response(jsonify({ + 'success': False, + 'message': 'Old password was incorrect' + }),UNAUTHORIZED_STATUS_CODE) + elif (not is_valid_password(newPassword)): + return generate_response(jsonify({ + 'success': False, + 'message': 'New password does not fulfill requirements' + }), BAD_REQUEST_STATUS_CODE) + else: + updated_user = db.change_password( + username, + flask_bcrypt.generate_password_hash(newPassword).decode('utf-8') + ) + return generate_response(db.user_schema.jsonify(updated_user), OK_STATUS_CODE) + +@app.route('/api/delete_account', methods=['DELETE']) +def delete_user(): + auth = request.authorization + username = auth.username + password = auth.password + user = db.get_user_from_username(username) + if not is_valid_credentials(username, password): + return generate_response(jsonify({ + 'success': False, + 'message': 'Password was incorrect' + }),UNAUTHORIZED_STATUS_CODE) + elif (not user): + return generate_response(jsonify({ + 'success': False, + 'message': 'No user with that username' + }), BAD_REQUEST_STATUS_CODE) + else: + db.delete_user_projects(user.id) + db.delete_editor_settings(user.editorSettingsId) + db.delete_user(user.id) + return generate_response(jsonify({ + 'success': True, + 'message': 'Successfully deleted account' + }), OK_STATUS_CODE) + +@app.route('/api/get_users', methods=['GET']) +@jwt_required +def get_users_request(): + return generate_response(db.users_schema.jsonify(db.get_users()), + OK_STATUS_CODE) + +@app.route('/api/get_user/<id>', methods=['GET']) +@jwt_required +def get_user_request(id): + return generate_response(db.user_schema.jsonify(db.get_user(id)), + OK_STATUS_CODE) + +### EDITOR SETTINGS ### +@app.route('/api/update_editor_settings/<user_id>', methods=['PUT']) +@jwt_required +def update_editor_settings_request(user_id): + editor_settings = request.json['editorSettings'] + updated_editor_settings = db.EditorSettings( + editor_settings['fontSize'], + editor_settings['margin'], + editor_settings['tabSize'], + ) + updated_editor_settings.id = editor_settings['id'] + return generate_response(db.editor_settings_schema.jsonify( + db.update_editor_settings(updated_editor_settings)), OK_STATUS_CODE) + +### PROJECT ### +@app.route('/api/add_project', methods=['POST']) +@jwt_required +def new_project(): + title = request.json['title'] + creatorId = request.json['creatorId'] + collaborators = request.json['collaborators'] if request.json['collaborators'] != [] else None + if (not title or not creatorId): + return generate_response(jsonify({ + 'success': False, + 'message': 'Bad request.' + }), BAD_REQUEST_STATUS_CODE) + elif (not db.is_user_id(creatorId)): + return generate_response(jsonify({ + 'success': False, + 'message': 'User does not exist' + }), BAD_REQUEST_STATUS_CODE) + elif (not is_valid_collaborators(collaborators)): + return generate_response(jsonify({ + 'success': False, + 'message': 'Invalid collaborators' + }), BAD_REQUEST_STATUS_CODE) + else: + new_project = db.Project(title, creatorId) + db.add_project(new_project) + + if collaborators: + for collaborator in collaborators: + new_collaborator = db.Collaborator( + project_id=new_project.id, + user_id=collaborator['userId'], + permission=collaborator['permission']) + db.add_collaborator(new_collaborator) + + + return generate_response(db.project_schema.jsonify(new_project), OK_STATUS_CODE) + +@app.route('/api/duplicate_project/<project_id>', methods=['POST']) +@jwt_required +def duplicate_project(project_id): + title = request.json['title'] + creator_id = request.json['creatorId'] + if (not title or not creator_id): + return generate_response(jsonify({ + 'success': False, + 'message': 'Bad request.' + }), BAD_REQUEST_STATUS_CODE) + elif (not db.is_user_id(creator_id)): + return generate_response(jsonify({ + 'success': False, + 'message': 'User does not exist' + }), BAD_REQUEST_STATUS_CODE) + else: + old_project = db.get_project(project_id) + duplicated_project = db.Project( + title, + creator_id, + archived=old_project.archived + ) + db.add_project(duplicated_project) + + collaborators = db.get_project_collaborators(project_id) + if collaborators: + for collaborator in collaborators: + if collaborator['userId'] != creator_id: + duplicated_collaborator = db.Collaborator( + project_id=duplicated_project.id, + user_id=collaborator['userId'], + permission=collaborator['permission']) + db.add_collaborator(duplicated_collaborator) + + if old_project.creatorId != creator_id: + old_creator_collaborator = db.Collaborator( + project_id=duplicated_project.id, + user_id=old_project.creatorId, + permission='May edit') + db.add_collaborator(old_creator_collaborator) + + files = db.get_project_files(project_id) + if files: + for file in files: + duplicated_file = db.File( + project_id=duplicated_project.id, + name=file['name'], + is_folder=file['isFolder'], + parent=file['parent'], + content=file['content']) + db.add_file(duplicated_file) + + return generate_response(db.project_schema.jsonify(duplicated_project), OK_STATUS_CODE) + +@app.route('/api/get_projects', methods=['GET']) +@jwt_required +def get_projects_request(): + return generate_response(db.projects_schema.jsonify(db.get_projects()), + OK_STATUS_CODE) + +@app.route('/api/get_projects/<user_id>', methods=['GET']) +@jwt_required +def get_user_projects_request(user_id): + return generate_response(db.projects_schema.jsonify(db.get_user_projects(user_id)), + OK_STATUS_CODE) + +@app.route('/api/get_project/<id>', methods=['GET']) +@jwt_required +def get_project_request(id): + return generate_response(db.project_schema.jsonify(db.get_project(id)), + OK_STATUS_CODE) + +@app.route('/api/update_project/<id>', methods=['POST', 'PUT']) +@jwt_required +def update_project_request(id): + project = json.loads(request.json['project']) + updated_project = db.Project( + title = project['title'], + creator_id = project['creatorId'], + archived = project['archived']) + updated_project.id = project['id'] + + return generate_response(db.project_schema.jsonify( + db.update_project(updated_project)), OK_STATUS_CODE) + +@app.route('/api/set_archived_project/<project_id>', methods=['POST', 'PUT']) +@jwt_required +def set_archived_project_request(project_id): + project = db.get_project(project_id) + project.archived = request.json['archived'] + + return generate_response(db.project_schema.jsonify( + db.update_project(project)), OK_STATUS_CODE) + +@app.route('/api/delete_project/<project_id>', methods=['DELETE']) +@jwt_required +def delete_project_request(project_id): + # TODO: Check that the user trying to delete the project is the creator! + db.delete_project_files(project_id) + db.delete_project_collaborators(project_id) + db.delete_project_messages(project_id) + db.delete_project(project_id) + return generate_response(jsonify({ + 'success': True, + 'message': 'Successfully deleted project' + }), OK_STATUS_CODE) + +@app.route('/api/parse_project_files/<project_id>', methods=['GET']) +@jwt_required +def parse_file_request(project_id): + if db.get_project(project_id): + if os.path.exists(str(project_id)): + find_cmd = 'find ' + str(project_id) + '/ -type f -name "*.py"' + find = Popen(find_cmd, shell=True, stdout=PIPE) + files = find.communicate()[0].decode('utf-8') + files = ' '.join(files.splitlines()) + + pylint_cmd = 'pylint --output-format=json ' + files + pylint = Popen(pylint_cmd, shell=True, stdout=PIPE) + result = pylint.communicate()[0].decode('utf-8') + else: + result = '[]' + return generate_response(jsonify({ + 'success': True, + 'message': 'Successfully parsed files.', + 'result': result + }), OK_STATUS_CODE) + else: + return generate_response(jsonify({ + 'success': False, + 'message': 'No such project' + }), BAD_REQUEST_STATUS_CODE) + +### FILE ### +@app.route('/api/create_file', methods=['POST']) +@jwt_required +def create_file_request(): + file = request.json['file'] + new_file = db.File( + file['projectId'], + file['name'], + file['isFolder'], + file['parent'], + file['content'], + ) + db.add_file(new_file) + + # Update edited date in project + project = db.get_project(new_file.projectId) + project.edited = datetime.now() + db.update_project(project) + + # Add file locally on server if project is open + open_project_path = str(new_file.projectId) + '/' + if os.path.exists(open_project_path): + if new_file.parent: + file_path = open_project_path + new_file.parent + '/' + new_file.name + else: + file_path = open_project_path + new_file.name + + if new_file.isFolder: + os.makedirs(file_path) + else: + f = open(file_path, 'w+') + f.write(new_file.content) + f.close() + + return generate_response(db.file_schema.jsonify( + new_file), OK_STATUS_CODE) + +@app.route('/api/update_file/<file_id>', methods=['PUT']) +@jwt_required +def update_file_request(file_id): + file = request.json['file'] + old_file = db.get_file(file['id']) + + updated_file = db.File( + file['projectId'], + file['name'], + file['isFolder'], + file['parent'], + file['content']) + updated_file.id = file['id'] + + # Update edited date in project + project = db.get_project(updated_file.projectId) + project.edited = datetime.now() + db.update_project(project) + + # Update file locally on server if project is open + open_project_path = str(project.id) + '/' + if old_file.parent: + old_path = open_project_path + old_file.parent + '/' + old_file.name + else: + old_path = open_project_path + old_file.name + if os.path.exists(old_path): + with SimpleFlock(str(project.id) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(old_path): + if file['parent']: + new_path = open_project_path + file['parent'] + '/' + file['name'] + else: + new_path = open_project_path + file['name'] + os.rename(old_path, new_path) + + return generate_response(db.file_schema.jsonify( + db.update_file(updated_file)), OK_STATUS_CODE) + +@app.route('/api/delete_file/<file_id>', methods=['DELETE']) +@jwt_required +def delete_file_request(file_id): + # Update edited date in project + file = db.get_file(file_id) + project = db.get_project(file.projectId) + project.edited = datetime.now() + db.update_project(project) + + # Remove file locally on server if project is open + open_project_path = str(file.projectId) + '/' + if file.parent: + path = open_project_path + file.parent + '/' + file.name + else: + path = open_project_path + file.name + if os.path.exists(path): + with SimpleFlock(str(file.projectId) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(path): + if file.isFolder: + shutil.rmtree(path) + else: + os.remove(path) + + return generate_response(db.file_schema.jsonify( + db.delete_file(file_id)), OK_STATUS_CODE) + +@app.route('/api/get_project_files/<project_id>', methods=['GET']) +@jwt_required +def get_project_files_request(project_id): + return generate_response(db.files_schema.jsonify( + db.get_project_files(project_id)), OK_STATUS_CODE) + +### COLLABORATOR ### +@app.route('/api/add_collaborator', methods=['POST']) +@jwt_required +def add_collaborator_request(): + collaborator = request.json['collaborator'] + new_collaborator = db.Collaborator( + collaborator['projectId'], + collaborator['userId'], + collaborator['permission'], + ) + db.add_collaborator(new_collaborator) + + # Update edited date in project + project = db.get_project(new_collaborator.projectId) + project.edited = datetime.now() + db.update_project(project) + + return generate_response(db.collaborator_schema.jsonify( + new_collaborator), OK_STATUS_CODE) + + +@app.route('/api/update_collaborator/<collaborator_id>', methods=['PUT']) +@jwt_required +def update_collaborator_request(collaborator_id): + # Update collaborator + collaborator = request.json['collaborator'] + updated_collaborator = db.Collaborator( + collaborator['projectId'], + collaborator['userId'], + collaborator['permission'] + ) + updated_collaborator.id = collaborator['id'] + + # Update edited date in project + project = db.get_project(updated_collaborator.projectId) + project.edited = datetime.now() + db.update_project(project) + + return generate_response(db.collaborator_schema.jsonify( + db.update_collaborator(updated_collaborator)), OK_STATUS_CODE) + +@app.route('/api/delete_collaborator/<collaborator_id>', methods=['DELETE']) +@jwt_required +def delete_collaborator_request(collaborator_id): + # Update edited date in project + collaborator = db.get_collaborator(collaborator_id) + project = db.get_project(collaborator.projectId) + project.edited = datetime.now() + db.update_project(project) + + return generate_response(db.collaborator_schema.jsonify( + db.delete_collaborator(collaborator_id)), OK_STATUS_CODE) + +@app.route('/api/get_project_collaborators/<project_id>', methods=['GET']) +@jwt_required +def get_project_collaborators_request(project_id): + return generate_response(db.collaborators_schema.jsonify( + db.get_project_collaborators(project_id)), OK_STATUS_CODE) + +### MESSAGE ### +@app.route('/api/get_project_messages/<project_id>', methods=['GET']) +@jwt_required +def get_project_messages_request(project_id): + return generate_response(db.messages_schema.jsonify( + db.get_project_messages(project_id)), OK_STATUS_CODE) + +if __name__ == '__main__': + socketio.run(app, host='0.0.0.0') + + """ If you deleted the db, run the following to re-initialize it. """ + # db.db.create_all() \ No newline at end of file diff --git a/backend/2/database_helper.py b/backend/2/database_helper.py new file mode 100644 index 00000000..8bfe3a7c --- /dev/null +++ b/backend/2/database_helper.py @@ -0,0 +1,439 @@ +import os + +from __init__ import app + +import json +from datetime import datetime +from flask import Flask, request, jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow, fields + +basedir = os.path.abspath(os.path.dirname(__file__)) + +# Database +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'db.sqlite') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +db = SQLAlchemy(app) +ma = Marshmallow(app) + +# EditorSettings +class EditorSettings(db.Model): + id = db.Column(db.Integer, primary_key=True) + fontSize = db.Column(db.Integer, nullable=False) + margin = db.Column(db.Integer, nullable=False) + tabSize = db.Column(db.Integer, nullable=False) + + def __init__(self, font_size, margin, tab_size): + self.fontSize = font_size + self.margin = margin + self.tabSize = tab_size + +class EditorSettingsSchema(ma.Schema): + class Meta: + model = EditorSettings + fields = ('id', 'fontSize', 'margin', 'tabSize') + +editor_settings_schema = EditorSettingsSchema(strict=True) + +# User +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True, nullable=False) + username = db.Column(db.String(50), unique=True, nullable=False) + password = db.Column(db.Text, nullable=False) + editorSettingsId = db.Column(db.Integer, db.ForeignKey(EditorSettings.id)) + editorSettings = db.relationship("EditorSettings") + + def __init__(self, email, username, password): + self.email = email + self.username = username + self.password = password + +class UserSchema(ma.Schema): + editorSettings = ma.Nested(EditorSettingsSchema) + class Meta: + model = User + fields = ('id', 'email', 'username', 'editorSettingsId', 'editorSettings') + +user_schema = UserSchema(strict=True) +users_schema = UserSchema(many=True, strict=True) + +# Project +class Project(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(50), nullable=False) + creatorId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + creator = db.relationship('User') + created = db.Column(db.String(50), default=datetime.now, nullable=False) + edited = db.Column(db.String(50), onupdate=datetime.now) + archived = db.Column(db.Boolean, default=False, nullable=False) + + def __init__(self, title, creator_id, created=None, edited=None, archived=False): + self.title = title + self.creatorId = creator_id + self.created = created + self.edited = edited + self.archived = archived + +class ProjectSchema(ma.Schema): + creator = ma.Nested(UserSchema) + class Meta: + model = Project + fields = ('id', 'title', 'creatorId', 'creator', 'created', 'edited', 'archived') + +project_schema = ProjectSchema(strict=True) +projects_schema = ProjectSchema(many=True, strict=True) + +# File +class File(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project', lazy='subquery') + name = db.Column(db.String(100), nullable=False) + isFolder = db.Column(db.Boolean, nullable=False) + parent = db.Column(db.String(100)) + content = db.Column(db.Text) + + def __init__(self, project_id, name, is_folder, parent, content): + self.projectId = project_id + self.name = name + self.isFolder = is_folder + self.parent = parent + self.content = content + +class FileSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + class Meta: + model = File + fields = ('id', 'projectId', 'project', 'name', 'isFolder', 'parent', 'content') + +file_schema = FileSchema(strict=True) +files_schema = FileSchema(many=True, strict=True) + +# Collaborator +class Collaborator(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project', lazy='subquery') + userId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + user = db.relationship('User', lazy='subquery') + permission = db.Column(db.Text, nullable=False) + + def __init__(self, project_id, user_id, permission): + self.projectId = project_id + self.userId = user_id + self.permission = permission + +class CollaboratorSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + user = ma.Nested(UserSchema) + class Meta: + fields = ('id', 'projectId', 'project', 'userId', 'user', 'permission') + +collaborator_schema = CollaboratorSchema(strict=True) +collaborators_schema = CollaboratorSchema(many=True, strict=True) + +# Message +class Message(db.Model): + id = db.Column(db.Integer, primary_key=True) + projectId = db.Column(db.Integer, db.ForeignKey(Project.id), nullable=False) + project = db.relationship('Project') + authorId = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + author = db.relationship('User') + message = db.Column(db.Text) + time = db.Column(db.String(50), default=datetime.now, nullable=False) + + def __init__(self, project_id, author_id, message, time=None): + self.projectId = project_id + self.authorId = author_id + self.message = message + self.time = time + +class MessageSchema(ma.Schema): + project = ma.Nested(ProjectSchema) + author = ma.Nested(UserSchema) + class Meta: + fields = ('id', 'projectId', 'project', 'authorId', 'author', 'message', 'time') + +message_schema = MessageSchema(strict=True) +messages_schema = MessageSchema(many=True, strict=True) + +### USER ### +def add_user(new_user): + db.session.add(new_user) + db.session.commit() + +def get_users(): + all_users = User.query.all() + result = users_schema.dump(all_users) + return result.data + +def get_user(id): + user = User.query.get(id) + return user + +def update_user(id, updated_user): + user = User.query.get(id) + user.email = updated_user.email + user.username = updated_user.username + user.password = updated_user.password + user.editorSettingsId = updated_user.editorSettingsId + db.session.commit() + return user + +def delete_user(id): + user = User.query.get(id) + db.session.delete(user) + db.session.commit() + return user + +def delete_all_users(): + num_users_deleted = db.session.query(User).delete() + db.session.commit() + return num_users_deleted + +def is_user(user): + return user != None and 'id' in user.keys() and bool(User.query.get(user['id'])) + +def is_user_id(userId): + return userId != None and bool(User.query.get(userId)) + +def is_unregistered_email(email): + return not bool(get_user_from_email(email)) + +def is_unregistered_username(username): + return not bool(get_user_from_username(username)) + +def get_user_from_email(email): + return User.query.filter_by(email=email).first() + +def get_user_from_username(username): + return User.query.filter_by(username=username).first() + +def get_username_from_email(email): + return get_user_from_email(email).username + +def is_valid_email_password(email, password): + user = get_user_from_email(email) + valid_password = user.password if user else None + return password != None and password == valid_password + +def is_valid_username_password(username, password): + user = get_user_from_username(username) + valid_password = user.password if user else None + return password != None and password == valid_password + +def change_password(username, newPassword): + user = get_user_from_username(username) + user.password = newPassword + db.session.commit() + return user + +### EDITORSETTINGS ### +def add_editor_settings(editor_settings): + db.session.add(editor_settings) + db.session.commit() + +def get_editor_settings(user_id): + return EditorSettings.query.filter_by(userId=user_id).first() + +def update_editor_settings(updated_editor_settings): + editor_settings = EditorSettings.query.get(updated_editor_settings.id) + editor_settings.fontSize = updated_editor_settings.fontSize + editor_settings.margin = updated_editor_settings.margin + editor_settings.tabSize = updated_editor_settings.tabSize + db.session.commit() + return editor_settings + +def delete_editor_settings(id): + editor_settings = EditorSettings.query.get(id) + db.session.delete(editor_settings) + db.session.commit() + return editor_settings + +def delete_all_editor_settings(): + num_editor_settings_deleted = db.session.query(EditorSettings).delete() + db.session.commit() + return num_editor_settings_deleted + +### PROJECT ### +def add_project(project): + db.session.add(project) + db.session.commit() + +def get_projects(): + all_projects = Project.query.all() + result = projects_schema.dump(all_projects) + return result.data + +def get_user_projects(user_id): + """ Returns all projects where user is the creator or a collaborator """ + user_projects = Project.query.filter_by(creatorId=user_id).all() + collaborator_project_ids = [id for id, in db.session.query(Collaborator.projectId).filter_by(userId=user_id)] + user_projects += Project.query.filter(Project.id.in_(collaborator_project_ids)).all() + result = projects_schema.dump(user_projects) + return result.data + +def get_project(id): + project = Project.query.get(id) + return project + +def update_project(updated_project): + project = Project.query.get(updated_project.id) + project.title = updated_project.title + project.creatorId = updated_project.creatorId + project.archived = updated_project.archived + db.session.commit() + return project + +def delete_project(id): + project = Project.query.get(id) + db.session.delete(project) + db.session.commit() + return project + +def delete_user_projects(user_id): + """ Deletes all projects owned by user with id. """ + user = User.query.get(user_id) + user_projects_query = Project.query.filter_by(creatorId=user_id) + + user_projects = user_projects_query.all() + for project in user_projects: + delete_project_files(project.id) + delete_project_collaborators(project.id) + delete_project_messages(project.id) + delete_project(project.id) + + num_projects_deleted = user_projects_query.delete() + db.session.commit() + return num_projects_deleted + +def delete_all_projects(): + num_projects_deleted = db.session.query(Project).delete() + db.session.commit() + return num_projects_deleted + +### FILE ### +def add_file(file): + db.session.add(file) + db.session.commit() + +def get_file(id): + return File.query.get(id) + +def get_files(): + all_files = File.query.all() + result = files_schema.dump(all_files) + return result.data + +def get_project_files(project_id): + project_files = File.query.filter_by(projectId=project_id).all() + result = files_schema.dump(project_files) + return result.data + +def update_file(updated_file): + file = File.query.get(updated_file.id) + file.projectId = updated_file.projectId + file.name = updated_file.name + file.isFolder = updated_file.isFolder + file.parent = updated_file.parent + file.content = updated_file.content + db.session.commit() + return file + +def delete_file(file_id): + file = File.query.filter_by(id=file_id).first() + db.session.delete(file) + db.session.commit() + return file + +def delete_project_files(project_id): + num_project_files_deleted = File.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_files_deleted + +def delete_all_files(): + num_files_deleted = db.session.query(File).delete() + db.session.commit() + return num_files_deleted + +### COLLABORATOR ### +def add_collaborator(collaborator): + db.session.add(collaborator) + db.session.commit() + +def get_collaborator(id): + collaborator = Collaborator.query.get(id) + return collaborator + +def get_collaborators(): + all_collaborators = Collaborator.query.all() + result = collaborators_schema.dump(all_collaborators) + return result.data + +def get_project_collaborators(project_id): + project_collaborators = Collaborator.query.filter_by(projectId=project_id).all() + result = collaborators_schema.dump(project_collaborators) + return result.data + +def update_collaborator(updated_collaborator): + collaborator = Collaborator.query.get(updated_collaborator.id) + collaborator.projectId = updated_collaborator.projectId + collaborator.userId = updated_collaborator.userId + collaborator.permission = updated_collaborator.permission + db.session.commit() + return collaborator + +def delete_collaborator(collaborator_id): + collaborator = Collaborator.query.filter_by(id=collaborator_id).first() + db.session.delete(collaborator) + db.session.commit() + return collaborator + +def delete_project_collaborators(project_id): + num_project_collaborators_deleted = Collaborator.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_collaborators_deleted + +def delete_all_collaborators(): + num_collaborators_deleted = db.session.query(Collaborator).delete() + db.session.commit() + return num_collaborators_deleted + +### Message ### +def add_message(message): + db.session.add(message) + db.session.commit() + +def get_message(id): + message = Message.query.get(id) + return message + +def get_messages(): + all_messages = Message.query.all() + result = messages_schema.dump(all_messages) + return result.data + +def get_project_messages(project_id): + project_messages = Message.query.filter_by(projectId=project_id).all() + result = messages_schema.dump(project_messages) + return result.data + +def update_message(updated_message): + message = Message.query.get(updated_message.id) + message.projectId = updated_message.projectId + message.autorId = updated_message.autorId + message.time = updated_message.time + db.session.commit() + return message + +def delete_project_messages(project_id): + num_project_messages_deleted = Message.query.filter_by(projectId=project_id).delete() + db.session.commit() + return num_project_messages_deleted + +def delete_all_messages(): + num_messages_deleted = db.session.query(Message).delete() + db.session.commit() + return num_messages_deleted \ No newline at end of file diff --git a/backend/2/socketio_helper.py b/backend/2/socketio_helper.py new file mode 100644 index 00000000..6dfe599e --- /dev/null +++ b/backend/2/socketio_helper.py @@ -0,0 +1,301 @@ +import os, shutil + +from flask import json +from flask_socketio import SocketIO, send, emit, join_room, leave_room +import diff_match_patch as dmp_module +from simpleflock import SimpleFlock + +from __init__ import app +import database_helper as db + +SIMPLE_FLOCK_TIMEOUT = 10 + +socketio = SocketIO(app) +dmp = dmp_module.diff_match_patch() + +project_viewers = dict() + +def has_viewer(username, project_id): + for viewer in project_viewers[str(project_id)]: + if viewer['username'] == username: + return True + return False + +def set_viewer(viewer, project_id): + filtered_viewers = [] + for curr_viewer in project_viewers[str(project_id)]: + if curr_viewer['username'] != viewer['username']: + filtered_viewers.append(curr_viewer) + project_viewers[str(project_id)].append(viewer) + project_viewers[str(project_id)] = filtered_viewers + +def add_project_viewer(project_id, username): + viewer = { + 'username': username, + 'cursor': { + 'fileId': None, + 'position': None, + 'added': False + } + } + if not str(project_id) in project_viewers.keys(): + project_viewers[str(project_id)] = [viewer] + elif not has_viewer(username, project_id): + project_viewers[str(project_id)].append(viewer) + else: + set_viewer(viewer, project_id) + +def remove_project_viewer(project_id, username): + if str(project_id) in project_viewers.keys() and \ + has_viewer(username, project_id): + viewers = [] + for viewer in project_viewers[str(project_id)]: + if viewer['username'] != username: + viewers.append(viewer) + project_viewers[str(project_id)] = viewers + + +## Join user room +@socketio.on('joinUserRoom') +def on_join_user_room(data): + data = json.loads(data) + user_id = data['userId'] + room = 'user' + str(user_id) + join_room(room) + emit('joinUserRoom', { + 'on': 'joinUserRoom', + 'userId': user_id + }, room=room) + +@socketio.on('leaveUserRoom') +def on_leave_user_room(data): + data = json.loads(data) + user_id = data['userId'] + room = 'user' + str(user_id) + leave_room(room) + emit('leaveUserRoom', { + 'on': 'leaveUserRoom', + 'userId': user_id + }, room=room) + +## Join project room +@socketio.on('joinProjectRoom') +def on_join_project_room(data): + data = json.loads(data) + username = data['username'] + project_id = data['projectId'] + room = 'project' + str(project_id) + join_room(room) + emit('joinProjectRoom', { + 'on': 'joinProjectRoom', + 'username': username, + 'projectId' : project_id + }, room=room) + +@socketio.on('leaveProjectRoom') +def on_leave_project_room(data): + data = json.loads(data) + username = data['username'] + project_id = data['projectId'] + room = 'project' + str(project_id) + leave_room(room) + emit('leaveProjectRoom', { + 'on': 'leaveProjectRoom', + 'username': username, + 'projectId' : project_id + }, room=room) + +## Project handling ## +@socketio.on('projectCreated') +def handle_project_created(data): + project = json.loads(data) + emit('projectCreated', { + 'on': 'projectCreated', + 'project': project + }, room = 'project' + str(project['id'])) + +@socketio.on('projectChanged') +def handle_project_changed(data): + project = json.loads(data) + emit('projectChanged', { + 'on': 'projectChanged', + 'project': project + }, room = 'project' + str(project['id'])) + +@socketio.on('projectDeleted') +def handle_project_deleted(data): + projectId = json.loads(data) + emit('projectDeleted', { + 'on': 'projectDeleted', + 'projectId': projectId + }, room = 'project' + str(projectId)) + +@socketio.on('collaboratorAdded') +def handle_collaborator_added(data): + collaborator = json.loads(data) + emit('collaboratorAdded', { + 'on': 'collaboratorAdded', + 'collaborator': collaborator + }, room = 'user' + str(collaborator['userId'])) + +@socketio.on('collaboratorChanged') +def handle_collaborator_changed(data): + collaborator = json.loads(data) + emit('collaboratorChanged', { + 'on': 'collaboratorChanged', + 'collaborator': collaborator + }, room = 'project' + str(collaborator['projectId'])) + +## Join open project room +@socketio.on('joinOpenProjectRoom') +def on_join_open_project_room(data): + data = json.loads(data) + username = data['username'] + project_id = data['projectId'] + room = 'openProject' + str(project_id) + join_room(room) + add_project_viewer(project_id, username) + + if not os.path.exists(str(project_id)): + os.mkdir(str(project_id)) + for file in db.get_project_files(project_id): + dir_name = str(project_id) + ('/' + file['parent'] if file['parent'] else '') + if not os.path.exists(dir_name): + os.makedirs(dir_name) + if not file['isFolder']: + f = open(dir_name + '/' + file['name'], "a+") + f.write(file['content']) + f.close() + + emit('joinOpenProjectRoom', { + 'on': 'joinOpenProjectRoom', + 'username': username, + 'viewers': project_viewers[str(project_id)] + }, room=room) + +@socketio.on('leaveOpenProjectRoom') +def on_leave_open_project_room(data): + data = json.loads(data) + username = data['username'] + project_id = data['projectId'] + room = 'openProject' + str(project_id) + leave_room(room) + remove_project_viewer(project_id, username) + + if not project_viewers[str(project_id)] and os.path.exists(str(project_id)): + shutil.rmtree(str(project_id)) + + emit('leaveOpenProjectRoom', { + 'on': 'leaveOpenProjectRoom', + 'username': username, + 'viewers': project_viewers[str(project_id)] + }, room=room) + +## File handling ## +@socketio.on('fileCreated') +def handle_file_created(data): + file = json.loads(data) + emit('fileCreated', { + 'on': 'fileCreated', + 'file': file + }, room = 'openProject' + str(file['projectId'])) + +@socketio.on('fileChanged') +def handle_file_changed(data): + file = json.loads(data) + emit('fileChanged', { + 'on': 'fileChanged', + 'file': file + }, room = 'openProject' + str(file['projectId'])) + + +@socketio.on('fileContentChanged') +def handle_file_content_changed(data): + data = json.loads(data) + project_id = data['projectId'] + file_id = data['fileId'] + patches_text = data['patchesText'] + patches = dmp.patch_fromText(patches_text) + + file = db.get_file(file_id) + file.content = dmp.patch_apply(patches, file.content)[0] + db.update_file(file) + + # Update file locally on server if project is open + open_project_path = str(file.projectId) + '/' + if os.path.exists(open_project_path): + if file.parent: + file_path = open_project_path + file.parent + '/' + file.name + else: + file_path = open_project_path + file.name + if os.path.exists(file_path): + with SimpleFlock(str(file.projectId) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(file_path): + f = open(file_path, 'w') + f.write(file.content) + f.close() + + emit('fileContentChanged', { + 'on': 'fileContentChanged', + 'projectId': project_id, + 'fileId': file_id, + 'patchesText' : patches_text + }, room = 'openProject' + str(project_id), include_self=False) + +@socketio.on('fileDeleted') +def handle_file_deleted(data): + file = json.loads(data) + emit('fileDeleted', { + 'on': 'fileDeleted', + 'file': file + }, room = 'openProject' + str(file['projectId'])) + +## Chat ## +@socketio.on('message') +def handle_message(data): + data = json.loads(data) + + projectId = data['projectId'] + username = data['username'] + message = data['message'] + + author = db.get_user_from_username(username) + new_message = db.Message( + projectId, + author.id, + message + ) + db.add_message(new_message) + + emit('message', { + 'on': 'message', + 'author': db.user_schema.dumps(author), + 'time': new_message.time, + 'message': message + }, room = 'openProject' + str(projectId)) + +## Editor ## +@socketio.on('viewerCursorChanged') +def handle_viewer_cursor_changed(data): + data = json.loads(data) + project_id = data['projectId'] + file_id = data['fileId'] + username = data['username'] + cursor_position = data['cursorPosition'] + + if not str(project_id) in project_viewers.keys() \ + or not has_viewer(username, project_id): + add_project_viewer(project_id, username) + + for viewer in project_viewers[str(project_id)]: + if viewer['username'] == username: + viewer['cursor'] = { + 'fileId': file_id, + 'position': cursor_position + } + emit('viewerCursorChanged', { + 'on': 'viewerCursorChanged', + 'viewer': username, + 'fileId': file_id, + 'cursorPosition': cursor_position + }, room = 'openProject' + str(project_id), include_self=False) \ No newline at end of file diff --git a/backend/2/test.py b/backend/2/test.py new file mode 100644 index 00000000..735bdcc8 --- /dev/null +++ b/backend/2/test.py @@ -0,0 +1 @@ +print('Hello world!') \ No newline at end of file diff --git a/backend/Pipfile b/backend/Pipfile index 2132e152..8f2ac692 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -17,6 +17,7 @@ pyjwt = "*" bson = "*" flask-socketio = "*" diff-match-patch = "*" +simpleflock = "*" [requires] python_version = "3.6" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 4aca4b48..dc7c95c1 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c40b8fdfa182b209d5d3d2e5c2cac67b40cbc77ff95f0ae845d722a57fae950b" + "sha256": "0cbc6707a54e77c6ff91fec870c927dfba819c9aaa97d84191e5ebef71e9cfba" }, "pipfile-spec": 6, "requires": { @@ -96,11 +96,11 @@ }, "flask": { "hashes": [ - "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", - "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" + "sha256:ad7c6d841e64296b962296c2c2dabc6543752985727af86a975072dea984b6f3", + "sha256:e7d32475d1de5facaa55e3958bc4ec66d3762076b074296aa50ef8fdc5b9df61" ], "index": "pypi", - "version": "==1.0.2" + "version": "==1.0.3" }, "flask-bcrypt": { "hashes": [ @@ -134,11 +134,11 @@ }, "flask-socketio": { "hashes": [ - "sha256:8d8f9f104db5ddff1b06ba322d8e158881d590144199c993fe26cf53218c7edd", - "sha256:f9b9c95c82b62381862fd3bc55cea7fcc08e787f6bb63fdc79a2f258df2bdc9a" + "sha256:841dc9636fcf0659223745434b7ceef56f716fede2311ec26cb97af30648ca22", + "sha256:f44b2fc91149c1b2b522c8fd64824d10d4e38d535c2cb76623c22b1e9eeb9de9" ], "index": "pypi", - "version": "==3.3.2" + "version": "==4.0.0" }, "flask-sqlalchemy": { "hashes": [ @@ -233,17 +233,24 @@ }, "python-engineio": { "hashes": [ - "sha256:a89d2cff7f9b447d37c450c2666101d97043b4b22524bf4efaafcb9784c9f7f4", - "sha256:fd14357f0b854de729cc2dba960ceba1d20da973c91f289110807f0bb2f69935" + "sha256:06bd817c0ea50df62e0ea31b89f427ba4ef6f954e09cb0f9036bcfce8193e304", + "sha256:0886831a4ce9f0e8293b927c4b8e4a3dc6dba4a55830abb9c170b12b34d453e9" ], - "version": "==3.5.1" + "version": "==3.6.0" }, "python-socketio": { "hashes": [ - "sha256:aa702157694d55a743fb6f1cc0bd1af58fbfda8a7d71d747d4b12d6dac29cab3", - "sha256:cd225eb0bb3b348665727cfaafad1e455ee13b8fd9ea9ff2691082b88b9a9444" + "sha256:80810f40a5b276937a95d4944229fa9c20ee61963365387549fd943589661a6f", + "sha256:c128d320b23ce3c4323ca5b0e20f602a075ceea56f368f6b9f52bed48d97df71" ], - "version": "==3.1.2" + "version": "==4.0.3" + }, + "simpleflock": { + "hashes": [ + "sha256:0dfebcbe4ae574fdd01466be002dc8cd274ba350b90d11ef910c4bc9766c59eb" + ], + "index": "pypi", + "version": "==0.0.3" }, "six": { "hashes": [ diff --git a/backend/app.py b/backend/app.py index d631cf6b..d2bda151 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,17 +2,20 @@ import os, sys, subprocess, shutil import random import logging +from subprocess import Popen, PIPE +from datetime import datetime from flask import Flask, request, jsonify, Response, json from flask_jwt_extended import JWTManager from flask_jwt_extended import (create_access_token, create_refresh_token, jwt_required, jwt_refresh_token_required, get_jwt_identity) from flask_bcrypt import Bcrypt +from flask_socketio import emit from base64 import b64decode -from datetime import datetime +from simpleflock import SimpleFlock from __init__ import app import database_helper as db -from socketio_helper import socketio +from socketio_helper import socketio, SIMPLE_FLOCK_TIMEOUT flask_bcrypt = Bcrypt(app) jwt = JWTManager(app) @@ -30,7 +33,8 @@ def generate_response(response, status, headers={}): response.status_code = status response.headers = {**{ 'Content-Type': 'application/json', - 'Access-Control-Expose-Headers' : 'X-Auth-Token', + 'Access-Control-Expose-Headers': 'X-Auth-Token', + 'Access-Control-Allow-Origin': '*' }, **headers} return response @@ -265,7 +269,6 @@ def new_project(): else: new_project = db.Project(title, creatorId) db.add_project(new_project) - app.logger.error(new_project.id) if collaborators: for collaborator in collaborators: @@ -389,27 +392,27 @@ def delete_project_request(project_id): @jwt_required def parse_file_request(project_id): if db.get_project(project_id): - os.mkdir(str(project_id)) - for file in db.get_project_files(project_id): - dir_name = str(project_id) + ('/' + file['parent'] if file['parent'] else '') - if not os.path.exists(dir_name): - os.makedirs(dir_name) - if not file['isFolder']: - f = open(dir_name + '/' + file['name'], "a+") - f.write(file['content']) - f.close() - result = subprocess.run(['pylint', '--output-format=json', str(project_id) + '/'],\ - stdout=subprocess.PIPE).stdout.decode('utf-8') - shutil.rmtree(str(project_id)) + if os.path.exists(str(project_id)): + with SimpleFlock(str(project_id) + 'lock', SIMPLE_FLOCK_TIMEOUT): + find_cmd = 'find ' + str(project_id) + '/ -type f -name "*.py"' + find = Popen(find_cmd, shell=True, stdout=PIPE) + files = find.communicate()[0].decode('utf-8') + files = ' '.join(files.splitlines()) + + pylint_cmd = 'pylint --output-format=json ' + files + pylint = Popen(pylint_cmd, shell=True, stdout=PIPE) + result = pylint.communicate()[0].decode('utf-8') + else: + result = '[]' return generate_response(jsonify({ 'success': True, - 'message': 'Successfully parsed file.', + 'message': 'Successfully parsed files.', 'result': result }), OK_STATUS_CODE) else: return generate_response(jsonify({ 'success': False, - 'message': 'No such file' + 'message': 'No such project' }), BAD_REQUEST_STATUS_CODE) ### FILE ### @@ -431,14 +434,30 @@ def create_file_request(): project.edited = datetime.now() db.update_project(project) + # Add file locally on server if project is open + open_project_path = str(new_file.projectId) + '/' + if os.path.exists(open_project_path): + if new_file.parent: + file_path = open_project_path + new_file.parent + '/' + new_file.name + else: + file_path = open_project_path + new_file.name + + if new_file.isFolder: + os.makedirs(file_path) + else: + f = open(file_path, 'w+') + f.write(new_file.content) + f.close() + return generate_response(db.file_schema.jsonify( new_file), OK_STATUS_CODE) @app.route('/api/update_file/<file_id>', methods=['PUT']) @jwt_required def update_file_request(file_id): - # Update file file = request.json['file'] + old_file = db.get_file(file['id']) + updated_file = db.File( file['projectId'], file['name'], @@ -452,6 +471,21 @@ def update_file_request(file_id): project.edited = datetime.now() db.update_project(project) + # Update file locally on server if project is open + open_project_path = str(project.id) + '/' + if old_file.parent: + old_path = open_project_path + old_file.parent + '/' + old_file.name + else: + old_path = open_project_path + old_file.name + if os.path.exists(old_path): + with SimpleFlock(str(project.id) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(old_path): + if file['parent']: + new_path = open_project_path + file['parent'] + '/' + file['name'] + else: + new_path = open_project_path + file['name'] + os.rename(old_path, new_path) + return generate_response(db.file_schema.jsonify( db.update_file(updated_file)), OK_STATUS_CODE) @@ -464,6 +498,20 @@ def delete_file_request(file_id): project.edited = datetime.now() db.update_project(project) + # Remove file locally on server if project is open + open_project_path = str(file.projectId) + '/' + if file.parent: + path = open_project_path + file.parent + '/' + file.name + else: + path = open_project_path + file.name + if os.path.exists(path): + with SimpleFlock(str(file.projectId) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(path): + if file.isFolder: + shutil.rmtree(path) + else: + os.remove(path) + return generate_response(db.file_schema.jsonify( db.delete_file(file_id)), OK_STATUS_CODE) diff --git a/backend/db.sqlite b/backend/db.sqlite index 487f58053c2e48871332198feaef47f8ecd16bd9..8877bab2689f0f7d27a34cbe7a6def330c44a19f 100644 GIT binary patch literal 282624 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 delta 2783 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 KB3>_3N+JN9llEKy diff --git a/backend/socketio_helper.py b/backend/socketio_helper.py index 1b2b7245..6dfe599e 100644 --- a/backend/socketio_helper.py +++ b/backend/socketio_helper.py @@ -1,9 +1,14 @@ -from __init__ import app -import database_helper as db +import os, shutil from flask import json from flask_socketio import SocketIO, send, emit, join_room, leave_room import diff_match_patch as dmp_module +from simpleflock import SimpleFlock + +from __init__ import app +import database_helper as db + +SIMPLE_FLOCK_TIMEOUT = 10 socketio = SocketIO(app) dmp = dmp_module.diff_match_patch() @@ -150,6 +155,18 @@ def on_join_open_project_room(data): room = 'openProject' + str(project_id) join_room(room) add_project_viewer(project_id, username) + + if not os.path.exists(str(project_id)): + os.mkdir(str(project_id)) + for file in db.get_project_files(project_id): + dir_name = str(project_id) + ('/' + file['parent'] if file['parent'] else '') + if not os.path.exists(dir_name): + os.makedirs(dir_name) + if not file['isFolder']: + f = open(dir_name + '/' + file['name'], "a+") + f.write(file['content']) + f.close() + emit('joinOpenProjectRoom', { 'on': 'joinOpenProjectRoom', 'username': username, @@ -164,6 +181,10 @@ def on_leave_open_project_room(data): room = 'openProject' + str(project_id) leave_room(room) remove_project_viewer(project_id, username) + + if not project_viewers[str(project_id)] and os.path.exists(str(project_id)): + shutil.rmtree(str(project_id)) + emit('leaveOpenProjectRoom', { 'on': 'leaveOpenProjectRoom', 'username': username, @@ -200,6 +221,20 @@ def handle_file_content_changed(data): file.content = dmp.patch_apply(patches, file.content)[0] db.update_file(file) + # Update file locally on server if project is open + open_project_path = str(file.projectId) + '/' + if os.path.exists(open_project_path): + if file.parent: + file_path = open_project_path + file.parent + '/' + file.name + else: + file_path = open_project_path + file.name + if os.path.exists(file_path): + with SimpleFlock(str(file.projectId) + 'lock', SIMPLE_FLOCK_TIMEOUT): + if os.path.exists(file_path): + f = open(file_path, 'w') + f.write(file.content) + f.close() + emit('fileContentChanged', { 'on': 'fileContentChanged', 'projectId': project_id, -- GitLab