diff --git a/cc_cloud/routes/routes.py b/cc_cloud/routes/routes.py index 02edc7e..21eeaee 100644 --- a/cc_cloud/routes/routes.py +++ b/cc_cloud/routes/routes.py @@ -7,12 +7,23 @@ def cloud_routes(app, auth, cloud_service): Creates the cloud webinterface endpoints. :param app: The flask app to attach to + :type app: flask.Flask :param auth: The authorization module to use :type auth: Auth + :param cloud_service: The cloud service module to use. + :type cloud_service: CloudService """ @app.route('/file', methods=['GET']) def download_file(): + """ + Endpoint for downloading a file. + + :param path: The path of the file to download. + :type path: str + :return: The file as an attachment if it exists, or an appropriate error message. + :rtype: flask.Response + """ user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) path = request.args.get('path') @@ -31,6 +42,14 @@ def download_file(): @app.route('/file', methods=['PUT']) def upload_file(): + """ + Endpoint for uploading a file. + + :param files: The uploaded file(s) to be saved. + :type files: werkzeug.datastructures.FileStorage or dict[str, werkzeug.datastructures.FileStorage] + :return: The response indicating the success of the file upload. + :rtype: flask.Response + """ user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) cloud_service.upload_file(user, request.files) @@ -40,6 +59,14 @@ def upload_file(): @app.route('/file', methods=['DELETE']) def delete_file(): + """ + Endpoint for deleting a file. + + :param path: The path of the file to delete. + :type path: str + :return: The response indicating the success of the file deletion. + :rtype: flask.Response + """ user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) path = request.args.get('path') @@ -49,8 +76,87 @@ def delete_file(): return create_flask_response(response_string, auth, user.authentication_cookie) + @app.route('/public_key', methods=['PUT']) + def add_public_key(): + """ + Endpoint for adding a public key. + + :param key: The public key to add. + :type key: str + :return: The response indicating the success of adding the public key. + :rtype: flask.Response + """ + user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) + public_key = request.args.get('key') + + added = cloud_service.set_local_user_authorized_key(user, public_key) + response_string = 'ok' if added else 'public key not valid' + + return create_flask_response(response_string, auth, user.authentication_cookie) + + + @app.route('/size', methods=['GET']) + def get_current_size(): + """ + Endpoint for retrieving the current storage usage. + + :return: The current storage size in bytes. + :rtype: flask.Response + """ + user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) + + current_size = cloud_service.get_storage_usage(user) + + return create_flask_response(current_size, auth, user.authentication_cookie) + + + @app.route('/size_limit', methods=['GET']) + def get_size_limit(): + """ + Endpoint for retrieving the size limit. + + :return: The size limit in bytes. + :rtype: flask.Response + """ + user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) + + size_limit = cloud_service.get_size_limit(user) + + return create_flask_response(size_limit, auth, user.authentication_cookie) + + + @app.route('/size_limit', methods=['PUT']) + def set_size_limit(): + """ + Endpoint for setting the size limit. + + :param username: The username for which to set the size limit. + :type username: str + :param size: The new size limit in bytes. + :type size: str + :return: The response indicating the success of changing the size limit. + :rtype: flask.Response + """ + user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) + change_user = request.args.get('username') + size = request.args.get('size') + + edited = cloud_service.set_size_limit(user, change_user, int(size)) + response_string = 'ok' if edited else 'could not change the size' + + return create_flask_response(response_string, auth, user.authentication_cookie) + + @app.route('/create_user', methods=['GET']) def create_user(): + """ + Endpoint for creating a user. + + :param username: The username to create. + :type username: str + :return: The response indicating the success of creating the user. + :rtype: flask.Response + """ user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) create_username = request.args.get('username') @@ -62,6 +168,14 @@ def create_user(): @app.route('/remove_user', methods=['GET']) def remove_user(): + """ + Endpoint for removing a user. + + :param username: The username to remove. + :type username: str + :return: The response indicating the success of removing the user. + :rtype: flask.Response + """ user = auth.verify_user(request.authorization, request.cookies, request.remote_addr) remove_username = request.args.get('username') diff --git a/cc_cloud/service/cloud_service.py b/cc_cloud/service/cloud_service.py index dc0dcd3..f846e81 100644 --- a/cc_cloud/service/cloud_service.py +++ b/cc_cloud/service/cloud_service.py @@ -1,4 +1,4 @@ - +from sshpubkeys import SSHKey from cc_cloud.service.filesystem_service import FilesystemService from cc_cloud.service.file_service import FileService from cc_cloud.system.local_user import LocalUser @@ -41,7 +41,10 @@ def get_user_ref(self, user): :return: user reference :rtype: str """ - return self.user_prefix + '-' + user.username + if isinstance(user, Auth.User): + return self.user_prefix + '-' + user.username + elif isinstance(user, str): + return self.user_prefix + '-' + user def local_user_exists_or_create(self, user): @@ -75,6 +78,10 @@ def add_local_user_to_db(self, username, ssh_user, ssh_password, size_limit): self.mongo.db['cloud_users'].update_one({'username': username}, {'$set': cloud_users}, upsert=True) + def get_local_user_from_db(self, username): + return self.mongo.db['cloud_users'].find_one({'username': username}) + + ## cloud storage actions def file_action(self, user, func, *args): @@ -141,13 +148,91 @@ def set_local_user_authorized_key(self, user, pub_key): :param pub_key: public ssh-key, that will be added to the authorized_keys file :type pub_key: str """ + try: + ssh = SSHKey(pub_key) + ssh.parse() + except Exception: + return False + _, local_user = self.local_user_exists_or_create(user) local_user.set_authorized_key(pub_key) + return True + + + def get_storage_usage(self, user): + """ + Get the current storage usage for a user. + + :param user: The user for whom to retrieve the storage usage. + :type user: cc_agency.broker.auth.Auth.User + :return: The storage usage in bytes. + :rtype: int + """ + user_ref = self.get_user_ref(user) + try: + return self.filesystem_service.get_storage_usage(user_ref) + except TypeError: + return 0 + + + def get_size_limit(self, user): + """ + Get the size limit for a user. + + :param user: The user for whom to retrieve the size limit. + :type user: cc_agency.broker.auth.Auth.User + :return: The size limit in bytes. + :rtype: int + """ + user_ref = self.get_user_ref(user) + return self.filesystem_service.get_size(user_ref) ## only for admin users + def set_size_limit(self, user, change_user, size): + """ + Set the size limit for a user. + + :param user: The user making the size limit change. Should be an admin user. + :type user: cc_agency.broker.auth.Auth.User + :param change_user: The user for whom the size limit will be changed. + :type change_user: cc_agency.broker.auth.Auth.User + :param size: The new size limit in bytes. + :type size: int + :return: True if the size limit was successfully changed, False otherwise. + :rtype: bool + """ + if not user.is_admin: + return False + + db_user = self.get_local_user_from_db(change_user) + if not db_user: + return False + + change_user_ref = self.get_user_ref(change_user) + current_size_limit = self.filesystem_service.get_size(change_user_ref) + if size > current_size_limit: + self.filesystem_service.increse_size(change_user_ref, size) + elif size < current_size_limit: + self.filesystem_service.reduce_size(change_user_ref, size) + + self.mongo.db['cloud_users'].update_one({'username': change_user}, {'$set': {'size_limit': size}}, upsert=True) + + return True + + def create_user(self, user, create_username): + """ + Create a new user. + + :param user: The user creating the new user. Should be an admin user. + :type user: cc_agency.broker.auth.Auth.User + :param create_username: The username for the new user. + :type create_username: str + :return: True if the user was successfully created, False otherwise. + :rtype: bool + """ if not user.is_admin: return False @@ -172,10 +257,8 @@ def remove_user(self, user, remove_username): if not user.is_admin: return False - remove_user = Auth.User(remove_username, False) - user_ref = self.get_user_ref(remove_user) - - self.mongo.db['cloud_users'].delete_one({'username': remove_user.username}) + user_ref = self.get_user_ref(remove_username) + self.mongo.db['cloud_users'].delete_one({'username': remove_username}) self.filesystem_service.umount(user_ref) self.filesystem_service.delete(user_ref) diff --git a/cc_cloud/service/filesystem_service.py b/cc_cloud/service/filesystem_service.py index 7addb04..da96394 100644 --- a/cc_cloud/service/filesystem_service.py +++ b/cc_cloud/service/filesystem_service.py @@ -25,7 +25,7 @@ def create(self, fs_name, size=None): :param fs_name: Creates a filesystem with the name fs_name :type fs_name: str - :param size: Size of the filesystem, defaults to None + :param size: Size (bytes) of the filesystem, defaults to None :type size: int, optional """ filepath = self.get_filepath(fs_name) @@ -134,7 +134,7 @@ def increse_size(self, fs_name, size): :param fs_name: Increse size of the filesystem with the name fs_name :type fs_name: str - :param size: The size of the file system is set to the specified size + :param size: The size (bytes) of the file system is set to the specified size :type size: int """ filepath = self.get_filepath(fs_name) @@ -150,7 +150,7 @@ def reduce_size(self, fs_name, size): :param fs_name: Reduce size of the filesystem with the name fs_name :type fs_name: str - :param size: The size of the file system is set to the specified size + :param size: The size (bytes) of the file system is set to the specified size. :type size: int """ if self.is_mounted(fs_name): @@ -159,7 +159,7 @@ def reduce_size(self, fs_name, size): self.create(fs_name, size) def get_size(self, fs_name): - """Get the size of the filesystem. + """Get the size limit of the filesystem in bytes. :param fs_name: Get size of the filesystem with the name fs_name :type fs_name: str @@ -169,6 +169,20 @@ def get_size(self, fs_name): filepath = self.get_filepath(fs_name) return os.path.getsize(filepath) + def get_storage_usage(self, fs_name): + """Get the current usage of the cloud storage in bytes. + + :param fs_name: Get usage of the cloud storage with the name fs_name + :type fs_name: str + :return: Usage of cloud storage + :rtype: int + """ + if not self.is_mounted(fs_name): + self.mount(fs_name) + mountpoint = self.get_mountpoint(fs_name) + total, used, free = shutil.disk_usage(mountpoint) + return used + def get_loop_device(self, filepath): """Get the loop device of the mounted filesystem. diff --git a/poetry.lock b/poetry.lock index 90c0b08..491868c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -97,7 +97,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7 [[package]] name = "coverage" -version = "7.2.5" +version = "7.2.7" description = "Code coverage measurement for Python" category = "main" optional = false @@ -146,6 +146,21 @@ websocket-client = ">=0.32.0" [package.extras] ssh = ["paramiko (>=2.4.3)"] +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "exceptiongroup" version = "1.1.1" @@ -448,6 +463,29 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sshpubkeys" +version = "3.3.1" +description = "SSH public key parser" +category = "main" +optional = false +python-versions = ">=3" + +[package.dependencies] +cryptography = ">=2.1.4" +ecdsa = ">=0.13" + +[package.extras] +dev = ["twine", "wheel", "yapf"] + [[package]] name = "tomli" version = "2.0.1" @@ -512,7 +550,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-co [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "551f506d78245fe049830dbf538fc38f8e7da379fb3867da1d88ffc066ca82a9" +content-hash = "3506063e6f54793acf91d229837e57a99a041b00e8bbbf4028ec9ff5e8e49b10" [metadata.files] attrs = [] @@ -527,6 +565,7 @@ colorama = [] coverage = [] cryptography = [] docker = [] +ecdsa = [] exceptiongroup = [] flask = [] idna = [] @@ -552,6 +591,8 @@ red-val = [] requests = [] "ruamel.yaml" = [] "ruamel.yaml.clib" = [] +six = [] +sshpubkeys = [] tomli = [] urllib3 = [] websocket-client = [] diff --git a/pyproject.toml b/pyproject.toml index 0b078ea..211c422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ Flask = "^2.3.2" cc-agency = "^9.1.1" pytest = "^7.3.1" pytest-cov = "^4.0.0" +sshpubkeys = "^3.3.1" [tool.poetry.dev-dependencies]