def set_user_role(self, role, uid=None, user_name=None): # Make sure the role is valid if not Perms.is_valid_role(role): raise ValidationError("%server_error_invalid_role") with Transaction(self.db): # Read the user data user = self.db.get_user(uid=uid, user_name=user_name) if user is None: raise NotFoundError() # Update the role if role != user.role: # Delete any active session, if the user has fewer permissions perms_old = Perms.lookup_role_permissions(user.role) perms_new = Perms.lookup_role_permissions(role) if perms_old & (perms_new ^ perms_old): # Note: (perms_new ^ perms_old) contains the changed # permission bits, perms_old & (perms_new ^ perms_old) # is not equal to zero if a bit that changed was active in # perms_old. self.db.purge_sessions_for_user(user.uid) # Write the user back with the updated role user.role = role self.db.update_user(user)
def update_user_settings(self, uid, settings): """ Merges the currently stored settings with the given settings object. Returns the updated settings object. """ # TODO: Provide method to not update the settings, as currently an # ill-behaving client has no chance to remove setting keys. # Make sure the "settings" object is a dictionary if not isinstance(settings, dict): raise ValidationError() # Perform the update with Transaction(self.db): # Merge the new settings with the old values if (uid in self.db.settings): for key, value in json.loads(self.db.settings[uid]).items(): if not key in settings: settings[key] = value # Serialise the settings to a string settings_str = json.dumps(settings, sort_keys=True) if len(settings_str) > API.MAX_SETTINGS_LEN: raise ValidationError() # Store the merged values in the database self.db.settings[uid] = settings_str return settings
def create_post(self, post): """ Creates a new post. """ # Coerce the post into a new Post object, make sure the pid is set to # "None"; otherwise the post would not get its own pid p = API.coerce_post(post) if not p.pid is None: raise ValidationError() # The revision must be set to zero if p.revision != 0: raise ValidationError() # Insert the post into the database with Transaction(self.db): # Create the post in the database p.pid = self.db.create_post(p) # Update the fulltext search self.db.update_fulltext(p.pid, p) # Insert keywords keywords = self.db.keywords for keyword in API._split(p.keywords): keywords[keyword] = p.pid # Convert the post back to a dictionary return API._post_to_dict(p)
def import_from_object(self, obj): """ Restors a database backup formerly created by the export_to_json function. This will delete the current content of the database. @param obj is a Python object that has been deserialised from JSON """ # Make sure that this operation is atomic with Transaction(self.db): # Delete everything in the database self.db.purge() # Go through all restorable tables (i.e., it doesn't make much sense # to backup the challenges, sessions, cache, keywords tables). if "configuration" in obj: for key, value in obj["configuration"].items(): self.db.configuration[key] = value if "posts" in obj: for post in obj["posts"]: p = API.coerce_post(post) self.db.create_post(p) self.db.update_fulltext(p.pid, p) if "posts_history" in obj: for post in obj["posts_history"]: self.db.create_post(API.coerce_post(post), history=True) if "users" in obj: for user in obj["users"]: self.db.create_user(API.coerce_user(user)) if "settings" in obj: for key, value in obj["settings"].items(): self.db.settings[key] = value # Rebuild the keywords table self._rebuild_keywords()
def get_user_settings(self, uid): """ Reads the user settings from the database and serialises them into a JSON object. Returns an empty object if the user has not stored any settings yet. """ with Transaction(self.db): if (uid in self.db.settings): return json.loads(self.db.settings[uid]) return {}
def update_post(self, post): """ Updates an already existing post. """ # Coerce the given post into a Post object, make sure the pid is set p = API.coerce_post(post) if p.pid is None: raise ValidationError() with Transaction(self.db): # Fetch the post that is supposed to be updated pid = p.pid old_post = self.db.get_post(pid) if old_post is None: raise NotFoundError() # Do nothing if there is no difference between the old and new post if ((old_post.author == p.author) and (old_post.date == p.date) and (old_post.keywords == p.keywords) and (old_post.content == p.content)): return API._post_to_dict(p) # Make sure that the revision of the given post is equal to the # revision of the old post; increment the revision of the post that # is to be stored. if old_post.revision != p.revision: raise ConflictError() p.revision += 1 # Copy the original creation uid and time p.cuid = old_post.cuid p.ctime = old_post.ctime # Insert the old post into the history table self.db.create_post(old_post, history=True) # Remove keywords associated with the old post keywords = self.db.keywords for keyword in API._split(old_post.keywords): keywords[keyword] = keywords[keyword] - set((pid, )) # Update the post in the normal posts table if self.db.update_post(p) == 0: raise NotFoundError() # Update the fulltext search self.db.update_fulltext(pid, p) # Insert the new keywords keywords = self.db.keywords for keyword in API._split(p.keywords): keywords[keyword] = pid return API._post_to_dict(p)
def get_configuration_object(self): """ Returns a JSON serialisable object containing the global configuration options. """ with Transaction(self.db): c = self.db.configuration return { "login_methods": { "username_password": c["login_method_username_password"] == "1", "cas": c["login_method_cas"] == "1", }, "salt": c["salt"], }
def _login_method_username_password(self, user_name, challenge, response): """ Internal implementation of the _login_method_username_password function. """ # Make sure the user name, challenge, and response are of the # correct format user_name = str(user_name).strip().lower() challenge, response = str(challenge), str(response) if not (API.USER_RE.match(user_name) and API.HEX64_RE.match(challenge) and API.HEX64_RE.match(response)): raise ValidationError() # Make sure the valid is valid (do this outside of the transaction # below to avoid purged sessions being rolled back. if not self._check_password_login_challenge(challenge): raise AuthentificationError() # Lookup the user, check the user role and compute the expected response with Transaction(self.db): # Make sure the user exists. If the user name does not exist, raise # an authentification error, i.e. do not expose the users' # non-existance to the client user = self.db.get_user_by_name(user_name) if user is None: raise AuthentificationError() # Make sure the user can actually login using a password if user.auth_method != "password": raise AuthentificationError() # Make sure the user has the right permissions if Perms.lookup_role_permissions(user.role) <= 0: raise AuthentificationError() # Compute the expected password hash expected_response = self._hash_password( bytes.fromhex(user.password), challenge) if expected_response != response: raise AuthentificationError() # Okay, all this worked. Let's create a session for the user and # return the sid sid = os.urandom(32).hex() self.db.create_session(sid, user.uid) return self.get_session_data(sid)
def export_to_object(self, export_passwords=False): """ Exports the entire database into a JSON serialisable object. Some tables with volatile data (such as the "cache", "challenges", and "sessions" tables) will not be exported. """ with Transaction(self.db): # Export the configuration options config_obj = {} for key, value in self.db.configuration.items(): config_obj[key] = value # Export the posts posts_arr = [] for post in self.db.list_posts(): posts_arr.append(asdict(post)) # Export the post history posts_history_arr = [] for post in self.db.list_posts(history=True): posts_history_arr.append(asdict(post)) # Export the users users_arr = [] for user in self.db.list_users(): user_obj = asdict(user) if not export_passwords: user_obj["reset_password"] = True del user_obj["password"] users_arr.append(user_obj) # Export the user settings settings_obj = {} for key, value in self.db.settings.items(): settings_obj[key] = value # Return the completely assembled object return { "configuration": config_obj, "posts": posts_arr, "posts_history": posts_history_arr, "users": users_arr, "settings": settings_obj }
def get_session_data(self, sid): """ Returns the session data. Returns None if the given session is not available or invalid. Updates the session mtime in order to re-validate the session. The session data is an object containing the following elements: { "sid": <the session id>, "uid": <the numerical user id>, "name": <the canonical user name>, "display_name": <the user-defined display name>, "role": <the user role string>, "reset_password": <true if the user must reset the password>, } """ # Validate the session identifier if not API.HEX64_RE.match(sid): raise ValidationError() # Remove any stale sessions, then try to fetch the user data associated # with the session self.db.purge_stale_sessions(API.SESSION_TIMEOUT) with Transaction(self.db): # Fetch the UID corresponding to the session uid = self.db.get_session_uid(sid) if not uid: return None # Advance the session mtime self.db.update_session_mtime(sid) # Get the user data associated with the UID user = self.db.get_user_by_id(uid) return { "sid": sid, "uid": uid, "name": user.name, "display_name": user.display_name, "role": user.role, "reset_password": user.reset_password, }
def _check_password_login_challenge(self, challenge): """ Used internally. Returns True and deletes the given challenge if it existed, returns False if the challenge is invalid (i.e. too old). """ with Transaction(self.db): # Delete stale challenges self.db.purge_stale_challenges(API.CHALLENGE_TIMEOUT) # Check whether the callenge exists if not challenge in self.db.challenges: return False # Delete the challenge del self.db.challenges[challenge] return True
def reset_user_password(self, uid=None, user_name=None): with Transaction(self.db): user = self.db.get_user(uid=uid, user_name=user_name) if user is None: raise NotFoundError() # Create a random password password = self.create_random_password() password_hash = self._hash_password(password) # Set the password and the reset_password flag user.password = password_hash user.reset_password = True # Write the user back to the database self.db.update_user(user) # Return the generated password for display return password
def get_password_login_challenge(self): """ Creates a login challenge and returns an object containing both the challenge and the salt. """ with Transaction(self.db): # Fetch the current UNIX time and create a random challenge challenge = os.urandom(32).hex() # Delete old challenges self.db.purge_stale_challenges(API.CHALLENGE_TIMEOUT) # Store the challenge and its creation time in the database self.db.challenges[challenge] = self.db.now() # Return a challenge object ready to be sent to the client return { "salt": self.db.configuration["salt"], "challenge": challenge }
def delete_post(self, pid): # Make sure the given post id is and integer and non-negative if not isinstance(pid, int) or pid < 0: raise ValidationError() with Transaction(self.db): # Fetch the post that is supposed to be deleted post = self.db.get_post(pid) if post is None: raise NotFoundError() # Delete all keywords associated with the post keywords = self.db.keywords for keyword in API._split(post.keywords): keywords[keyword] = keywords[keyword] - set((pid, )) # Delete the post from the history, the main post table, and the # fulltext search index self.db.delete_post(pid) self.db.delete_post(pid, history=True) self.db.delete_fulltext(pid)
def update_user(self, properties, uid=None, user_name=None): """ Updates attributes of a user by merging the given properties into an existing user structure. """ with Transaction(self.db): # Try to fetch the user, either by user name or uid user = self.db.get_user(uid=uid, user_name=user_name) if user is None: raise NotFoundError() # Make sure the settings we're trying to update actually exist, and, # in case they do, update the corresponding value for key, value in properties.items(): # If we're updating the password to a new password and the new # password is actually different from the current password, # then reset the "reset_password" flag if ((key == "password") and (value.lower() != user.password.lower())): user.reset_password = False # Update all other properties if hasattr(user, key): setattr(user, key, value) else: raise ValidationError() # Coerce the updated user to make sure all new settings adhere to # the rules user_new = API.coerce_user(asdict(user)) try: self.db.update_user(user_new) except UniqueKeyViolationError: raise ConflictError() return asdict(user_new)
def _rebuild_keywords(self): """ Given the current list of posts, rebuilds the index that maps keywords onto a set of posts. This function is used after importing a backup. """ with Transaction(self.db): # Delete all keywords self.db.keywords.clear() # List all posts and construct a mapping between posts and keywords keywords = {} for post in self.db.list_posts(): for keyword in API._split(post.keywords): if keyword in keywords: keywords[keyword].add(post.pid) else: keywords[keyword] = { post.pid, } # Insert the keywords into the database; the keywords dictionary # is a MultiDict, i.e., it stores a set per keyword for keyword, pids in keywords.items(): self.db.keywords[keyword] = pids
def delete_user(self, uid=None, user_name=None, force=False): """ Deletes the user with the given uid or user name. Per default, does not delete users who already have posts; to this end, "force" has to be set to True. Important: this function will move all content generated by this user to the dummy "[deleted]" user account. @param uid is the numerical id of the user that should be deleted. Supply either uid or user_name. @param user_name is the name of the user that should be deleted. Supply either uid or user_name. @param force if true, force the deletion of users who contributed content. """ with Transaction(self.db): # Read the user data user = self.db.get_user(uid=uid, user_name=user_name) if user is None: raise NotFoundError() # Check whether the user has any posts. If yes, either rewrite these # posts to the "[deleted]" user (if force=True) or do nothing and # abort (if force=False). def _rewrite(history): # List all the posts for the user filter = FilterUID(user.uid) | FilterAuthor(user.uid) user_posts = self.db.list_posts(history=history, filter=filter) # Abort if we found any posts, and "force" is not set if len(user_posts) > 0 and not force: return False # Otherwise, update the post CUID or MUID to "0" and update the # post for post in user_posts: if post.cuid == user.uid: post.cuid = 0 if post.muid == user.uid: post.muid = 0 if post.author == user.uid: post.author = 0 self.db.update_post(post, history=history) return True # Rewrite both the current post table and the history table if not (_rewrite(history=False) and _rewrite(history=True)): return False # Delete all sessions for this user self.db.purge_sessions_for_user(user.uid) # Delete all settings for this user if user.uid in self.db.settings: del self.db.settings[user.uid] # Delete the user account itself self.db.delete_user(user.uid) # Success! return True
def _init(self): if self.db.open and self.perform_initialisation: with Transaction(self.db): self._init_configuration() self._init_users() self.perform_initialisation = False