class DocumentWS(BaseWebSocketHandler): sessions = dict() def open(self, document_id): print('Websocket opened') current_user = self.get_current_user() self.user_info = SessionUserInfo() doc_db, can_access = self.user_info.init_access( document_id, current_user) if can_access: if doc_db.id in DocumentWS.sessions: self.doc = DocumentWS.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 print("id when opened %s" % self.id) else: self.id = 0 self.doc = dict() self.doc['db'] = doc_db self.doc['participants'] = dict() self.doc['last_diffs'] = json_decode(doc_db.last_diffs) self.doc['comments'] = json_decode(doc_db.comments) self.doc['settings'] = json_decode(doc_db.settings) self.doc['contents'] = json_decode(doc_db.contents) self.doc['metadata'] = json_decode(doc_db.metadata) self.doc['version'] = doc_db.version self.doc['diff_version'] = doc_db.diff_version self.doc['comment_version'] = doc_db.comment_version self.doc['title'] = doc_db.title self.doc['id'] = doc_db.id DocumentWS.sessions[doc_db.id] = self.doc self.doc['participants'][self.id] = self response = dict() response['type'] = 'welcome' self.write_message(response) def confirm_diff(self, request_id): response = dict() response['type'] = 'confirm_diff' response['request_id'] = request_id self.write_message(response) def send_document(self): response = dict() response['type'] = 'document_data' response['document'] = dict() response['document']['id'] = self.doc['id'] response['document']['version'] = self.doc['version'] if self.doc['diff_version'] < self.doc['version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] response['document']['title'] = self.doc['title'] response['document']['contents'] = self.doc['contents'] response['document']['metadata'] = self.doc['metadata'] response['document']['settings'] = self.doc['settings'] document_owner = self.doc['db'].owner access_rights = get_accessrights( AccessRight.objects.filter( document__owner=document_owner)) response['document']['access_rights'] = access_rights # TODO: switch on filtering when choose workflow and have UI for # assigning roles to users # filtered_comments = filter_comments_by_role( # DocumentWS.sessions[self.user_info.document_id]["comments"], # access_rights, # 'editing', # self.user_info # ) response['document']['comments'] = self.doc["comments"] # response['document']['comments'] = filtered_comments response['document']['comment_version'] = self.doc["comment_version"] response['document']['access_rights'] = get_accessrights( AccessRight.objects.filter(document__owner=document_owner)) response['document']['owner'] = dict() response['document']['owner']['id'] = document_owner.id response['document']['owner']['name'] = document_owner.readable_name response['document']['owner'][ 'avatar'] = avatar_url(document_owner, 80) response['document']['owner']['team_members'] = [] for team_member in document_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['avatar'] = avatar_url(team_member.member, 80) response['document']['owner']['team_members'].append(tm_object) response['document_values'] = dict() response['document_values']['is_owner'] = self.user_info.is_owner response['document_values']['rights'] = self.user_info.access_rights if self.doc['version'] > self.doc['diff_version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] elif self.doc['diff_version'] > self.doc['version']: needed_diffs = self.doc['diff_version'] - self.doc['version'] response['document_values']['last_diffs'] = self.doc[ "last_diffs"][-needed_diffs:] else: response['document_values']['last_diffs'] = [] if self.user_info.is_new: response['document_values']['is_new'] = True if not self.user_info.is_owner: response['user'] = dict() response['user']['id'] = self.user_info.user.id response['user']['name'] = self.user_info.user.readable_name response['user']['avatar'] = avatar_url(self.user_info.user, 80) # if self.doc['in_control'] == self.id: # response['document_values']['control']=True response['document_values']['session_id'] = self.id self.write_message(response) def on_message(self, message): if self.user_info.document_id not in DocumentWS.sessions: print('receiving message for closed document') return parsed = json_decode(message) print(parsed["type"]) if parsed["type"] == 'get_document': self.send_document() elif parsed["type"] == 'participant_update': self.handle_participant_update() elif parsed["type"] == 'chat': self.handle_chat(parsed) elif parsed["type"] == 'check_diff_version': self.check_diff_version(parsed) elif parsed["type"] == 'selection_change': self.handle_selection_change(message, parsed) elif ( parsed["type"] == 'update_document' and self.can_update_document() ): self.handle_document_update(parsed) elif parsed["type"] == 'update_title' and self.can_update_document(): self.handle_title_update(parsed) elif parsed["type"] == 'setting_change' and self.can_update_document(): self.handle_settings_change(message, parsed) elif parsed["type"] == 'diff' and self.can_update_document(): self.handle_diff(message, parsed) def update_document(self, changes): if changes['version'] == self.doc['version']: # Document hasn't changed, return. return elif ( changes['version'] > self.doc['diff_version'] or changes['version'] < self.doc['version'] ): # The version number is too high. Possibly due to server restart. # Do not accept it, and send a document instead. self.send_document() return else: # The saved version does not contain all accepted diffs, so we keep # the remaining ones + 1000 remaining_diffs = 1000 + \ self.doc['diff_version'] - changes['version'] self.doc['last_diffs'] = self.doc['last_diffs'][-remaining_diffs:] self.doc['title'] = changes['title'] self.doc['contents'] = changes['contents'] self.doc['metadata'] = changes['metadata'] self.doc['version'] = changes['version'] def update_title(self, title): self.doc['title'] = title def update_comments(self, comments_updates): for cd in comments_updates: id = str(cd["id"]) if cd["type"] == "create": del cd["type"] self.doc["comments"][id] = cd elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "review:isMajor" in cd: self.doc["comments"][id][ "review:isMajor"] = cd["review:isMajor"] elif cd["type"] == "add_answer": comment_id = str(cd["commentId"]) if "answers" not in self.doc["comments"][comment_id]: self.doc["comments"][comment_id]["answers"] = [] del cd["type"] self.doc["comments"][comment_id]["answers"].append(cd) elif cd["type"] == "delete_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: self.doc["comments"][comment_id][ "answers"].remove(answer) elif cd["type"] == "update_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: answer["answer"] = cd["answer"] self.doc['comment_version'] += 1 def handle_participant_update(self): DocumentWS.send_participant_list(self.user_info.document_id) def handle_document_update(self, parsed): self.update_document(parsed["document"]) DocumentWS.save_document(self.user_info.document_id) message = { "type": 'check_hash', "diff_version": parsed["document"]["version"], "hash": parsed["document"]["hash"] } DocumentWS.send_updates(message, self.user_info.document_id, self.id) def handle_title_update(self, parsed): self.update_title(parsed["title"]) DocumentWS.save_document(self.user_info.document_id) def handle_chat(self, parsed): chat = { "id": str(uuid.uuid4()), "body": parsed['body'], "from": self.user_info.user.id, "type": 'chat' } DocumentWS.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, message, parsed): if self.user_info.document_id in DocumentWS.sessions and parsed[ "diff_version"] == self.doc['diff_version']: DocumentWS.send_updates( message, self.user_info.document_id, self.id) def handle_settings_change(self, message, parsed): DocumentWS.sessions[ self.user_info.document_id]['settings'][ parsed['variable']] = parsed['value'] DocumentWS.send_updates(message, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, parsed_diffs): allowed_operations = ['addMark', 'removeMark'] only_comment = True for diff in parsed_diffs: if not (diff['type'] in allowed_operations and diff[ 'param']['_'] == 'comment'): only_comment = False return only_comment def handle_diff(self, message, parsed): if ( self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(parsed['diff']) ): print( ( 'received non-comment diff from comment-only ' 'collaborator. Discarding.' ) ) return if parsed["diff_version"] == self.doc['diff_version'] and parsed[ "comment_version"] == self.doc['comment_version']: self.doc["last_diffs"].extend(parsed["diff"]) self.doc['diff_version'] += len(parsed["diff"]) self.update_comments(parsed["comments"]) self.confirm_diff(parsed["request_id"]) DocumentWS.send_updates( message, self.user_info.document_id, self.id) elif parsed["diff_version"] != self.doc['diff_version']: if parsed["diff_version"] < ( self.doc['diff_version'] - len(self.doc["last_diffs"])): print('unfixable') # Client has a version that is too old self.send_document() elif parsed["diff_version"] < self.doc['diff_version']: print("can fix it") number_requested_diffs = self.doc[ 'diff_version'] - parsed["diff_version"] response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], "reject_request_id": parsed["request_id"], } self.write_message(response) else: print('unfixable') # Client has a version that is too old self.send_document() else: print('comment_version incorrect!') print(parsed["comment_version"]) print(self.doc['comment_version']) def check_diff_version(self, parsed): pdv = parsed["diff_version"] ddv = self.doc['diff_version'] if pdv == ddv: response = { "type": "confirm_diff_version", "diff_version": pdv, } self.write_message(response) return elif pdv + len(self.doc["last_diffs"]) >= ddv: number_requested_diffs = ddv - pdv response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], } self.write_message(response) return else: print('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def on_close(self): print('Websocket closing') if ( hasattr(self.user_info, 'document_id') and self.user_info.document_id in DocumentWS.sessions and hasattr(self, 'id') and self.id in DocumentWS.sessions[ self.user_info.document_id ]['participants'] ): del self.doc['participants'][self.id] if len(self.doc['participants'].keys()) == 0: DocumentWS.save_document(self.user_info.document_id) del DocumentWS.sessions[self.user_info.document_id] print("noone left") @classmethod def send_participant_list(cls, document_id): if document_id in DocumentWS.sessions: participant_list = [] for waiter in cls.sessions[document_id]['participants'].keys(): participant_list.append({ 'session_id': waiter, 'id': cls.sessions[document_id]['participants'][ waiter ].user_info.user.id, 'name': cls.sessions[document_id]['participants'][ waiter ].user_info.user.readable_name, 'avatar': avatar_url(cls.sessions[document_id][ 'participants' ][waiter].user_info.user, 80) }) message = { "participant_list": participant_list, "type": 'connections' } DocumentWS.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None): info("sending message to %d waiters", len(cls.sessions[document_id])) for waiter in cls.sessions[document_id]['participants'].keys(): if cls.sessions[document_id][ 'participants'][waiter].id != sender_id: try: cls.sessions[document_id]['participants'][ waiter].write_message(message) except WebSocketClosedError: error("Error sending message", exc_info=True) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] doc_db.title = doc['title'] doc_db.version = doc['version'] doc_db.diff_version = doc['diff_version'] doc_db.comment_version = doc['comment_version'] doc_db.contents = json_encode(doc['contents']) doc_db.metadata = json_encode(doc['metadata']) doc_db.settings = json_encode(doc['settings']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) print('saving document #' + str(doc_db.id)) print('version ' + str(doc_db.version)) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)
class DocumentWS(BaseWebSocketHandler): sessions = dict() def open(self, document_id): print('Websocket opened') current_user = self.get_current_user() self.user_info = SessionUserInfo() doc_db, can_access = self.user_info.init_access( document_id, current_user) if can_access: if doc_db.id in DocumentWS.sessions: self.doc = DocumentWS.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 print "id when opened %s" % self.id else: self.id = 0 self.doc = dict() self.doc['db'] = doc_db self.doc['participants'] = dict() self.doc['last_diffs'] = json_decode(doc_db.last_diffs) self.doc['comments'] = json_decode(doc_db.comments) self.doc['settings'] = json_decode(doc_db.settings) self.doc['contents'] = json_decode(doc_db.contents) self.doc['metadata'] = json_decode(doc_db.metadata) self.doc['version'] = doc_db.version self.doc['diff_version'] = doc_db.diff_version self.doc['comment_version'] = doc_db.comment_version self.doc['title'] = doc_db.title self.doc['id'] = doc_db.id DocumentWS.sessions[doc_db.id] = self.doc self.doc['participants'][self.id] = self response = dict() response['type'] = 'welcome' self.write_message(response) def confirm_diff(self, request_id): response = dict() response['type'] = 'confirm_diff' response['request_id'] = request_id self.write_message(response) def send_document(self): response = dict() response['type'] = 'document_data' response['document'] = dict() response['document']['id'] = self.doc['id'] response['document']['version'] = self.doc['version'] if self.doc['diff_version'] < self.doc['version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] response['document']['title'] = self.doc['title'] response['document']['contents'] = self.doc['contents'] response['document']['metadata'] = self.doc['metadata'] response['document']['settings'] = self.doc['settings'] document_owner = self.doc['db'].owner access_rights = get_accessrights( AccessRight.objects.filter(document__owner=document_owner)) response['document']['access_rights'] = access_rights # TODO: switch on filtering when choose workflow and have UI for # assigning roles to users # filtered_comments = filter_comments_by_role( # DocumentWS.sessions[self.user_info.document_id]["comments"], # access_rights, # 'editing', # self.user_info # ) response['document']['comments'] = self.doc["comments"] # response['document']['comments'] = filtered_comments response['document']['comment_version'] = self.doc["comment_version"] response['document']['access_rights'] = get_accessrights( AccessRight.objects.filter(document__owner=document_owner)) response['document']['owner'] = dict() response['document']['owner']['id'] = document_owner.id response['document']['owner']['name'] = document_owner.readable_name response['document']['owner']['avatar'] = avatar_url( document_owner, 80) response['document']['owner']['team_members'] = [] for team_member in document_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['avatar'] = avatar_url(team_member.member, 80) response['document']['owner']['team_members'].append(tm_object) response['document_values'] = dict() response['document_values']['is_owner'] = self.user_info.is_owner response['document_values']['rights'] = self.user_info.access_rights if self.doc['version'] > self.doc['diff_version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] elif self.doc['diff_version'] > self.doc['version']: needed_diffs = self.doc['diff_version'] - self.doc['version'] response['document_values']['last_diffs'] = self.doc["last_diffs"][ -needed_diffs:] else: response['document_values']['last_diffs'] = [] if self.user_info.is_new: response['document_values']['is_new'] = True if not self.user_info.is_owner: response['user'] = dict() response['user']['id'] = self.user_info.user.id response['user']['name'] = self.user_info.user.readable_name response['user']['avatar'] = avatar_url(self.user_info.user, 80) # if self.doc['in_control'] == self.id: # response['document_values']['control']=True response['document_values']['session_id'] = self.id self.write_message(response) def on_message(self, message): if self.user_info.document_id not in DocumentWS.sessions: print('receiving message for closed document') return parsed = json_decode(message) print(parsed["type"]) if parsed["type"] == 'get_document': self.send_document() elif parsed["type"] == 'participant_update': self.handle_participant_update() elif parsed["type"] == 'chat': self.handle_chat(parsed) elif parsed["type"] == 'check_diff_version': self.check_diff_version(parsed) elif parsed["type"] == 'selection_change': self.handle_selection_change(message, parsed) elif (parsed["type"] == 'update_document' and self.can_update_document()): self.handle_document_update(parsed) elif parsed["type"] == 'update_title' and self.can_update_document(): self.handle_title_update(parsed) elif parsed["type"] == 'setting_change' and self.can_update_document(): self.handle_settings_change(message, parsed) elif parsed["type"] == 'diff' and self.can_update_document(): self.handle_diff(message, parsed) def update_document(self, changes): if changes['version'] == self.doc['version']: # Document hasn't changed, return. return elif (changes['version'] > self.doc['diff_version'] or changes['version'] < self.doc['version']): # The version number is too high. Possibly due to server restart. # Do not accept it, and send a document instead. self.send_document() return else: # The saved version does not contain all accepted diffs, so we keep # the remaining ones + 1000 remaining_diffs = 1000 + \ self.doc['diff_version'] - changes['version'] self.doc['last_diffs'] = self.doc['last_diffs'][-remaining_diffs:] self.doc['title'] = changes['title'] self.doc['contents'] = changes['contents'] self.doc['metadata'] = changes['metadata'] self.doc['version'] = changes['version'] def update_title(self, title): self.doc['title'] = title def update_comments(self, comments_updates): for cd in comments_updates: id = str(cd["id"]) if cd["type"] == "create": del cd["type"] self.doc["comments"][id] = cd elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "review:isMajor" in cd: self.doc["comments"][id]["review:isMajor"] = cd[ "review:isMajor"] elif cd["type"] == "add_answer": comment_id = str(cd["commentId"]) if "answers" not in self.doc["comments"][comment_id]: self.doc["comments"][comment_id]["answers"] = [] del cd["type"] self.doc["comments"][comment_id]["answers"].append(cd) elif cd["type"] == "delete_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: self.doc["comments"][comment_id]["answers"].remove( answer) elif cd["type"] == "update_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: answer["answer"] = cd["answer"] self.doc['comment_version'] += 1 def handle_participant_update(self): DocumentWS.send_participant_list(self.user_info.document_id) def handle_document_update(self, parsed): self.update_document(parsed["document"]) DocumentWS.save_document(self.user_info.document_id) message = { "type": 'check_hash', "diff_version": parsed["document"]["version"], "hash": parsed["document"]["hash"] } DocumentWS.send_updates(message, self.user_info.document_id, self.id) def handle_title_update(self, parsed): self.update_title(parsed["title"]) DocumentWS.save_document(self.user_info.document_id) def handle_chat(self, parsed): chat = { "id": str(uuid.uuid4()), "body": parsed['body'], "from": self.user_info.user.id, "type": 'chat' } DocumentWS.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, message, parsed): if self.user_info.document_id in DocumentWS.sessions and parsed[ "diff_version"] == self.doc['diff_version']: DocumentWS.send_updates(message, self.user_info.document_id, self.id) def handle_settings_change(self, message, parsed): DocumentWS.sessions[self.user_info.document_id]['settings'][ parsed['variable']] = parsed['value'] DocumentWS.send_updates(message, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, parsed_diffs): allowed_operations = ['addMark', 'removeMark'] only_comment = True for diff in parsed_diffs: if not (diff['type'] in allowed_operations and diff['param']['_'] == 'comment'): only_comment = False return only_comment def handle_diff(self, message, parsed): if (self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(parsed['diff'])): print(('received non-comment diff from comment-only ' 'collaborator. Discarding.')) return if parsed["diff_version"] == self.doc['diff_version'] and parsed[ "comment_version"] == self.doc['comment_version']: self.doc["last_diffs"].extend(parsed["diff"]) self.doc['diff_version'] += len(parsed["diff"]) self.update_comments(parsed["comments"]) self.confirm_diff(parsed["request_id"]) DocumentWS.send_updates(message, self.user_info.document_id, self.id) elif parsed["diff_version"] != self.doc['diff_version']: if parsed["diff_version"] < (self.doc['diff_version'] - len(self.doc["last_diffs"])): print('unfixable') # Client has a version that is too old self.send_document() elif parsed["diff_version"] < self.doc['diff_version']: print "can fix it" number_requested_diffs = self.doc['diff_version'] - parsed[ "diff_version"] response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], "reject_request_id": parsed["request_id"], } self.write_message(response) else: print('unfixable') # Client has a version that is too old self.send_document() else: print('comment_version incorrect!') print(parsed["comment_version"]) print(self.doc['comment_version']) def check_diff_version(self, parsed): pdv = parsed["diff_version"] ddv = self.doc['diff_version'] if pdv == ddv: response = { "type": "confirm_diff_version", "diff_version": pdv, } self.write_message(response) return elif pdv + len(self.doc["last_diffs"]) >= ddv: number_requested_diffs = ddv - pdv response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], } self.write_message(response) return else: print('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def on_close(self): print('Websocket closing') if (hasattr(self.user_info, 'document_id') and self.user_info.document_id in DocumentWS.sessions and hasattr(self, 'id') and self.id in DocumentWS.sessions[ self.user_info.document_id]['participants']): del self.doc['participants'][self.id] if len(self.doc['participants'].keys()) == 0: DocumentWS.save_document(self.user_info.document_id) del DocumentWS.sessions[self.user_info.document_id] print "noone left" @classmethod def send_participant_list(cls, document_id): if document_id in DocumentWS.sessions: participant_list = [] for waiter in cls.sessions[document_id]['participants'].keys(): participant_list.append({ 'session_id': waiter, 'id': cls.sessions[document_id]['participants'] [waiter].user_info.user.id, 'name': cls.sessions[document_id]['participants'] [waiter].user_info.user.readable_name, 'avatar': avatar_url( cls.sessions[document_id]['participants'] [waiter].user_info.user, 80) }) message = { "participant_list": participant_list, "type": 'connections' } DocumentWS.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None): info("sending message to %d waiters", len(cls.sessions[document_id])) for waiter in cls.sessions[document_id]['participants'].keys(): if cls.sessions[document_id]['participants'][ waiter].id != sender_id: try: cls.sessions[document_id]['participants'][ waiter].write_message(message) except WebSocketClosedError: error("Error sending message", exc_info=True) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] doc_db.title = doc['title'] doc_db.version = doc['version'] doc_db.diff_version = doc['diff_version'] doc_db.comment_version = doc['comment_version'] doc_db.contents = json_encode(doc['contents']) doc_db.metadata = json_encode(doc['metadata']) doc_db.settings = json_encode(doc['settings']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) print('saving document #' + str(doc_db.id)) print('version ' + str(doc_db.version)) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)
class WebSocket(BaseWebSocketHandler): sessions = dict() def open(self, arg): super().open(arg) if len(self.args) < 2: self.access_denied() return self.document_id = int(self.args[0]) def confirm_diff(self, rid): response = {'type': 'confirm_diff', 'rid': rid} self.send_message(response) def subscribe_doc(self, connection_count=0): self.user_info = SessionUserInfo(self.user) doc_db, can_access = self.user_info.init_access(self.document_id) if not can_access or float(doc_db.doc_version) != FW_DOCUMENT_VERSION: self.access_denied() return if (doc_db.id in WebSocket.sessions and len(WebSocket.sessions[doc_db.id]['participants']) > 0): logger.debug("Serving already opened file") self.doc = WebSocket.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 self.doc['participants'][self.id] = self logger.debug("id when opened %s" % self.id) else: logger.debug("Opening file") self.id = 0 self.doc = { 'db': doc_db, 'participants': { 0: self }, 'last_diffs': json_decode(doc_db.last_diffs), 'comments': json_decode(doc_db.comments), 'bibliography': json_decode(doc_db.bibliography), 'contents': json_decode(doc_db.contents), 'version': doc_db.version, 'title': doc_db.title, 'id': doc_db.id, 'template': { 'id': doc_db.template.id, 'definition': json_decode(doc_db.template.definition) } } WebSocket.sessions[doc_db.id] = self.doc self.send_message({'type': 'subscribed'}) if connection_count < 1: self.send_styles() self.send_document() if self.can_communicate(): self.handle_participant_update() def send_styles(self): doc_db = self.doc['db'] response = dict() response['type'] = 'styles' serializer = PythonWithURLSerializer() export_temps = serializer.serialize( doc_db.template.exporttemplate_set.all(), fields=['file_type', 'template_file', 'title']) document_styles = serializer.serialize( doc_db.template.documentstyle_set.all(), use_natural_foreign_keys=True, fields=['title', 'slug', 'contents', 'documentstylefile_set']) response['styles'] = { 'export_templates': [obj['fields'] for obj in export_temps], 'document_styles': [obj['fields'] for obj in document_styles] } self.send_message(response) def send_document(self): response = dict() response['type'] = 'doc_data' doc_owner = self.doc['db'].owner response['doc_info'] = { 'id': self.doc['id'], 'is_owner': self.user_info.is_owner, 'access_rights': self.user_info.access_rights, 'owner': { 'id': doc_owner.id, 'name': doc_owner.readable_name, 'username': doc_owner.username, 'avatar': get_user_avatar_url(doc_owner), 'team_members': [] } } response['doc'] = { 'v': self.doc['version'], 'contents': self.doc['contents'], 'bibliography': self.doc['bibliography'], 'template': self.doc['template'], 'images': {} } response['time'] = int(time()) * 1000 for dimage in DocumentImage.objects.filter(document_id=self.doc['id']): image = dimage.image field_obj = { 'id': image.id, 'title': dimage.title, 'image': image.image.url, 'file_type': image.file_type, 'added': mktime(image.added.timetuple()) * 1000, 'checksum': image.checksum, 'cats': [] } if image.thumbnail: field_obj['thumbnail'] = image.thumbnail.url field_obj['height'] = image.height field_obj['width'] = image.width response['doc']['images'][image.id] = field_obj if self.user_info.access_rights == 'read-without-comments': response['doc']['comments'] = [] elif self.user_info.access_rights == 'review': # Reviewer should only get his/her own comments filtered_comments = {} for key, value in list(self.doc["comments"].items()): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] for team_member in doc_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['username'] = team_member.member.get_username() tm_object['avatar'] = get_user_avatar_url(team_member.member) response['doc_info']['owner']['team_members'].append(tm_object) response['doc_info']['session_id'] = self.id self.send_message(response) def reject_message(self, message): if (message["type"] == "diff"): self.send_message({'type': 'reject_diff', 'rid': message['rid']}) def handle_message(self, message): if message["type"] == 'subscribe': connection_count = 0 if 'connection' in message: connection_count = message['connection'] self.subscribe_doc(connection_count) return if self.user_info.document_id not in WebSocket.sessions: logger.debug('receiving message for closed document') return if message["type"] == 'get_document': self.send_document() elif (message["type"] == 'participant_update' and self.can_communicate()): self.handle_participant_update() elif message["type"] == 'chat' and self.can_communicate(): self.handle_chat(message) elif message["type"] == 'check_version': self.check_version(message) elif message["type"] == 'selection_change': self.handle_selection_change(message) elif message["type"] == 'diff' and self.can_update_document(): self.handle_diff(message) def update_bibliography(self, bibliography_updates): for bu in bibliography_updates: if "id" not in bu: continue id = bu["id"] if bu["type"] == "update": self.doc["bibliography"][id] = bu["reference"] elif bu["type"] == "delete": del self.doc["bibliography"][id] def update_images(self, image_updates): for iu in image_updates: if "id" not in iu: continue id = iu["id"] if iu["type"] == "update": # Ensure that access rights exist if not UserImage.objects.filter( image__id=id, owner=self.user_info.user).exists(): continue doc_image = DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id) if doc_image.exists(): doc_image.title = iu["image"]["title"] doc_image.save() else: DocumentImage.objects.create(document_id=self.doc["id"], image_id=id, title=iu["image"]["title"]) elif iu["type"] == "delete": DocumentImage.objects.filter(document_id=self.doc["id"], image_id=id).delete() for image in Image.objects.filter(id=id): if image.is_deletable(): image.delete() def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: if "id" not in cd: # ignore continue id = cd["id"] if cd["type"] == "create": self.doc["comments"][id] = { "user": cd["user"], "username": cd["username"], "assignedUser": cd["assignedUser"], "assignedUsername": cd["assignedUsername"], "date": cd["date"], "comment": cd["comment"], "isMajor": cd["isMajor"], "resolved": cd["resolved"], } elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "isMajor" in cd: self.doc["comments"][id]["isMajor"] = cd["isMajor"] if "assignedUser" in cd and "assignedUsername" in cd: self.doc["comments"][id]["assignedUser"] = cd[ "assignedUser"] self.doc["comments"][id]["assignedUsername"] = cd[ "assignedUsername"] if "resolved" in cd: self.doc["comments"][id]["resolved"] = cd["resolved"] elif cd["type"] == "add_answer": if "answers" not in self.doc["comments"][id]: self.doc["comments"][id]["answers"] = [] self.doc["comments"][id]["answers"].append({ "id": cd["answerId"], "user": cd["user"], "username": cd["username"], "date": cd["date"], "answer": cd["answer"] }) elif cd["type"] == "delete_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: self.doc["comments"][id]["answers"].remove(answer) elif cd["type"] == "update_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: answer["answer"] = cd["answer"] def handle_participant_update(self): WebSocket.send_participant_list(self.user_info.document_id) def handle_chat(self, message): chat = { "id": str(uuid.uuid4()), "body": message['body'], "from": self.user_info.user.id, "type": 'chat' } WebSocket.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, message): if self.user_info.document_id in WebSocket.sessions and message[ "v"] == self.doc['version']: WebSocket.send_updates(message, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, message): allowed_operations = ['addMark', 'removeMark'] only_comment = True if "ds" in message: # ds = document steps for step in message["ds"]: if not (step['stepType'] in allowed_operations and step['mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, message): pv = message["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if (self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(message)): logger.error(('received non-comment diff from comment-only ' 'collaborator. Discarding.')) return if pv == dv: self.doc["last_diffs"].append(message) # Only keep the last 1000 diffs self.doc["last_diffs"] = self.doc["last_diffs"][-1000:] self.doc['version'] += 1 if "jd" in message: # jd = json diff try: apply_patch(self.doc['contents'], message["jd"], True) except (JsonPatchConflict, JsonPointerException): logger.exception("Cannot apply json diff.") logger.error(json_encode(message)) logger.error(json_encode(self.doc['contents'])) self.send_document() # The json diff is only needed by the python backend which does # not understand the steps. It can therefore be removed before # broadcast to other clients. del message["jd"] if "ti" in message: # ti = title self.doc["title"] = message["ti"] if "cu" in message: # cu = comment updates self.update_comments(message["cu"]) if "bu" in message: # bu = bibliography updates self.update_bibliography(message["bu"]) if "iu" in message: # iu = image updates self.update_images(message["iu"]) if self.doc['version'] % 10 == 0: WebSocket.save_document(self.user_info.document_id) self.confirm_diff(message["rid"]) WebSocket.send_updates(message, self.user_info.document_id, self.id, self.user_info.user.id) elif pv < dv: if pv + len(self.doc["last_diffs"]) >= dv: # We have enough last_diffs stored to fix it. logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) else: logger.debug('unfixable') # Client has a version that is too old to be fixed self.send_document() else: # Client has a higher version than server. Something is fishy! logger.debug('unfixable') def check_version(self, message): pv = message["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if pv == dv: response = { "type": "confirm_version", "v": pv, } self.send_message(response) return elif pv + len(self.doc["last_diffs"]) >= dv: logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) return else: logger.debug('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): logger.debug('Websocket closing') if (hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in WebSocket.sessions and hasattr(self, 'id') and self.id in WebSocket.sessions[ self.user_info.document_id]['participants']): del self.doc['participants'][self.id] if len(self.doc['participants']) == 0: WebSocket.save_document(self.user_info.document_id) del WebSocket.sessions[self.user_info.document_id] logger.debug("noone left") else: WebSocket.send_participant_list(self.user_info.document_id) @classmethod def send_participant_list(cls, document_id): if document_id in WebSocket.sessions: participant_list = [] for session_id, waiter in list( cls.sessions[document_id]['participants'].items()): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': get_user_avatar_url(waiter.user_info.user) }) message = { "participant_list": participant_list, "type": 'connections' } WebSocket.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): logger.debug("Sending message to %d waiters", len(cls.sessions[document_id]['participants'])) for waiter in list(cls.sessions[document_id]['participants'].values()): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to send the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif (access_rights == 'review' and user_id != waiter.user_info.user.id): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to sned the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif (message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE): continue elif (message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id): continue waiter.send_message(message) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] if doc_db.version == doc['version']: return doc_db.title = doc['title'][-255:] doc_db.version = doc['version'] doc_db.contents = json_encode(doc['contents']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) doc_db.bibliography = json_encode(doc['bibliography']) logger.debug('saving document # %d' % doc_db.id) logger.debug('version %d' % doc_db.version) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)
class WebSocket(BaseWebSocketHandler): sessions = dict() def open(self, arg): logger.debug('Websocket opened') response = dict() current_user = self.get_current_user() args = arg.split("/") self.messages = {'server': 0, 'client': 0, 'last_ten': []} if len(args) < 3 or current_user is None: response['type'] = 'access_denied' self.id = 0 self.send_message(response) IOLoop.current().add_callback(self.do_close) return document_id = int(args[0]) connection_count = int(args[1]) self.user_info = SessionUserInfo() doc_db, can_access = self.user_info.init_access( document_id, current_user) if not can_access or doc_db.doc_version != FW_DOCUMENT_VERSION: response['type'] = 'access_denied' self.id = 0 self.send_message(response) return response['type'] = 'welcome' if (doc_db.id in WebSocket.sessions and len(WebSocket.sessions[doc_db.id]['participants']) > 0): logger.debug("Serving already opened file") self.doc = WebSocket.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 self.doc['participants'][self.id] = self logger.debug("id when opened %s" % self.id) else: logger.debug("Opening file") self.id = 0 self.doc = { 'db': doc_db, 'participants': { 0: self }, 'last_diffs': json_decode(doc_db.last_diffs), 'comments': json_decode(doc_db.comments), 'bibliography': json_decode(doc_db.bibliography), 'contents': json_decode(doc_db.contents), 'version': doc_db.version, 'title': doc_db.title, 'id': doc_db.id } WebSocket.sessions[doc_db.id] = self.doc serializer = PythonWithURLSerializer() export_temps = serializer.serialize(ExportTemplate.objects.all()) document_styles = serializer.serialize(DocumentStyle.objects.all(), use_natural_foreign_keys=True) cite_styles = serializer.serialize(CitationStyle.objects.all()) cite_locales = serializer.serialize(CitationLocale.objects.all()) response['styles'] = { 'export_templates': [obj['fields'] for obj in export_temps], 'document_styles': [obj['fields'] for obj in document_styles], 'citation_styles': [obj['fields'] for obj in cite_styles], 'citation_locales': [obj['fields'] for obj in cite_locales], } self.send_message(response) if connection_count < 1: self.send_document() if self.can_communicate(): self.handle_participant_update() def do_close(self): self.close() def confirm_diff(self, rid): response = {'type': 'confirm_diff', 'rid': rid} self.send_message(response) def send_document(self): response = dict() response['type'] = 'doc_data' doc_owner = self.doc['db'].owner response['doc_info'] = { 'id': self.doc['id'], 'is_owner': self.user_info.is_owner, 'access_rights': self.user_info.access_rights, 'owner': { 'id': doc_owner.id, 'name': doc_owner.readable_name, 'avatar': avatar_url(doc_owner, 80), 'team_members': [] } } response['doc'] = { 'v': self.doc['version'], 'contents': self.doc['contents'], 'bibliography': self.doc['bibliography'], 'images': {} } for dimage in DocumentImage.objects.filter(document_id=self.doc['id']): image = dimage.image field_obj = { 'id': image.id, 'title': dimage.title, 'image': image.image.url, 'file_type': image.file_type, 'added': mktime(image.added.timetuple()) * 1000, 'checksum': image.checksum, 'cats': [] } if image.thumbnail: field_obj['thumbnail'] = image.thumbnail.url field_obj['height'] = image.height field_obj['width'] = image.width response['doc']['images'][image.id] = field_obj if self.user_info.access_rights == 'read-without-comments': response['doc']['comments'] = [] elif self.user_info.access_rights == 'review': # Reviewer should only get his/her own comments filtered_comments = {} for key, value in self.doc["comments"].items(): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] for team_member in doc_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['avatar'] = avatar_url(team_member.member, 80) response['doc_info']['owner']['team_members'].append(tm_object) collaborators = get_accessrights( AccessRight.objects.filter(document__owner=doc_owner)) response['doc_info']['collaborators'] = collaborators if self.user_info.is_owner: the_user = self.user_info.user response['doc_info']['owner']['email'] = the_user.email response['doc_info']['owner']['username'] = the_user.username response['doc_info']['owner']['first_name'] = the_user.first_name response['doc_info']['owner']['last_name'] = the_user.last_name else: the_user = self.user_info.user response['user'] = dict() response['user']['id'] = the_user.id response['user']['name'] = the_user.readable_name response['user']['avatar'] = avatar_url(the_user, 80) response['user']['email'] = the_user.email response['user']['username'] = the_user.username response['user']['first_name'] = the_user.first_name response['user']['last_name'] = the_user.last_name response['doc_info']['session_id'] = self.id self.send_message(response) def on_message(self, message): if self.user_info.document_id not in WebSocket.sessions: logger.debug('receiving message for closed document') return parsed = json_decode(message) if parsed["type"] == 'request_resend': self.resend_messages(parsed["from"]) return if 'c' not in parsed and 's' not in parsed: self.write_message({'type': 'access_denied'}) # Message doesn't contain needed client/server info. Ignore. return logger.debug("Type %s, server %d, client %d, id %d" % (parsed["type"], parsed["s"], parsed["c"], self.id)) if parsed["c"] < (self.messages["client"] + 1): # Receive a message already received at least once. Ignore. return elif parsed["c"] > (self.messages["client"] + 1): # Messages from the client have been lost. logger.debug('REQUEST RESEND FROM CLIENT') self.write_message({ 'type': 'request_resend', 'from': self.messages["client"] }) return elif parsed["s"] < self.messages["server"]: # Message was sent either simultaneously with message from server # or a message from the server previously sent never arrived. # Resend the messages the client missed. logger.debug('SIMULTANEOUS') self.messages["client"] += 1 self.resend_messages(parsed["s"]) if (parsed["type"] == "diff"): self.send_message({ 'type': 'reject_diff', 'rid': parsed['rid'] }) return # Message order is correct. We continue processing the data. self.messages["client"] += 1 if parsed["type"] == 'get_document': self.send_document() elif parsed["type"] == 'participant_update' and self.can_communicate(): self.handle_participant_update() elif parsed["type"] == 'chat' and self.can_communicate(): self.handle_chat(parsed) elif parsed["type"] == 'check_version': self.check_version(parsed) elif parsed["type"] == 'selection_change': self.handle_selection_change(parsed) elif parsed["type"] == 'diff' and self.can_update_document(): self.handle_diff(parsed) def resend_messages(self, from_no): to_send = self.messages["server"] - from_no logger.debug('resending messages: %d' % to_send) logger.debug('Server: %d, from: %d' % (self.messages["server"], from_no)) if to_send > len(self.messages['last_ten']): # Too many messages requested. We have to abort. logger.debug('cannot fix it') self.send_document() return self.messages['server'] -= to_send for message in self.messages['last_ten'][0 - to_send:]: self.send_message(message) def update_bibliography(self, bibliography_updates): for bu in bibliography_updates: if "id" not in bu: continue id = bu["id"] if bu["type"] == "update": self.doc["bibliography"][id] = bu["reference"] elif bu["type"] == "delete": del self.doc["bibliography"][id] def update_images(self, image_updates): for iu in image_updates: if "id" not in iu: continue id = iu["id"] if iu["type"] == "update": # Ensure that access rights exist if not UserImage.objects.filter( image__id=id, owner=self.user_info.user).exists(): continue doc_image = DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id) if doc_image.exists(): doc_image.title = iu["image"]["title"] doc_image.save() else: DocumentImage.objects.create(document_id=self.doc["id"], image_id=id, title=iu["image"]["title"]) elif iu["type"] == "delete": DocumentImage.objects.filter(document_id=self.doc["id"], image_id=id).delete() for image in Image.objects.filter(id=id): if image.is_deletable(): image.delete() def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: if "id" not in cd: # ignore continue id = str(cd["id"]) if cd["type"] == "create": del cd["type"] self.doc["comments"][id] = cd elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "review:isMajor" in cd: self.doc["comments"][id]["review:isMajor"] = cd[ "review:isMajor"] elif cd["type"] == "add_answer": if "answers" not in self.doc["comments"][id]: self.doc["comments"][id]["answers"] = [] del cd["type"] self.doc["comments"][id]["answers"].append(cd) elif cd["type"] == "delete_answer": answer_id = str(cd["answerId"]) for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: self.doc["comments"][id]["answers"].remove(answer) elif cd["type"] == "update_answer": answer_id = str(cd["answerId"]) for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: answer["answer"] = cd["answer"] def handle_participant_update(self): WebSocket.send_participant_list(self.user_info.document_id) def handle_chat(self, parsed): chat = { "id": str(uuid.uuid4()), "body": parsed['body'], "from": self.user_info.user.id, "type": 'chat' } WebSocket.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, parsed): if self.user_info.document_id in WebSocket.sessions and parsed[ "v"] == self.doc['version']: WebSocket.send_updates(parsed, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, parsed): allowed_operations = ['addMark', 'removeMark'] only_comment = True if "ds" in parsed: # ds = document steps for step in parsed["ds"]: if not (step['stepType'] in allowed_operations and step['mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, parsed): pv = parsed["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if (self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(parsed)): logger.error(('received non-comment diff from comment-only ' 'collaborator. Discarding.')) return if pv == dv: self.doc["last_diffs"].append(parsed) # Only keep the last 1000 diffs self.doc["last_diffs"] = self.doc["last_diffs"][-1000:] self.doc['version'] += 1 if "jd" in parsed: # jd = json diff try: apply_patch(self.doc['contents'], parsed["jd"], True) except JsonPatchConflict: logger.exception("Cannot apply json diff.") logger.error(json_encode(parsed)) logger.error(json_encode(self.doc['contents'])) self.send_document() # The json diff is only needed by the python backend which does # not understand the steps. It can therefore be removed before # broadcast to other clients. del parsed["jd"] if "ti" in parsed: # ti = title self.doc["title"] = parsed["ti"] if "cu" in parsed: # cu = comment updates self.update_comments(parsed["cu"]) if "bu" in parsed: # bu = bibliography updates self.update_bibliography(parsed["bu"]) if "iu" in parsed: # iu = image updates self.update_images(parsed["iu"]) WebSocket.save_document(self.user_info.document_id) self.confirm_diff(parsed["rid"]) WebSocket.send_updates(parsed, self.user_info.document_id, self.id, self.user_info.user.id) elif pv < dv: if pv + len(self.doc["last_diffs"]) >= dv: # We have enough last_diffs stored to fix it. logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) else: logger.debug('unfixable') # Client has a version that is too old to be fixed self.send_document() else: # Client has a higher version than server. Something is fishy! logger.debug('unfixable') def check_version(self, parsed): pv = parsed["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if pv == dv: response = { "type": "confirm_version", "v": pv, } self.send_message(response) return elif pv + len(self.doc["last_diffs"]) >= dv: logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) return else: logger.debug('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): logger.debug('Websocket closing') if (hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in WebSocket.sessions and hasattr(self, 'id') and self.id in WebSocket.sessions[ self.user_info.document_id]['participants']): del self.doc['participants'][self.id] if len(self.doc['participants']) == 0: WebSocket.save_document(self.user_info.document_id) del WebSocket.sessions[self.user_info.document_id] logger.debug("noone left") else: WebSocket.send_participant_list(self.user_info.document_id) def send_message(self, message): self.messages['server'] += 1 message['c'] = self.messages['client'] message['s'] = self.messages['server'] self.messages['last_ten'].append(message) self.messages['last_ten'] = self.messages['last_ten'][-10:] logger.debug("Sending: Type %s, Server: %d, Client: %d, id: %d" % (message["type"], message['s'], message['c'], self.id)) if message["type"] == 'diff': logger.debug("Diff version: %d" % message["v"]) self.write_message(message) @classmethod def send_participant_list(cls, document_id): if document_id in WebSocket.sessions: participant_list = [] for session_id, waiter in cls.sessions[document_id][ 'participants'].items(): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': avatar_url(waiter.user_info.user, 80) }) message = { "participant_list": participant_list, "type": 'connections' } WebSocket.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): logger.debug("Sending message to %d waiters", len(cls.sessions[document_id]['participants'])) for waiter in cls.sessions[document_id]['participants'].values(): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to send the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif (access_rights == 'review' and user_id != waiter.user_info.user.id): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to sned the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif (message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE): continue elif (message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id): continue try: waiter.send_message(message) except WebSocketClosedError: logger.error("Error sending message", exc_info=True) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] doc_db.title = doc['title'][-255:] doc_db.version = doc['version'] doc_db.contents = json_encode(doc['contents']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) doc_db.bibliography = json_encode(doc['bibliography']) logger.debug('saving document # %d' % doc_db.id) logger.debug('version %d' % doc_db.version) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)
class WebSocket(BaseWebSocketHandler): sessions = dict() history_length = 1000 # Only keep the last 1000 diffs def open(self, arg): super().open(arg) if len(self.args) < 2: self.access_denied() return self.document_id = int(self.args[0]) logger.debug( f"Action:Document socket opened by user. " f"URL:{self.endpoint} User:{self.user.id} ParticipantID:{self.id}") def confirm_diff(self, rid): response = { 'type': 'confirm_diff', 'rid': rid } self.send_message(response) def subscribe_doc(self, connection_count=0): self.user_info = SessionUserInfo(self.user) doc_db, can_access = self.user_info.init_access( self.document_id ) if not can_access or float(doc_db.doc_version) != FW_DOCUMENT_VERSION: self.access_denied() return if ( doc_db.id in WebSocket.sessions and len(WebSocket.sessions[doc_db.id]['participants']) > 0 ): logger.debug( f"Action:Serving already opened document. " f"URL:{self.endpoint} User:{self.user.id} " f" ParticipantID:{self.id}") self.doc = WebSocket.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 self.doc['participants'][self.id] = self else: logger.debug( f"Action:Opening document from DB. " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") self.id = 0 self.doc = { 'db': doc_db, 'participants': { 0: self }, 'last_diffs': json_decode(doc_db.last_diffs), 'comments': json_decode(doc_db.comments), 'bibliography': json_decode(doc_db.bibliography), 'contents': json_decode(doc_db.contents), 'version': doc_db.version, 'title': doc_db.title, 'id': doc_db.id, 'template': { 'id': doc_db.template.id, 'definition': json_decode(doc_db.template.definition) } } WebSocket.sessions[doc_db.id] = self.doc logger.debug( f"Action:Participant ID Assigned. URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id}") self.send_message({ 'type': 'subscribed' }) if connection_count < 1: self.send_styles() self.send_document() if self.can_communicate(): self.handle_participant_update() def send_styles(self): doc_db = self.doc['db'] response = dict() response['type'] = 'styles' serializer = PythonWithURLSerializer() export_temps = serializer.serialize( doc_db.template.exporttemplate_set.all(), fields=['file_type', 'template_file', 'title'] ) document_styles = serializer.serialize( doc_db.template.documentstyle_set.all(), use_natural_foreign_keys=True, fields=['title', 'slug', 'contents', 'documentstylefile_set'] ) document_templates = {} for obj in DocumentTemplate.objects.filter( Q(user=self.user) | Q(user=None) ).order_by(F('user').desc(nulls_first=True)): document_templates[obj.import_id] = { 'title': obj.title, 'id': obj.id } response['styles'] = { 'export_templates': [obj['fields'] for obj in export_temps], 'document_styles': [obj['fields'] for obj in document_styles], 'document_templates': document_templates } self.send_message(response) def unfixable(self): self.send_document() def send_document(self): response = dict() response['type'] = 'doc_data' doc_owner = self.doc['db'].owner response['doc_info'] = { 'id': self.doc['id'], 'is_owner': self.user_info.is_owner, 'access_rights': self.user_info.access_rights, 'owner': { 'id': doc_owner.id, 'name': doc_owner.readable_name, 'username': doc_owner.username, 'avatar': get_user_avatar_url(doc_owner), 'team_members': [] } } response['doc'] = { 'v': self.doc['version'], 'contents': self.doc['contents'], 'bibliography': self.doc['bibliography'], 'template': self.doc['template'], 'images': {} } response['time'] = int(time()) * 1000 for dimage in DocumentImage.objects.filter(document_id=self.doc['id']): image = dimage.image field_obj = { 'id': image.id, 'title': dimage.title, 'copyright': json.loads(dimage.copyright), 'image': image.image.url, 'file_type': image.file_type, 'added': mktime(image.added.timetuple()) * 1000, 'checksum': image.checksum, 'cats': [] } if image.thumbnail: field_obj['thumbnail'] = image.thumbnail.url field_obj['height'] = image.height field_obj['width'] = image.width response['doc']['images'][image.id] = field_obj if self.user_info.access_rights == 'read-without-comments': response['doc']['comments'] = [] elif self.user_info.access_rights == 'review': # Reviewer should only get his/her own comments filtered_comments = {} for key, value in list(self.doc["comments"].items()): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] for team_member in doc_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['username'] = team_member.member.get_username() tm_object['avatar'] = get_user_avatar_url(team_member.member) response['doc_info']['owner']['team_members'].append(tm_object) response['doc_info']['session_id'] = self.id self.send_message(response) def reject_message(self, message): if (message["type"] == "diff"): self.send_message({ 'type': 'reject_diff', 'rid': message['rid'] }) def handle_message(self, message): if message["type"] == 'subscribe': connection_count = 0 if 'connection' in message: connection_count = message['connection'] self.subscribe_doc(connection_count) return if self.user_info.document_id not in WebSocket.sessions: logger.debug( f"Action:Receiving message for closed document. " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") return if message["type"] == 'get_document': self.send_document() elif ( message["type"] == 'participant_update' and self.can_communicate() ): self.handle_participant_update() elif message["type"] == 'chat' and self.can_communicate(): self.handle_chat(message) elif message["type"] == 'check_version': self.check_version(message) elif message["type"] == 'selection_change': self.handle_selection_change(message) elif message["type"] == 'diff' and self.can_update_document(): self.handle_diff(message) def update_bibliography(self, bibliography_updates): for bu in bibliography_updates: if "id" not in bu: continue id = bu["id"] if bu["type"] == "update": self.doc["bibliography"][id] = bu["reference"] elif bu["type"] == "delete": del self.doc["bibliography"][id] def update_images(self, image_updates): for iu in image_updates: if "id" not in iu: continue id = iu["id"] if iu["type"] == "update": # Ensure that access rights exist if not UserImage.objects.filter( image__id=id, owner=self.user_info.user ).exists(): continue doc_image = DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id ).first() if doc_image: doc_image.title = iu["image"]["title"] doc_image.copyright = json.dumps(iu["image"]["copyright"]) doc_image.save() else: DocumentImage.objects.create( document_id=self.doc["id"], image_id=id, title=iu["image"]["title"], copyright=json.dumps(iu["image"]["copyright"]) ) elif iu["type"] == "delete": DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id ).delete() for image in Image.objects.filter(id=id): if image.is_deletable(): image.delete() def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: if "id" not in cd: # ignore continue id = cd["id"] if cd["type"] == "create": self.doc["comments"][id] = { "user": cd["user"], "username": cd["username"], "assignedUser": cd["assignedUser"], "assignedUsername": cd["assignedUsername"], "date": cd["date"], "comment": cd["comment"], "isMajor": cd["isMajor"], "resolved": cd["resolved"], } elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "isMajor" in cd: self.doc["comments"][id][ "isMajor"] = cd["isMajor"] if "assignedUser" in cd and "assignedUsername" in cd: self.doc["comments"][id][ "assignedUser"] = cd["assignedUser"] self.doc["comments"][id][ "assignedUsername"] = cd["assignedUsername"] if "resolved" in cd: self.doc["comments"][id][ "resolved"] = cd["resolved"] elif cd["type"] == "add_answer": if "answers" not in self.doc["comments"][id]: self.doc["comments"][id]["answers"] = [] self.doc["comments"][id]["answers"].append({ "id": cd["answerId"], "user": cd["user"], "username": cd["username"], "date": cd["date"], "answer": cd["answer"] }) elif cd["type"] == "delete_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: self.doc["comments"][id]["answers"].remove(answer) elif cd["type"] == "update_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: answer["answer"] = cd["answer"] def handle_participant_update(self): WebSocket.send_participant_list(self.user_info.document_id) def handle_chat(self, message): chat = { "id": str(uuid.uuid4()), "body": message['body'], "from": self.user_info.user.id, "type": 'chat' } WebSocket.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, message): if self.user_info.document_id in WebSocket.sessions and message[ "v"] == self.doc['version']: WebSocket.send_updates( message, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, message): allowed_operations = ['addMark', 'removeMark'] only_comment = True if "ds" in message: # ds = document steps for step in message["ds"]: if not (step['stepType'] in allowed_operations and step[ 'mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, message): pv = message["v"] dv = self.doc['version'] logger.debug( f"Action:Handling Diff. URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id} Client version:{pv} " f"Server version:{dv} Message:{message}") if ( self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(message) ): logger.error( f"Action:Received non-comment diff from comment-only " f"collaborator.Discarding URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id}") return if pv == dv: self.doc["last_diffs"].append(message) self.doc["last_diffs"] = self.doc[ "last_diffs" ][-self.history_length:] self.doc['version'] += 1 if "jd" in message: # jd = json diff try: apply_patch( self.doc['contents'], message["jd"], True ) except (JsonPatchConflict, JsonPointerException): logger.exception( f"Action:Cannot apply json diff. " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") logger.error( f"Action:Patch Exception URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id} " f"Message:{json_encode(message)}") logger.error( f"Action:Patch Exception URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id} " f"Document:{json_encode(self.doc['contents'])}") self.unfixable() # The json diff is only needed by the python backend which does # not understand the steps. It can therefore be removed before # broadcast to other clients. del message["jd"] if "ti" in message: # ti = title self.doc["title"] = message["ti"] if "cu" in message: # cu = comment updates self.update_comments(message["cu"]) if "bu" in message: # bu = bibliography updates self.update_bibliography(message["bu"]) if "iu" in message: # iu = image updates self.update_images(message["iu"]) if self.doc['version'] % 10 == 0: WebSocket.save_document(self.user_info.document_id) self.confirm_diff(message["rid"]) WebSocket.send_updates( message, self.user_info.document_id, self.id, self.user_info.user.id ) elif pv < dv: if pv + len(self.doc["last_diffs"]) >= dv: # We have enough last_diffs stored to fix it. number_diffs = pv - dv logger.debug( f"Action:Resending document diffs. URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id} " f"number of messages to be resent:{number_diffs}") messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) else: logger.debug( f"Action:User is on a very old version of the document. " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") # Client has a version that is too old to be fixed self.unfixable() else: # Client has a higher version than server. Something is fishy! logger.debug( f"Action:User has higher document version than server.Fishy! " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") def check_version(self, message): pv = message["v"] dv = self.doc['version'] logger.debug( f"Action:Checking version of document. URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id} " f"Client document version:{pv} Server document version:{dv}") if pv == dv: response = { "type": "confirm_version", "v": pv, } self.send_message(response) return elif pv + len(self.doc["last_diffs"]) >= dv: number_diffs = pv - dv logger.debug( f"Action:Resending document diffs. URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id}" f"number of messages to be resent:{number_diffs}") messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) return else: logger.debug( f"Action:User is on a very old version of the document. " f"URL:{self.endpoint} User:{self.user.id} " f"ParticipantID:{self.id}") # Client has a version that is too old self.unfixable() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): logger.debug( f"Action:Closing websocket. URL:{self.endpoint} " f"User:{self.user.id} ParticipantID:{self.id}") if ( hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in WebSocket.sessions and hasattr(self, 'id') and self.id in WebSocket.sessions[ self.user_info.document_id ]['participants'] ): del self.doc['participants'][self.id] if len(self.doc['participants']) == 0: WebSocket.save_document(self.user_info.document_id) del WebSocket.sessions[self.user_info.document_id] logger.debug( f"Action:No participants for the document. " f"URL:{self.endpoint} User:{self.user.id}") else: WebSocket.send_participant_list(self.user_info.document_id) @classmethod def send_participant_list(cls, document_id): if document_id in WebSocket.sessions: participant_list = [] for session_id, waiter in list(cls.sessions[ document_id ]['participants'].items()): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': get_user_avatar_url(waiter.user_info.user) }) message = { "participant_list": participant_list, "type": 'connections' } WebSocket.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): logger.debug( f"Action:Sending message to waiters. DocumentID:{document_id} " f"waiters:{len(cls.sessions[document_id]['participants'])}") for waiter in list(cls.sessions[document_id]['participants'].values()): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to send the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( access_rights == 'review' and user_id != waiter.user_info.user.id ): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to send the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE ): continue elif ( message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id ): continue waiter.send_message(message) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] if doc_db.version == doc['version']: return doc_db.title = doc['title'][-255:] doc_db.version = doc['version'] doc_db.contents = json_encode(doc['contents']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) doc_db.bibliography = json_encode(doc['bibliography']) logger.debug( f"Action:Saving document to DB. DocumentID:{doc_db.id} " f"Doc version:{doc_db.version}") try: # this try block is to avoid a db exception # in case the doc has been deleted from the db # in fiduswriter the owner of a doc could delete a doc # while an invited writer is editing the same doc doc_db.save(update_fields=[ 'title', 'version', 'contents', 'last_diffs', 'comments', 'bibliography', 'updated']) except DatabaseError as e: expected_msg = 'Save with update_fields did not affect any rows.' if str(e) == expected_msg: cls.__insert_document(doc=doc_db) else: raise e @classmethod def __insert_document(cls, doc: Document) -> None: """ Purpose: during plugin tests we experienced Integrity errors at the end of tests. This exception occurs while handling another exception, so in order to have a clean tests output we raise the exception in a way we don't output misleading error messages related to different exceptions :param doc: socket document model instance :return: None """ from django.db.utils import IntegrityError try: doc.save() except IntegrityError as e: raise IntegrityError( 'plugin test error when we try to save a doc already deleted ' 'along with the rest of db data so it ' 'raises an Integrity error: {}'.format(e)) from None @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)
class DocumentWS(BaseWebSocketHandler): sessions = dict() def open(self, document_id): print('Websocket opened') response = dict() current_user = self.get_current_user() if current_user is None: response['type'] = 'access_denied' self.write_message(response) return self.user_info = SessionUserInfo() doc_db, can_access = self.user_info.init_access( document_id, current_user) if can_access: if doc_db.id in DocumentWS.sessions: self.doc = DocumentWS.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 print("id when opened %s" % self.id) else: self.id = 0 self.doc = dict() self.doc['db'] = doc_db self.doc['participants'] = dict() self.doc['last_diffs'] = json_decode(doc_db.last_diffs) self.doc['comments'] = json_decode(doc_db.comments) self.doc['settings'] = json_decode(doc_db.settings) self.doc['contents'] = json_decode(doc_db.contents) self.doc['metadata'] = json_decode(doc_db.metadata) self.doc['version'] = doc_db.version self.doc['diff_version'] = doc_db.diff_version self.doc['comment_version'] = doc_db.comment_version self.doc['title'] = doc_db.title self.doc['id'] = doc_db.id DocumentWS.sessions[doc_db.id] = self.doc self.doc['participants'][self.id] = self response['type'] = 'welcome' self.write_message(response) def confirm_diff(self, request_id): response = dict() response['type'] = 'confirm_diff' response['request_id'] = request_id self.write_message(response) def send_document(self): response = dict() response['type'] = 'doc_data' response['doc'] = dict() response['doc']['id'] = self.doc['id'] response['doc']['version'] = self.doc['version'] if self.doc['diff_version'] < self.doc['version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] response['doc']['title'] = self.doc['title'] response['doc']['contents'] = self.doc['contents'] response['doc']['metadata'] = self.doc['metadata'] response['doc']['settings'] = self.doc['settings'] document_owner = self.doc['db'].owner access_rights = get_accessrights( AccessRight.objects.filter( document__owner=document_owner)) response['doc']['access_rights'] = access_rights if (self.user_info.access_rights == 'read-without-comments'): response['doc']['comments'] = [] elif (self.user_info.access_rights == 'review'): # Reviewer should only get his/her own comments filtered_comments = {} for key, value in self.doc["comments"].items(): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] response['doc']['comment_version'] = self.doc["comment_version"] response['doc']['access_rights'] = get_accessrights( AccessRight.objects.filter(document__owner=document_owner)) response['doc']['owner'] = dict() response['doc']['owner']['id'] = document_owner.id response['doc']['owner']['name'] = document_owner.readable_name response['doc']['owner'][ 'avatar'] = avatar_url(document_owner, 80) response['doc']['owner']['team_members'] = [] for team_member in document_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['avatar'] = avatar_url(team_member.member, 80) response['doc']['owner']['team_members'].append(tm_object) response['doc_info'] = dict() response['doc_info']['is_owner'] = self.user_info.is_owner response['doc_info']['rights'] = self.user_info.access_rights if self.doc['version'] > self.doc['diff_version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] elif self.doc['diff_version'] > self.doc['version']: needed_diffs = self.doc['diff_version'] - self.doc['version'] # We only send those diffs needed by the receiver. response['doc_info']['unapplied_diffs'] = self.doc[ "last_diffs"][-needed_diffs:] else: response['doc_info']['unapplied_diffs'] = [] if not self.user_info.is_owner: response['user'] = dict() response['user']['id'] = self.user_info.user.id response['user']['name'] = self.user_info.user.readable_name response['user']['avatar'] = avatar_url(self.user_info.user, 80) response['doc_info']['session_id'] = self.id self.write_message(response) def on_message(self, message): if self.user_info.document_id not in DocumentWS.sessions: print('receiving message for closed document') return parsed = json_decode(message) print(parsed["type"]) if parsed["type"] == 'get_document': self.send_document() elif parsed["type"] == 'participant_update' and self.can_communicate(): self.handle_participant_update() elif parsed["type"] == 'chat' and self.can_communicate(): self.handle_chat(parsed) elif parsed["type"] == 'check_diff_version': self.check_diff_version(parsed) elif parsed["type"] == 'selection_change': self.handle_selection_change(parsed) elif ( parsed["type"] == 'update_doc' and self.can_update_document() ): self.handle_document_update(parsed) elif parsed["type"] == 'update_title' and self.can_update_document(): self.handle_title_update(parsed) elif parsed["type"] == 'diff' and self.can_update_document(): self.handle_diff(parsed) def update_document(self, changes): if changes['version'] == self.doc['version']: # Document hasn't changed, return. return elif ( changes['version'] > self.doc['diff_version'] or changes['version'] < self.doc['version'] ): # The version number is too high. Possibly due to server restart. # Do not accept it, and send a document instead. self.send_document() return else: # The saved version does not contain all accepted diffs, so we keep # the remaining ones + 1000 in case a client needs to reconnect and # is missing some. remaining_diffs = 1000 + \ self.doc['diff_version'] - changes['version'] self.doc['last_diffs'] = self.doc['last_diffs'][-remaining_diffs:] self.doc['title'] = changes['title'] self.doc['contents'] = changes['contents'] self.doc['metadata'] = changes['metadata'] self.doc['settings'] = changes['settings'] self.doc['version'] = changes['version'] def update_title(self, title): self.doc['title'] = title def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: id = str(cd["id"]) if cd["type"] == "create": del cd["type"] self.doc["comments"][id] = cd elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "review:isMajor" in cd: self.doc["comments"][id][ "review:isMajor"] = cd["review:isMajor"] elif cd["type"] == "add_answer": comment_id = str(cd["commentId"]) if "answers" not in self.doc["comments"][comment_id]: self.doc["comments"][comment_id]["answers"] = [] del cd["type"] self.doc["comments"][comment_id]["answers"].append(cd) elif cd["type"] == "delete_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: self.doc["comments"][comment_id][ "answers"].remove(answer) elif cd["type"] == "update_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: answer["answer"] = cd["answer"] self.doc['comment_version'] += 1 def handle_participant_update(self): DocumentWS.send_participant_list(self.user_info.document_id) def handle_document_update(self, parsed): self.update_document(parsed["doc"]) DocumentWS.save_document(self.user_info.document_id, False) message = { "type": 'check_hash', "diff_version": parsed["doc"]["version"], "hash": parsed["hash"] } DocumentWS.send_updates(message, self.user_info.document_id, self.id) def handle_title_update(self, parsed): self.update_title(parsed["title"]) DocumentWS.save_document(self.user_info.document_id, False) def handle_chat(self, parsed): chat = { "id": str(uuid.uuid4()), "body": parsed['body'], "from": self.user_info.user.id, "type": 'chat' } DocumentWS.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, parsed): if self.user_info.document_id in DocumentWS.sessions and parsed[ "diff_version"] == self.doc['diff_version']: DocumentWS.send_updates( parsed, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, parsed_diffs): allowed_operations = ['addMark', 'removeMark'] only_comment = True for diff in parsed_diffs: if not (diff['stepType'] in allowed_operations and diff[ 'mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, parsed): if ( self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(parsed['diff']) ): print( ( 'received non-comment diff from comment-only ' 'collaborator. Discarding.' ) ) return if parsed["diff_version"] == self.doc['diff_version'] and parsed[ "comment_version"] == self.doc['comment_version']: self.doc["last_diffs"].extend(parsed["diff"]) self.doc['diff_version'] += len(parsed["diff"]) self.update_comments(parsed["comments"]) self.confirm_diff(parsed["request_id"]) DocumentWS.send_updates( parsed, self.user_info.document_id, self.id, self.user_info.user.id ) elif parsed["diff_version"] != self.doc['diff_version']: if parsed["diff_version"] < ( self.doc['diff_version'] - len(self.doc["last_diffs"])): print('unfixable') # Client has a version that is too old self.send_document() elif parsed["diff_version"] < self.doc['diff_version']: print("can fix it") number_requested_diffs = self.doc[ 'diff_version'] - parsed["diff_version"] response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], "reject_request_id": parsed["request_id"], } self.write_message(response) else: print('unfixable') # Client has a version that is too old self.send_document() else: print('comment_version incorrect!') def check_diff_version(self, parsed): pdv = parsed["diff_version"] ddv = self.doc['diff_version'] if pdv == ddv: response = { "type": "confirm_diff_version", "diff_version": pdv, } self.write_message(response) return elif pdv + len(self.doc["last_diffs"]) >= ddv: number_requested_diffs = ddv - pdv response = { "type": "diff", "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][-number_requested_diffs:], } self.write_message(response) return else: print('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): print('Websocket closing') if ( hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in DocumentWS.sessions and hasattr(self, 'id') and self.id in DocumentWS.sessions[ self.user_info.document_id ]['participants'] ): del self.doc['participants'][self.id] if len(self.doc['participants'].keys()) == 0: DocumentWS.save_document(self.user_info.document_id, True) del DocumentWS.sessions[self.user_info.document_id] print("noone left") @classmethod def send_participant_list(cls, document_id): if document_id in DocumentWS.sessions: participant_list = [] for session_id, waiter in cls.sessions[ document_id ]['participants'].items(): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': avatar_url(waiter.user_info.user, 80) }) message = { "participant_list": participant_list, "type": 'connections' } DocumentWS.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): info("sending message to %d waiters", len(cls.sessions[document_id])) for waiter in cls.sessions[document_id]['participants'].values(): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to sned the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( access_rights == 'review' and user_id != waiter.user_info.user.id ): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to sned the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE ): continue elif ( message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id ): continue try: waiter.write_message(message) except WebSocketClosedError: error("Error sending message", exc_info=True) @classmethod def save_document(cls, document_id, all_have_left): doc = cls.sessions[document_id] doc_db = doc['db'] doc_db.title = doc['title'] doc_db.version = doc['version'] doc_db.diff_version = doc['diff_version'] doc_db.comment_version = doc['comment_version'] doc_db.contents = json_encode(doc['contents']) doc_db.metadata = json_encode(doc['metadata']) doc_db.settings = json_encode(doc['settings']) if all_have_left: remaining_diffs = doc['diff_version'] - doc['version'] if remaining_diffs > 0: doc['last_diffs'] = doc['last_diffs'][-remaining_diffs:] else: doc['last_diffs'] = [] doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) print('saving document #' + str(doc_db.id)) print('version ' + str(doc_db.version)) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id, True)
class WebSocket(BaseWebSocketHandler): sessions = dict() def open(self, document_id): print('Websocket opened') response = dict() current_user = self.get_current_user() if current_user is None: response['type'] = 'access_denied' self.write_message(response) return self.user_info = SessionUserInfo() doc_db, can_access = self.user_info.init_access( document_id, current_user) if can_access: if doc_db.id in WebSocket.sessions: self.doc = WebSocket.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 print("id when opened %s" % self.id) else: self.id = 0 self.doc = dict() self.doc['db'] = doc_db self.doc['participants'] = dict() self.doc['last_diffs'] = json_decode(doc_db.last_diffs) self.doc['comments'] = json_decode(doc_db.comments) self.doc['settings'] = json_decode(doc_db.settings) self.doc['contents'] = json_decode(doc_db.contents) self.doc['metadata'] = json_decode(doc_db.metadata) self.doc['version'] = doc_db.version self.doc['diff_version'] = doc_db.diff_version self.doc['comment_version'] = doc_db.comment_version self.doc['title'] = doc_db.title self.doc['id'] = doc_db.id WebSocket.sessions[doc_db.id] = self.doc self.doc['participants'][self.id] = self response['type'] = 'welcome' self.write_message(response) def confirm_diff(self, request_id): response = dict() response['type'] = 'confirm_diff' response['request_id'] = request_id self.write_message(response) def send_document(self): response = dict() response['type'] = 'doc_data' response['doc'] = dict() response['doc']['id'] = self.doc['id'] response['doc']['version'] = self.doc['version'] if self.doc['diff_version'] < self.doc['version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] response['doc']['title'] = self.doc['title'] response['doc']['contents'] = self.doc['contents'] response['doc']['metadata'] = self.doc['metadata'] response['doc']['settings'] = self.doc['settings'] doc_owner = self.doc['db'].owner access_rights = get_accessrights( AccessRight.objects.filter( document__owner=doc_owner)) response['doc']['access_rights'] = access_rights if self.user_info.access_rights == 'read-without-comments': response['doc']['comments'] = [] elif self.user_info.access_rights == 'review': # Reviewer should only get his/her own comments filtered_comments = {} for key, value in self.doc["comments"].items(): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] response['doc']['comment_version'] = self.doc["comment_version"] response['doc']['access_rights'] = get_accessrights( AccessRight.objects.filter(document__owner=doc_owner)) response['doc']['owner'] = dict() response['doc']['owner']['id'] = doc_owner.id response['doc']['owner']['name'] = doc_owner.readable_name response['doc']['owner'][ 'avatar'] = avatar_url(doc_owner, 80) response['doc']['owner']['team_members'] = [] for team_member in doc_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['avatar'] = avatar_url(team_member.member, 80) response['doc']['owner']['team_members'].append(tm_object) response['doc_info'] = dict() response['doc_info']['is_owner'] = self.user_info.is_owner response['doc_info']['rights'] = self.user_info.access_rights if self.doc['version'] > self.doc['diff_version']: print('!!!diff version issue!!!') self.doc['diff_version'] = self.doc['version'] self.doc["last_diffs"] = [] elif self.doc['diff_version'] > self.doc['version']: needed_diffs = self.doc['diff_version'] - self.doc['version'] # We only send those diffs needed by the receiver. response['doc_info']['unapplied_diffs'] = self.doc[ "last_diffs"][-needed_diffs:] else: response['doc_info']['unapplied_diffs'] = [] if self.user_info.is_owner: the_user = self.user_info.user response['doc']['owner']['email'] = the_user.email response['doc']['owner']['username'] = the_user.username response['doc']['owner']['first_name'] = the_user.first_name response['doc']['owner']['last_name'] = the_user.last_name response['doc']['owner']['email'] = the_user.email else: the_user = self.user_info.user response['user'] = dict() response['user']['id'] = the_user.id response['user']['name'] = the_user.readable_name response['user']['avatar'] = avatar_url(the_user, 80) response['user']['email'] = the_user.email response['user']['username'] = the_user.username response['user']['first_name'] = the_user.first_name response['user']['last_name'] = the_user.last_name response['user']['email'] = the_user.email response['doc_info']['session_id'] = self.id self.write_message(response) def on_message(self, message): if self.user_info.document_id not in WebSocket.sessions: print('receiving message for closed document') return parsed = json_decode(message) print(parsed["type"]) if parsed["type"] == 'get_document': self.send_document() elif parsed["type"] == 'participant_update' and self.can_communicate(): self.handle_participant_update() elif parsed["type"] == 'chat' and self.can_communicate(): self.handle_chat(parsed) elif parsed["type"] == 'check_diff_version': self.check_diff_version(parsed) elif parsed["type"] == 'selection_change': self.handle_selection_change(parsed) elif ( parsed["type"] == 'update_doc' and self.can_update_document() ): self.handle_document_update(parsed) elif parsed["type"] == 'update_title' and self.can_update_document(): self.handle_title_update(parsed) elif parsed["type"] == 'diff' and self.can_update_document(): self.handle_diff(parsed) def update_document(self, changes): if changes['version'] == self.doc['version']: # Document hasn't changed, return. return elif ( changes['version'] > self.doc['diff_version'] or changes['version'] < self.doc['version'] ): # The version number is too high. Possibly due to server restart. # Do not accept it, and send a document instead. self.send_document() return else: # The saved version does not contain all accepted diffs, so we keep # the remaining ones + 1000 in case a client needs to reconnect and # is missing some. remaining_diffs = 1000 + \ self.doc['diff_version'] - changes['version'] self.doc['last_diffs'] = self.doc['last_diffs'][-remaining_diffs:] self.doc['title'] = changes['title'] self.doc['contents'] = changes['contents'] self.doc['metadata'] = changes['metadata'] self.doc['settings'] = changes['settings'] self.doc['version'] = changes['version'] def update_title(self, title): self.doc['title'] = title def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: id = str(cd["id"]) if cd["type"] == "create": del cd["type"] self.doc["comments"][id] = cd elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "review:isMajor" in cd: self.doc["comments"][id][ "review:isMajor"] = cd["review:isMajor"] elif cd["type"] == "add_answer": comment_id = str(cd["commentId"]) if "answers" not in self.doc["comments"][comment_id]: self.doc["comments"][comment_id]["answers"] = [] del cd["type"] self.doc["comments"][comment_id]["answers"].append(cd) elif cd["type"] == "delete_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: self.doc["comments"][comment_id][ "answers"].remove(answer) elif cd["type"] == "update_answer": comment_id = str(cd["commentId"]) for answer in self.doc["comments"][comment_id]["answers"]: if answer["id"] == cd["id"]: answer["answer"] = cd["answer"] self.doc['comment_version'] += 1 def handle_participant_update(self): WebSocket.send_participant_list(self.user_info.document_id) def handle_document_update(self, parsed): self.update_document(parsed["doc"]) WebSocket.save_document(self.user_info.document_id, False) message = { "type": 'check_hash', "diff_version": parsed["doc"]["version"], "hash": parsed["hash"] } WebSocket.send_updates(message, self.user_info.document_id, self.id) def handle_title_update(self, parsed): self.update_title(parsed["title"]) WebSocket.save_document(self.user_info.document_id, False) def handle_chat(self, parsed): chat = { "id": str(uuid.uuid4()), "body": parsed['body'], "from": self.user_info.user.id, "type": 'chat' } WebSocket.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, parsed): if self.user_info.document_id in WebSocket.sessions and parsed[ "diff_version"] == self.doc['diff_version']: WebSocket.send_updates( parsed, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, parsed_diffs): allowed_operations = ['addMark', 'removeMark'] only_comment = True for diff in parsed_diffs: if not (diff['stepType'] in allowed_operations and diff[ 'mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, parsed): pdv = parsed["diff_version"] ddv = self.doc['diff_version'] pcv = parsed["comment_version"] dcv = self.doc['comment_version'] if ( self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(parsed['diff']) ): print( ( 'received non-comment diff from comment-only ' 'collaborator. Discarding.' ) ) return if pdv == ddv and pcv == dcv: self.doc["last_diffs"].extend(parsed["diff"]) self.doc['diff_version'] += len(parsed["diff"]) self.update_comments(parsed["comments"]) self.confirm_diff(parsed["request_id"]) WebSocket.send_updates( parsed, self.user_info.document_id, self.id, self.user_info.user.id ) elif pdv > ddv: # Client has a higher version than server. Something is fishy! print('unfixable') elif pdv < ddv: if pdv + len(self.doc["last_diffs"]) >= ddv: # We have enough last_diffs stored to fix it. print("can fix it") number_diffs = \ parsed["diff_version"] - self.doc['diff_version'] response = { "type": "diff", "server_fix": True, "diff_version": parsed["diff_version"], "diff": self.doc["last_diffs"][number_diffs:], "reject_request_id": parsed["request_id"], } self.write_message(response) else: print('unfixable') # Client has a version that is too old to be fixed self.send_document() else: print('comment_version incorrect!') def check_diff_version(self, parsed): pdv = parsed["diff_version"] ddv = self.doc['diff_version'] if pdv == ddv: response = { "type": "confirm_diff_version", "diff_version": pdv, } self.write_message(response) return elif pdv + len(self.doc["last_diffs"]) >= ddv: print("can fix it") number_diffs = pdv - ddv response = { "type": "diff", "server_fix": True, "diff_version": pdv, "diff": self.doc["last_diffs"][number_diffs:], } self.write_message(response) return else: print('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): print('Websocket closing') if ( hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in WebSocket.sessions and hasattr(self, 'id') and self.id in WebSocket.sessions[ self.user_info.document_id ]['participants'] ): del self.doc['participants'][self.id] if len(self.doc['participants'].keys()) == 0: WebSocket.save_document(self.user_info.document_id, True) del WebSocket.sessions[self.user_info.document_id] print("noone left") @classmethod def send_participant_list(cls, document_id): if document_id in WebSocket.sessions: participant_list = [] for session_id, waiter in cls.sessions[ document_id ]['participants'].items(): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': avatar_url(waiter.user_info.user, 80) }) message = { "participant_list": participant_list, "type": 'connections' } WebSocket.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): info("sending message to %d waiters", len(cls.sessions[document_id])) for waiter in cls.sessions[document_id]['participants'].values(): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to send the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( access_rights == 'review' and user_id != waiter.user_info.user.id ): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to sned the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE ): continue elif ( message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id ): continue try: waiter.write_message(message) except WebSocketClosedError: error("Error sending message", exc_info=True) @classmethod def save_document(cls, document_id, all_have_left): doc = cls.sessions[document_id] doc_db = doc['db'] doc_db.title = doc['title'][-255:] doc_db.version = doc['version'] doc_db.diff_version = doc['diff_version'] doc_db.comment_version = doc['comment_version'] doc_db.contents = json_encode(doc['contents']) doc_db.metadata = json_encode(doc['metadata']) doc_db.settings = json_encode(doc['settings']) if all_have_left: remaining_diffs = doc['diff_version'] - doc['version'] if remaining_diffs > 0: doc['last_diffs'] = doc['last_diffs'][-remaining_diffs:] else: doc['last_diffs'] = [] doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) print('saving document #' + str(doc_db.id)) print('version ' + str(doc_db.version)) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id, True)
class WebSocket(BaseWebSocketHandler): sessions = dict() def open(self, arg): super().open(arg) if len(self.args) < 2: self.access_denied() return self.document_id = int(self.args[0]) def confirm_diff(self, rid): response = { 'type': 'confirm_diff', 'rid': rid } self.send_message(response) def subscribe_doc(self, connection_count=0): self.user_info = SessionUserInfo(self.user) doc_db, can_access = self.user_info.init_access( self.document_id ) if not can_access or float(doc_db.doc_version) != FW_DOCUMENT_VERSION: self.access_denied() return if ( doc_db.id in WebSocket.sessions and len(WebSocket.sessions[doc_db.id]['participants']) > 0 ): logger.debug("Serving already opened file") self.doc = WebSocket.sessions[doc_db.id] self.id = max(self.doc['participants']) + 1 self.doc['participants'][self.id] = self logger.debug("id when opened %s" % self.id) else: logger.debug("Opening file") self.id = 0 self.doc = { 'db': doc_db, 'participants': { 0: self }, 'last_diffs': json_decode(doc_db.last_diffs), 'comments': json_decode(doc_db.comments), 'bibliography': json_decode(doc_db.bibliography), 'contents': json_decode(doc_db.contents), 'version': doc_db.version, 'title': doc_db.title, 'id': doc_db.id, 'template': { 'title': doc_db.template.title, 'definition': json_decode(doc_db.template.definition) } } WebSocket.sessions[doc_db.id] = self.doc self.send_message({ 'type': 'subscribed' }) if connection_count < 1: self.send_styles() self.send_document() if self.can_communicate(): self.handle_participant_update() def send_styles(self): doc_db = self.doc['db'] response = dict() response['type'] = 'styles' serializer = PythonWithURLSerializer() export_temps = serializer.serialize( doc_db.template.export_templates.all() ) document_styles = serializer.serialize( doc_db.template.document_styles.all(), use_natural_foreign_keys=True ) cite_styles = serializer.serialize( doc_db.template.citation_styles.all() ) cite_locales = serializer.serialize( CitationLocale.objects.all() ) response['styles'] = { 'export_templates': [obj['fields'] for obj in export_temps], 'document_styles': [obj['fields'] for obj in document_styles], 'citation_styles': [obj['fields'] for obj in cite_styles], 'citation_locales': [obj['fields'] for obj in cite_locales], } self.send_message(response) def send_document(self): response = dict() response['type'] = 'doc_data' doc_owner = self.doc['db'].owner response['doc_info'] = { 'id': self.doc['id'], 'is_owner': self.user_info.is_owner, 'access_rights': self.user_info.access_rights, 'owner': { 'id': doc_owner.id, 'name': doc_owner.readable_name, 'username': doc_owner.username, 'avatar': get_user_avatar_url(doc_owner)['url'], 'team_members': [] } } response['doc'] = { 'v': self.doc['version'], 'contents': self.doc['contents'], 'bibliography': self.doc['bibliography'], 'template': self.doc['template'], 'images': {} } response['time'] = int(time()) * 1000 for dimage in DocumentImage.objects.filter(document_id=self.doc['id']): image = dimage.image field_obj = { 'id': image.id, 'title': dimage.title, 'image': image.image.url, 'file_type': image.file_type, 'added': mktime(image.added.timetuple()) * 1000, 'checksum': image.checksum, 'cats': [] } if image.thumbnail: field_obj['thumbnail'] = image.thumbnail.url field_obj['height'] = image.height field_obj['width'] = image.width response['doc']['images'][image.id] = field_obj if self.user_info.access_rights == 'read-without-comments': response['doc']['comments'] = [] elif self.user_info.access_rights == 'review': # Reviewer should only get his/her own comments filtered_comments = {} for key, value in list(self.doc["comments"].items()): if value["user"] == self.user_info.user.id: filtered_comments[key] = value response['doc']['comments'] = filtered_comments else: response['doc']['comments'] = self.doc["comments"] for team_member in doc_owner.leader.all(): tm_object = dict() tm_object['id'] = team_member.member.id tm_object['name'] = team_member.member.readable_name tm_object['username'] = team_member.member.get_username() tm_object['avatar'] = get_user_avatar_url( team_member.member )['url'] response['doc_info']['owner']['team_members'].append(tm_object) collaborators = get_accessrights( AccessRight.objects.filter(document__owner=doc_owner) ) response['doc_info']['collaborators'] = collaborators response['doc_info']['session_id'] = self.id self.send_message(response) def reject_message(self, message): if (message["type"] == "diff"): self.send_message({ 'type': 'reject_diff', 'rid': message['rid'] }) def handle_message(self, message): if message["type"] == 'subscribe': connection_count = 0 if 'connection' in message: connection_count = message['connection'] self.subscribe_doc(connection_count) return if self.user_info.document_id not in WebSocket.sessions: logger.debug('receiving message for closed document') return if message["type"] == 'get_document': self.send_document() elif ( message["type"] == 'participant_update' and self.can_communicate() ): self.handle_participant_update() elif message["type"] == 'chat' and self.can_communicate(): self.handle_chat(message) elif message["type"] == 'check_version': self.check_version(message) elif message["type"] == 'selection_change': self.handle_selection_change(message) elif message["type"] == 'diff' and self.can_update_document(): self.handle_diff(message) def update_bibliography(self, bibliography_updates): for bu in bibliography_updates: if "id" not in bu: continue id = bu["id"] if bu["type"] == "update": self.doc["bibliography"][id] = bu["reference"] elif bu["type"] == "delete": del self.doc["bibliography"][id] def update_images(self, image_updates): for iu in image_updates: if "id" not in iu: continue id = iu["id"] if iu["type"] == "update": # Ensure that access rights exist if not UserImage.objects.filter( image__id=id, owner=self.user_info.user ).exists(): continue doc_image = DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id ) if doc_image.exists(): doc_image.title = iu["image"]["title"] doc_image.save() else: DocumentImage.objects.create( document_id=self.doc["id"], image_id=id, title=iu["image"]["title"] ) elif iu["type"] == "delete": DocumentImage.objects.filter( document_id=self.doc["id"], image_id=id ).delete() for image in Image.objects.filter(id=id): if image.is_deletable(): image.delete() def update_comments(self, comments_updates): comments_updates = deepcopy(comments_updates) for cd in comments_updates: if "id" not in cd: # ignore continue id = cd["id"] if cd["type"] == "create": self.doc["comments"][id] = { "user": cd["user"], "username": cd["username"], "assignedUser": cd["assignedUser"], "assignedUsername": cd["assignedUsername"], "date": cd["date"], "comment": cd["comment"], "isMajor": cd["isMajor"], "resolved": cd["resolved"], } elif cd["type"] == "delete": del self.doc["comments"][id] elif cd["type"] == "update": self.doc["comments"][id]["comment"] = cd["comment"] if "isMajor" in cd: self.doc["comments"][id][ "isMajor"] = cd["isMajor"] if "assignedUser" in cd and "assignedUsername" in cd: self.doc["comments"][id][ "assignedUser"] = cd["assignedUser"] self.doc["comments"][id][ "assignedUsername"] = cd["assignedUsername"] if "resolved" in cd: self.doc["comments"][id][ "resolved"] = cd["resolved"] elif cd["type"] == "add_answer": if "answers" not in self.doc["comments"][id]: self.doc["comments"][id]["answers"] = [] self.doc["comments"][id]["answers"].append({ "id": cd["answerId"], "user": cd["user"], "username": cd["username"], "date": cd["date"], "answer": cd["answer"] }) elif cd["type"] == "delete_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: self.doc["comments"][id]["answers"].remove(answer) elif cd["type"] == "update_answer": answer_id = cd["answerId"] for answer in self.doc["comments"][id]["answers"]: if answer["id"] == answer_id: answer["answer"] = cd["answer"] def handle_participant_update(self): WebSocket.send_participant_list(self.user_info.document_id) def handle_chat(self, message): chat = { "id": str(uuid.uuid4()), "body": message['body'], "from": self.user_info.user.id, "type": 'chat' } WebSocket.send_updates(chat, self.user_info.document_id) def handle_selection_change(self, message): if self.user_info.document_id in WebSocket.sessions and message[ "v"] == self.doc['version']: WebSocket.send_updates( message, self.user_info.document_id, self.id) # Checks if the diff only contains changes to comments. def only_comments(self, message): allowed_operations = ['addMark', 'removeMark'] only_comment = True if "ds" in message: # ds = document steps for step in message["ds"]: if not (step['stepType'] in allowed_operations and step[ 'mark']['type'] == 'comment'): only_comment = False return only_comment def handle_diff(self, message): pv = message["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if ( self.user_info.access_rights in COMMENT_ONLY and not self.only_comments(message) ): logger.error( ( 'received non-comment diff from comment-only ' 'collaborator. Discarding.' ) ) return if pv == dv: self.doc["last_diffs"].append(message) # Only keep the last 1000 diffs self.doc["last_diffs"] = self.doc["last_diffs"][-1000:] self.doc['version'] += 1 if "jd" in message: # jd = json diff try: apply_patch( self.doc['contents'], message["jd"], True ) except (JsonPatchConflict, JsonPointerException): logger.exception("Cannot apply json diff.") logger.error(json_encode(message)) logger.error(json_encode(self.doc['contents'])) self.send_document() # The json diff is only needed by the python backend which does # not understand the steps. It can therefore be removed before # broadcast to other clients. del message["jd"] if "ti" in message: # ti = title self.doc["title"] = message["ti"] if "cu" in message: # cu = comment updates self.update_comments(message["cu"]) if "bu" in message: # bu = bibliography updates self.update_bibliography(message["bu"]) if "iu" in message: # iu = image updates self.update_images(message["iu"]) if self.doc['version'] % 10 == 0: WebSocket.save_document(self.user_info.document_id) self.confirm_diff(message["rid"]) WebSocket.send_updates( message, self.user_info.document_id, self.id, self.user_info.user.id ) elif pv < dv: if pv + len(self.doc["last_diffs"]) >= dv: # We have enough last_diffs stored to fix it. logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) else: logger.debug('unfixable') # Client has a version that is too old to be fixed self.send_document() else: # Client has a higher version than server. Something is fishy! logger.debug('unfixable') def check_version(self, message): pv = message["v"] dv = self.doc['version'] logger.debug("PV: %d, DV: %d" % (pv, dv)) if pv == dv: response = { "type": "confirm_version", "v": pv, } self.send_message(response) return elif pv + len(self.doc["last_diffs"]) >= dv: logger.debug("can fix it") number_diffs = pv - dv messages = self.doc["last_diffs"][number_diffs:] for message in messages: new_message = message.copy() new_message["server_fix"] = True self.send_message(new_message) return else: logger.debug('unfixable') # Client has a version that is too old self.send_document() return def can_update_document(self): return self.user_info.access_rights in CAN_UPDATE_DOCUMENT def can_communicate(self): return self.user_info.access_rights in CAN_COMMUNICATE def on_close(self): logger.debug('Websocket closing') if ( hasattr(self, 'user_info') and hasattr(self.user_info, 'document_id') and self.user_info.document_id in WebSocket.sessions and hasattr(self, 'id') and self.id in WebSocket.sessions[ self.user_info.document_id ]['participants'] ): del self.doc['participants'][self.id] if len(self.doc['participants']) == 0: WebSocket.save_document(self.user_info.document_id) del WebSocket.sessions[self.user_info.document_id] logger.debug("noone left") else: WebSocket.send_participant_list(self.user_info.document_id) @classmethod def send_participant_list(cls, document_id): if document_id in WebSocket.sessions: participant_list = [] for session_id, waiter in list(cls.sessions[ document_id ]['participants'].items()): access_rights = waiter.user_info.access_rights if access_rights not in CAN_COMMUNICATE: continue participant_list.append({ 'session_id': session_id, 'id': waiter.user_info.user.id, 'name': waiter.user_info.user.readable_name, 'avatar': get_user_avatar_url(waiter.user_info.user)['url'] }) message = { "participant_list": participant_list, "type": 'connections' } WebSocket.send_updates(message, document_id) @classmethod def send_updates(cls, message, document_id, sender_id=None, user_id=None): logger.debug( "Sending message to %d waiters", len(cls.sessions[document_id]['participants']) ) for waiter in list(cls.sessions[document_id]['participants'].values()): if waiter.id != sender_id: access_rights = waiter.user_info.access_rights if "comments" in message and len(message["comments"]) > 0: # Filter comments if needed if access_rights == 'read-without-comments': # The reader should not receive the comments update, so # we remove the comments from the copy of the message # sent to the reviewer. We still need to send the rest # of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( access_rights == 'review' and user_id != waiter.user_info.user.id ): # The reviewer should not receive comments updates from # others than themselves, so we remove the comments # from the copy of the message sent to the reviewer # that are not from them. We still need to sned the # rest of the message as it may contain other diff # information. message = deepcopy(message) message['comments'] = [] elif ( message['type'] in ["chat", "connections"] and access_rights not in CAN_COMMUNICATE ): continue elif ( message['type'] == "selection_change" and access_rights not in CAN_COMMUNICATE and user_id != waiter.user_info.user.id ): continue waiter.send_message(message) @classmethod def save_document(cls, document_id): doc = cls.sessions[document_id] doc_db = doc['db'] if doc_db.version == doc['version']: return doc_db.title = doc['title'][-255:] doc_db.version = doc['version'] doc_db.contents = json_encode(doc['contents']) doc_db.last_diffs = json_encode(doc['last_diffs']) doc_db.comments = json_encode(doc['comments']) doc_db.bibliography = json_encode(doc['bibliography']) logger.debug('saving document # %d' % doc_db.id) logger.debug('version %d' % doc_db.version) doc_db.save() @classmethod def save_all_docs(cls): for document_id in cls.sessions: cls.save_document(document_id)