From d72d7520941e5f3869a59315124ee25645fc12ac Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 10:11:36 +0100 Subject: [PATCH 01/33] migration and background in progress --- mwdb/app.py | 6 + mwdb/model/__init__.py | 3 +- mwdb/model/file.py | 127 +++++++++++++++ .../3c610b0ddebc_add_related_files_table.py | 36 +++++ mwdb/model/object.py | 5 + mwdb/resources/file.py | 145 +++++++++++++++++- mwdb/web/src/commons/api/index.js | 4 + 7 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py diff --git a/mwdb/app.py b/mwdb/app.py index 384db861a..451d32efa 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -44,6 +44,8 @@ FileDownloadZipResource, FileItemResource, FileResource, + RelatedFileDownloadResource, + RelatedFileItemResource, ) from mwdb.resources.group import GroupListResource, GroupMemberResource, GroupResource from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource @@ -259,6 +261,10 @@ def require_auth(): api.add_resource(FileDownloadResource, "/file//download") api.add_resource(FileDownloadZipResource, "/file//download/zip") +# RelatedFiles endpoints +api.add_resource(RelatedFileItemResource, "/related_file/") +api.add_resource(RelatedFileDownloadResource, "/related_file//download") + # Config endpoints api.add_resource(ConfigResource, "/config") api.add_resource(ConfigStatsResource, "/config/stats") diff --git a/mwdb/model/__init__.py b/mwdb/model/__init__.py index 2d6323e44..443f33ffc 100644 --- a/mwdb/model/__init__.py +++ b/mwdb/model/__init__.py @@ -47,7 +47,7 @@ def after_cursor_execute(conn, cursor, statement, parameters, context, executema from .blob import TextBlob # noqa: E402 from .comment import Comment # noqa: E402 from .config import Config, StaticConfig # noqa: E402 -from .file import File # noqa: E402 +from .file import File, RelatedFile # noqa: E402 from .group import Group, Member # noqa: E402 from .karton import KartonAnalysis, karton_object # noqa: E402 from .oauth import OpenIDProvider, OpenIDUserIdentity # noqa: E402 @@ -76,6 +76,7 @@ def after_cursor_execute(conn, cursor, statement, parameters, context, executema "OpenIDProvider", "OpenIDUserIdentity", "relation", + "RelatedFile", "QuickQuery", "Tag", "User", diff --git a/mwdb/model/file.py b/mwdb/model/file.py index f8d3b3090..7990cd210 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -9,6 +9,7 @@ from sqlalchemy.dialects.postgresql.array import ARRAY from sqlalchemy.ext.mutable import MutableList from werkzeug.utils import secure_filename +from flask import g from mwdb.core.auth import AuthScope, generate_token, verify_token from mwdb.core.config import StorageProviderType, app_config @@ -328,3 +329,129 @@ def get_by_download_token(download_token): def _send_to_karton(self): return send_file_to_karton(self) + + +class RelatedFile(db.Model): + __tablename__ = "related_file" + + id = db.Column(db.Integer, primary_key=True) + object_id = db.Column( + db.Integer, + db.ForeignKey("object.id", ondelete="CASCADE"), + nullable=False, + ) + file_name = db.Column(db.String, nullable=False) + file_size = db.Column(db.Integer, nullable=False) + sha256 = db.Column(db.String, nullable=False) + + related_object = db.relationship( + "Object", + back_populates="related_files", + lazy=True, + ) + + def _calculate_path(self): + if app_config.mwdb.storage_provider == StorageProviderType.DISK: + upload_path = ( + "related_files" + if app_config.mwdb.related_files_folder == "" + else app_config.mwdb.related_files_folder + "/related_files" + ) + elif app_config.mwdb.storage_provider == StorageProviderType.S3: + upload_path = "related_files/" + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} is not supported" + ) + + sample_sha256 = self.sha256.lower() + + if app_config.mwdb.hash_pathing: + # example: related_files/9/f/8/6/9f86d0818... + upload_path = os.path.join(upload_path, *list(sample_sha256)[0:4]) + + if app_config.mwdb.storage_provider == StorageProviderType.DISK: + upload_path = os.path.abspath(upload_path) + os.makedirs(upload_path, mode=0o755, exist_ok=True) + + return os.path.join(upload_path, sample_sha256) + + @classmethod + def create( + cls, + file_name, + file_stream, + related_object, + ): + file_stream.seek(0, os.SEEK_END) + file_size = file_stream.tell() + if file_size == 0: + raise EmptyFileError + + sha256 = calc_hash(file_stream, hashlib.sha256(), lambda h: h.hexdigest()) + + new_related_file = ( + db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() + ) + + # If file already exists + if new_related_file is not None: + return + + new_related_file = RelatedFile( + object_id=related_object.id, + file_name=secure_filename(file_name), + file_size=file_size, + sha256=sha256, + ) + + file_stream.seek(0, os.SEEK_SET) + if app_config.mwdb.storage_provider == StorageProviderType.S3: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).put_object( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=new_related_file._calculate_path(), + Body=file_stream, + ) + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + with open(new_related_file._calculate_path(), "wb") as f: + shutil.copyfileobj(file_stream, f) + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} " + f"is not supported" + ) + + @classmethod + def access(cls, identifier): + related_file_obj = db.session.query(RelatedFile).filter(RelatedFile.sha256 == identifier).first() + if related_file_obj is None: + return None + if not g.auth_user.has_access_to_object(related_file_obj.object_id): + return None + + return related_file_obj + + def iterate(self, chunk_size=1024 * 256): + """ + Iterates over bytes in the file contents + """ + fh = self.open() + try: + if hasattr(fh, "stream"): + yield from fh.stream(chunk_size) + else: + while True: + chunk = fh.read(chunk_size) + if chunk: + yield chunk + else: + return + finally: + fh.close() \ No newline at end of file diff --git a/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py b/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py new file mode 100644 index 000000000..4baefba68 --- /dev/null +++ b/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py @@ -0,0 +1,36 @@ +"""add related_files table + +Revision ID: 3c610b0ddebc +Revises: c7c72fd7fac5 +Create Date: 2022-12-12 09:02:40.406370 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3c610b0ddebc' +down_revision = 'c7c72fd7fac5' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "related_file", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("object_id", sa.Integer(), nullable=False), + sa.Column("file_name", sa.String(), nullable=False), + sa.Column("file_size", sa.Integer, nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.ForeignKeyConstraint( + ["object_id"], + ["object.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("related_file") diff --git a/mwdb/model/object.py b/mwdb/model/object.py index e60e3bfb3..6a946a15d 100644 --- a/mwdb/model/object.py +++ b/mwdb/model/object.py @@ -257,6 +257,11 @@ class Object(db.Model): lazy="joined", cascade="save-update, merge, delete", ) + related_files = db.relationship( + "RelatedFile", + back_populates="related_object", + lazy=True, + ) followers = db.relationship( "User", secondary=favorites, back_populates="favorites", lazy="joined" diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index be2fad3cd..c60e4148b 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -5,7 +5,7 @@ from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks from mwdb.core.rate_limit import rate_limited_resource -from mwdb.model import File +from mwdb.model import File, RelatedFile from mwdb.model.file import EmptyFileError from mwdb.model.object import ObjectTypeConflictError from mwdb.schema.file import ( @@ -585,3 +585,146 @@ def post(self, identifier): download_token = file.generate_download_token() schema = FileDownloadTokenResponseSchema() return schema.dump({"token": download_token}) + + +@rate_limited_resource +class RelatedFileDownloadResource(Resource): + def get(self, identifier): + """ + --- + summary: Download related file + description: | + Returns related file contents. + security: + - bearerAuth: [] + tags: + - related_file + parameters: + - in: path + name: identifier + schema: + type: string + description: File identifier (SHA256) + required: true + responses: + 200: + description: File contents + content: + application/octet-stream: + schema: + type: string + format: binary + 404: + description: | + When related file doesn't exist + or user doesn't have access to it. + 503: + description: | + Request canceled due to database statement timeout. + """ + + if not g.auth_user: + raise Unauthorized("Not authenticated.") + + related_file_obj = RelatedFile.access(identifier) + + if related_file_obj is None: + raise NotFound("Object not found") + + return Response( + related_file_obj.iterate(), + content_type="application/octet-stream", + headers={"Content-disposition": f"attachment; filename={related_file_obj.sha256}"}, + ) + + +@rate_limited_resource +class RelatedFileItemResource(Resource): + @requires_authorization + @requires_capabilities(Capabilities.adding_files) + def post(self, identifier): + """ + --- + summary: Upload file + description: | + Uploads a new file. + + Requires `adding_files` capability. + security: + - bearerAuth: [] + tags: + - related_file + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: RelatedFile contents to be uploaded + identifier: + type: string + description: | + sha256 of the relating file + required: + - file + - identifier + responses: + 200: + description: Information about uploaded file + 400: + description: RelatedFile is empty + 409: + description: RelatedFile already exists + 503: + description: | + Request canceled due to database statement timeout. + """ + try: + RelatedFile.create( + request.files["file"].filename, + request.files["file"].stream, + identifier + ) + except EmptyFileError: + raise BadRequest("RelatedFile cannot be empty") + + return "OK" #self.create_object(obj["options"]) + + @requires_authorization + @requires_capabilities(Capabilities.removing_objects) + def delete(self, identifier): + """ + --- + summary: Delete file + description: | + Removes a file from the database along with its references. + + Requires `removing_objects` capability. + security: + - bearerAuth: [] + tags: + - file + parameters: + - in: path + name: identifier + schema: + type: string + description: File identifier (SHA256/SHA512/SHA1/MD5) + responses: + 200: + description: When file was deleted + 403: + description: When user doesn't have `removing_objects` capability + 404: + description: | + When file doesn't exist, object is not a file + or user doesn't have access to this object. + 503: + description: | + Request canceled due to database statement timeout. + """ + return super().delete(identifier) \ No newline at end of file diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 847c2be97..5a8be281f 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -444,6 +444,10 @@ function uploadFile(file, parent, upload_as, attributes, fileUploadTimeout) { return axios.post(`/file`, formData, { timeout: fileUploadTimeout }); } +//function uploadRelatedFile(file, id) { +// return axios.post(`/related_file/${id}`) +//} + function getRemoteNames() { return axios.get("/remote"); } From c33d5c17ce06af7366e238e9c2acc6f12d5c309c Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 11:19:33 +0100 Subject: [PATCH 02/33] working upload (I think) --- mwdb/app.py | 4 ++- mwdb/model/file.py | 32 +++++++++++++------ .../3c610b0ddebc_add_related_files_table.py | 7 ++-- mwdb/resources/file.py | 30 ++++++++++------- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/mwdb/app.py b/mwdb/app.py index 451d32efa..088181278 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -263,7 +263,9 @@ def require_auth(): # RelatedFiles endpoints api.add_resource(RelatedFileItemResource, "/related_file/") -api.add_resource(RelatedFileDownloadResource, "/related_file//download") +api.add_resource( + RelatedFileDownloadResource, "/related_file//download" +) # Config endpoints api.add_resource(ConfigResource, "/config") diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 7990cd210..81b311c33 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -5,11 +5,11 @@ import tempfile import pyzipper +from flask import g from sqlalchemy import or_ from sqlalchemy.dialects.postgresql.array import ARRAY from sqlalchemy.ext.mutable import MutableList from werkzeug.utils import secure_filename -from flask import g from mwdb.core.auth import AuthScope, generate_token, verify_token from mwdb.core.config import StorageProviderType, app_config @@ -352,11 +352,12 @@ class RelatedFile(db.Model): def _calculate_path(self): if app_config.mwdb.storage_provider == StorageProviderType.DISK: - upload_path = ( - "related_files" - if app_config.mwdb.related_files_folder == "" - else app_config.mwdb.related_files_folder + "/related_files" - ) + # upload_path = ( + # "related_files" + # if app_config.mwdb.related_files_folder == "" + # else app_config.mwdb.related_files_folder + "/related_files" + # ) + upload_path = "/app/uploads/related_files" elif app_config.mwdb.storage_provider == StorageProviderType.S3: upload_path = "related_files/" else: @@ -381,7 +382,7 @@ def create( cls, file_name, file_stream, - related_object, + related_object_dhash, ): file_stream.seek(0, os.SEEK_END) file_size = file_stream.tell() @@ -393,11 +394,20 @@ def create( new_related_file = ( db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() ) + related_object = ( + db.session.query(Object) + .filter(Object.dhash == related_object_dhash) + .first() + ) # If file already exists if new_related_file is not None: return + # If related file doesn't exist + if related_object is None: + raise ValueError("There is no object with this sha256") + new_related_file = RelatedFile( object_id=related_object.id, file_name=secure_filename(file_name), @@ -430,7 +440,11 @@ def create( @classmethod def access(cls, identifier): - related_file_obj = db.session.query(RelatedFile).filter(RelatedFile.sha256 == identifier).first() + related_file_obj = ( + db.session.query(RelatedFile) + .filter(RelatedFile.sha256 == identifier) + .first() + ) if related_file_obj is None: return None if not g.auth_user.has_access_to_object(related_file_obj.object_id): @@ -454,4 +468,4 @@ def iterate(self, chunk_size=1024 * 256): else: return finally: - fh.close() \ No newline at end of file + fh.close() diff --git a/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py b/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py index 4baefba68..21d625637 100644 --- a/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py +++ b/mwdb/model/migrations/versions/3c610b0ddebc_add_related_files_table.py @@ -5,13 +5,12 @@ Create Date: 2022-12-12 09:02:40.406370 """ -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. -revision = '3c610b0ddebc' -down_revision = 'c7c72fd7fac5' +revision = "3c610b0ddebc" +down_revision = "c7c72fd7fac5" branch_labels = None depends_on = None diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index c60e4148b..04abd3b5a 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -634,7 +634,9 @@ def get(self, identifier): return Response( related_file_obj.iterate(), content_type="application/octet-stream", - headers={"Content-disposition": f"attachment; filename={related_file_obj.sha256}"}, + headers={ + "Content-disposition": f"attachment; filename={related_file_obj.sha256}" + }, ) @@ -654,6 +656,13 @@ def post(self, identifier): - bearerAuth: [] tags: - related_file + parameters: + - in: path + name: identifier + schema: + type: string + description: File identifier (SHA256) + required: true requestBody: required: true content: @@ -665,18 +674,15 @@ def post(self, identifier): type: string format: binary description: RelatedFile contents to be uploaded - identifier: - type: string - description: | - sha256 of the relating file required: - file - - identifier responses: 200: description: Information about uploaded file 400: description: RelatedFile is empty + 404: + description: There is no file with provided sha256 409: description: RelatedFile already exists 503: @@ -685,14 +691,14 @@ def post(self, identifier): """ try: RelatedFile.create( - request.files["file"].filename, - request.files["file"].stream, - identifier + request.files["file"].filename, request.files["file"].stream, identifier ) except EmptyFileError: raise BadRequest("RelatedFile cannot be empty") - - return "OK" #self.create_object(obj["options"]) + except ValueError: + raise NotFound("There is no file with provided sha256") + + return "OK" # self.create_object(obj["options"]) @requires_authorization @requires_capabilities(Capabilities.removing_objects) @@ -727,4 +733,4 @@ def delete(self, identifier): description: | Request canceled due to database statement timeout. """ - return super().delete(identifier) \ No newline at end of file + return super().delete(identifier) From da5f8bc79b19c9d592fed2a34828bd1e15289838 Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 11:30:18 +0100 Subject: [PATCH 03/33] adding related files in database --- mwdb/model/file.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 81b311c33..09618ca68 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -437,6 +437,8 @@ def create( f"StorageProvider {app_config.mwdb.storage_provider} " f"is not supported" ) + db.session.add(new_related_file) + db.session.commit() @classmethod def access(cls, identifier): From f9b30065d135d7fdc3f2c97ad192cadbbc2f758e Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 14:54:24 +0100 Subject: [PATCH 04/33] (probably) working backend --- mwdb/model/file.py | 71 ++++++++++++++++++++++++++++++++- mwdb/resources/file.py | 89 +++++++++++++++++++++++++++++++++++------- mwdb/schema/file.py | 11 ++++++ 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 09618ca68..36ece00ae 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -406,7 +406,9 @@ def create( # If related file doesn't exist if related_object is None: - raise ValueError("There is no object with this sha256") + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) new_related_file = RelatedFile( object_id=related_object.id, @@ -449,11 +451,76 @@ def access(cls, identifier): ) if related_file_obj is None: return None - if not g.auth_user.has_access_to_object(related_file_obj.object_id): + + main_obj = ( + db.session.query(Object) + .filter(Object.id == related_file_obj.object_id) + .first() + ) + if not main_obj.has_explicit_access(g.auth_user): return None return related_file_obj + @classmethod + def delete(cls, identifier): + related_file_obj = ( + db.session.query(RelatedFile) + .filter(RelatedFile.sha256 == identifier) + .first() + ) + + if related_file_obj is None: + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) + main_obj = ( + db.session.query(Object) + .filter(Object.id == related_file_obj.object_id) + .first() + ) + if not main_obj.has_explicit_access(g.auth_user): + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) + + db.session.delete(related_file_obj) + db.session.commit() + return + + def open(self): + """ + Opens the related file stream with contents. + """ + if app_config.mwdb.storage_provider == StorageProviderType.S3: + # Stream coming from Boto3 get_object is not buffered and not seekable. + # We need to download it to the temporary file first. + stream = tempfile.TemporaryFile(mode="w+b") + try: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).download_fileobj( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=self._calculate_path(), + Fileobj=stream, + ) + stream.seek(0, io.SEEK_SET) + return stream + except Exception: + stream.close() + raise + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + return open(self._calculate_path(), "rb") + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} is not supported" + ) + def iterate(self, chunk_size=1024 * 256): """ Iterates over bytes in the file contents diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index 04abd3b5a..773570639 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -5,7 +5,7 @@ from mwdb.core.capabilities import Capabilities from mwdb.core.plugins import hooks from mwdb.core.rate_limit import rate_limited_resource -from mwdb.model import File, RelatedFile +from mwdb.model import File, Object, RelatedFile, db from mwdb.model.file import EmptyFileError from mwdb.model.object import ObjectTypeConflictError from mwdb.schema.file import ( @@ -14,6 +14,7 @@ FileItemResponseSchema, FileLegacyCreateRequestSchema, FileListResponseSchema, + RelatedFileResponseSchema, ) from . import load_schema, requires_authorization, requires_capabilities @@ -604,11 +605,11 @@ def get(self, identifier): name: identifier schema: type: string - description: File identifier (SHA256) + description: RelatedFile identifier (SHA256) required: true responses: 200: - description: File contents + description: RelatedFile contents content: application/octet-stream: schema: @@ -642,14 +643,62 @@ def get(self, identifier): @rate_limited_resource class RelatedFileItemResource(Resource): + def get(self, identifier): + """ + --- + summary: Get list of RelatedFiles + description: | + Returns list of RelatedFiles for a File specified by sha256 + security: + - bearerAuth: [] + tags: + - related_file + parameters: + - in: path + name: identifier + schema: + type: string + description: Master File identifier (SHA256) + required: true + responses: + 200: + description: List of RelatedFiles + content: + application/json: + schema: RelatedFileResponseSchema + 404: + description: | + There is no file with provided sha256 or you don't have access to it + 503: + description: | + Request canceled due to database statement timeout. + """ + master_object = ( + db.session.query(Object) + .filter(Object.dhash == identifier) + .filter(g.auth_user.has_access_to_object(Object.id)) + ).first() + + if master_object is None: + raise NotFound( + "There is no file with provided sha256 or you don't have access to it" + ) + + related_files = db.session.query(RelatedFile).filter( + RelatedFile.object_id == master_object.id + ) + schema = RelatedFileResponseSchema() + + return schema.dump({"related_files": related_files}) + @requires_authorization @requires_capabilities(Capabilities.adding_files) def post(self, identifier): """ --- - summary: Upload file + summary: Upload related file description: | - Uploads a new file. + Uploads a new related file. Requires `adding_files` capability. security: @@ -661,7 +710,7 @@ def post(self, identifier): name: identifier schema: type: string - description: File identifier (SHA256) + description: Master File identifier (SHA256) required: true requestBody: required: true @@ -678,11 +727,12 @@ def post(self, identifier): - file responses: 200: - description: Information about uploaded file + description: OK 400: description: RelatedFile is empty 404: - description: There is no file with provided sha256 + description: | + There is no file with provided sha256 or you don't have access to it 409: description: RelatedFile already exists 503: @@ -696,9 +746,11 @@ def post(self, identifier): except EmptyFileError: raise BadRequest("RelatedFile cannot be empty") except ValueError: - raise NotFound("There is no file with provided sha256") + raise NotFound( + "There is no file with provided sha256 or you don't have access to it" + ) - return "OK" # self.create_object(obj["options"]) + return Response("OK") @requires_authorization @requires_capabilities(Capabilities.removing_objects) @@ -713,24 +765,31 @@ def delete(self, identifier): security: - bearerAuth: [] tags: - - file + - related_file parameters: - in: path name: identifier schema: type: string - description: File identifier (SHA256/SHA512/SHA1/MD5) + description: RelatedFile identifier (SHA256/SHA512/SHA1/MD5) responses: 200: - description: When file was deleted + description: When related file was deleted 403: description: When user doesn't have `removing_objects` capability 404: description: | - When file doesn't exist, object is not a file + When related file doesn't exist or user doesn't have access to this object. 503: description: | Request canceled due to database statement timeout. """ - return super().delete(identifier) + try: + RelatedFile.delete(identifier) + except ValueError: + raise NotFound( + "There is no file with provided sha256 or you don't have access to it" + ) + + return Response("OK") diff --git a/mwdb/schema/file.py b/mwdb/schema/file.py index 8825135ce..0f8e1581a 100644 --- a/mwdb/schema/file.py +++ b/mwdb/schema/file.py @@ -73,3 +73,14 @@ class FileItemResponseSchema(ObjectItemResponseSchema): class FileDownloadTokenResponseSchema(Schema): token = fields.Str(required=True, allow_none=False) + + +class RelatedFileItemResponseSchema(Schema): + file_name = fields.Str(required=True, allow_none=False) + file_size = fields.Int(required=True, allow_none=False) + + +class RelatedFileResponseSchema(Schema): + related_files = fields.Nested( + RelatedFileItemResponseSchema, many=True, required=True, allow_none=False + ) From 434e5e8a27d6aa6150b00cda34c0612bbc13422c Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 15:05:03 +0100 Subject: [PATCH 05/33] Raise exception when the same related file is uploaded second time --- mwdb/model/file.py | 2 +- mwdb/resources/file.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 36ece00ae..8965c3d19 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -402,7 +402,7 @@ def create( # If file already exists if new_related_file is not None: - return + raise FileExistsError("Related file with this sha256 already exists") # If related file doesn't exist if related_object is None: diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index 773570639..29fcc53bc 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -749,6 +749,8 @@ def post(self, identifier): raise NotFound( "There is no file with provided sha256 or you don't have access to it" ) + except FileExistsError: + raise Conflict("Related file with this sha256 already exists") return Response("OK") From 6f57b7e0d028c3a3a8a03616c2ccab750b416757 Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 17:07:33 +0100 Subject: [PATCH 06/33] Initial front-end commit --- mwdb/schema/file.py | 1 + mwdb/web/src/commons/api/index.js | 7 +- .../ShowObject/Views/RelatedFilesTab.js | 69 +++++++++++++++++++ mwdb/web/src/components/ShowObject/index.js | 1 + mwdb/web/src/components/ShowSample.js | 2 + 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js diff --git a/mwdb/schema/file.py b/mwdb/schema/file.py index 0f8e1581a..b0a9114f3 100644 --- a/mwdb/schema/file.py +++ b/mwdb/schema/file.py @@ -78,6 +78,7 @@ class FileDownloadTokenResponseSchema(Schema): class RelatedFileItemResponseSchema(Schema): file_name = fields.Str(required=True, allow_none=False) file_size = fields.Int(required=True, allow_none=False) + sha256 = fields.Str(required=True, allow_none=False) class RelatedFileResponseSchema(Schema): diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 5a8be281f..772cd8c3c 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -444,9 +444,9 @@ function uploadFile(file, parent, upload_as, attributes, fileUploadTimeout) { return axios.post(`/file`, formData, { timeout: fileUploadTimeout }); } -//function uploadRelatedFile(file, id) { -// return axios.post(`/related_file/${id}`) -//} +function getListOfRelatedFiles(id) { + return axios.get(`/related_file/${id}`) +} function getRemoteNames() { return axios.get("/remote"); @@ -630,6 +630,7 @@ const api = { requestFileDownloadLink, requestZipFileDownloadLink, uploadFile, + getListOfRelatedFiles, getRemoteNames, pushObjectRemote, pullObjectRemote, diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js new file mode 100644 index 000000000..9e2bd0111 --- /dev/null +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -0,0 +1,69 @@ +import React, { useContext, useState } from "react"; +import { Link } from "react-router-dom"; + +import { faPlus, faExternalLinkSquare } from "@fortawesome/free-solid-svg-icons"; + +import { ObjectContext } from "@mwdb-web/commons/context"; +import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; +import { APIContext } from "@mwdb-web/commons/api/context"; + +function RelatedFileItem({ file_name, file_size, sha256 }){ + console.log(file_name); + return ( + + + {file_name} + + + {file_size} + + + + Pobierz + + + + ); +} + +function ShowRelatedFiles(){ + const [relatedFiles, setRelatedFiles] = useState([]); + + async function updateRelatedFiles(){ + try{ + let response = await api.getListOfRelatedFiles(context.object.sha256); + setRelatedFiles(response.data.related_files); + } catch (error) { + console.log(error); + } + } + + const api = useContext(APIContext); + const context = useContext(ObjectContext); + + updateRelatedFiles(); + + return ( + + {relatedFiles.map((related_file) => ( + + ))} +
+ ); +} + +export default function RelatedFilesTab(){ + return ( + , + ]} + /> + ) +} \ No newline at end of file diff --git a/mwdb/web/src/components/ShowObject/index.js b/mwdb/web/src/components/ShowObject/index.js index e8be8f093..f8af59d65 100644 --- a/mwdb/web/src/components/ShowObject/index.js +++ b/mwdb/web/src/components/ShowObject/index.js @@ -1,6 +1,7 @@ export { default as ShowObject } from "./ShowObject"; export { default as RelationsTab } from "./Views/RelationsTab"; +export { default as RelatedFilesTab } from "./Views/RelatedFilesTab" export { default as LatestConfigTab } from "./Views/LatestConfigTab"; export { default as DownloadAction } from "./Actions/DownloadAction"; diff --git a/mwdb/web/src/components/ShowSample.js b/mwdb/web/src/components/ShowSample.js index 1c87015d1..251815b87 100644 --- a/mwdb/web/src/components/ShowSample.js +++ b/mwdb/web/src/components/ShowSample.js @@ -8,6 +8,7 @@ import { useTabContext, LatestConfigTab, RelationsTab, + RelatedFilesTab, DownloadAction, ZipAction, FavoriteAction, @@ -335,6 +336,7 @@ export default function ShowSample(props) { , ]} /> + ); From 735fc1fb949361e16f0201e7955fd2517f7cdfce Mon Sep 17 00:00:00 2001 From: Repumba Date: Mon, 12 Dec 2022 17:35:39 +0100 Subject: [PATCH 07/33] Front-end adjustments --- .../ShowObject/Views/RelatedFilesTab.js | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 9e2bd0111..eeed963f8 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -1,7 +1,8 @@ import React, { useContext, useState } from "react"; import { Link } from "react-router-dom"; -import { faPlus, faExternalLinkSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faExternalLinkSquare, faDownload } from "@fortawesome/free-solid-svg-icons"; import { ObjectContext } from "@mwdb-web/commons/context"; import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; @@ -10,18 +11,23 @@ import { APIContext } from "@mwdb-web/commons/api/context"; function RelatedFileItem({ file_name, file_size, sha256 }){ console.log(file_name); return ( - + {file_name} - {file_size} + {file_size} B { + + }} > - Pobierz + +  Download @@ -46,7 +52,18 @@ function ShowRelatedFiles(){ updateRelatedFiles(); return ( - +
+ + + + + {relatedFiles.map((related_file) => ( ))} @@ -62,7 +79,7 @@ export default function RelatedFilesTab(){ icon = {faExternalLinkSquare} component = {ShowRelatedFiles} actions={[ - , + , ]} /> ) From 1fd981a9a3a896a52c2ef95bb59f4b1e4df48c7e Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 10:19:19 +0100 Subject: [PATCH 08/33] Fix problem with too many rerenders --- .../components/ShowObject/Views/RelatedFilesTab.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index eeed963f8..5ef548cc0 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -49,7 +49,14 @@ function ShowRelatedFiles(){ const api = useContext(APIContext); const context = useContext(ObjectContext); - updateRelatedFiles(); + const getRelatedFiles = useCallback(updateRelatedFiles, [ + api, + context + ]); + + useEffect(() => { + getRelatedFiles(); + }, [getRelatedFiles]); return (
+ File name + + File size + + Download +
From 8dd170e1fdcff252ba76884e6b6628014af3d84d Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 12:30:48 +0100 Subject: [PATCH 09/33] progress in front-end --- mwdb/web/src/commons/api/index.js | 19 +- .../ShowObject/Views/RelatedFilesTab.js | 174 ++++++++++++------ mwdb/web/src/components/ShowObject/index.js | 2 +- 3 files changed, 134 insertions(+), 61 deletions(-) diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 772cd8c3c..064095908 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -444,8 +444,22 @@ function uploadFile(file, parent, upload_as, attributes, fileUploadTimeout) { return axios.post(`/file`, formData, { timeout: fileUploadTimeout }); } +function uploadRelatedFile(file, masterFileDhash) { + let formData = new FormData(); + formData.append("file", file); + return axios.post(`/related_file/${masterFileDhash}`, formData); +} + +function downloadRelatedFile(id) { + return axios.get(`/related_file/${id}/download`); +} + +function deleteRelatedFile(id) { + return axios.delete(`/related_file/${id}`); +} + function getListOfRelatedFiles(id) { - return axios.get(`/related_file/${id}`) + return axios.get(`/related_file/${id}`); } function getRemoteNames() { @@ -630,6 +644,9 @@ const api = { requestFileDownloadLink, requestZipFileDownloadLink, uploadFile, + uploadRelatedFile, + downloadRelatedFile, + deleteRelatedFile, getListOfRelatedFiles, getRemoteNames, pushObjectRemote, diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 5ef548cc0..e20726aa9 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -2,57 +2,69 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPlus, faExternalLinkSquare, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { + faPlus, + faExternalLinkSquare, + faDownload, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; import { ObjectContext } from "@mwdb-web/commons/context"; import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; import { APIContext } from "@mwdb-web/commons/api/context"; +import ReactModal from "react-modal"; -function RelatedFileItem({ file_name, file_size, sha256 }){ - console.log(file_name); - return ( - - - - - - ); -} - -function ShowRelatedFiles(){ +function ShowRelatedFiles() { const [relatedFiles, setRelatedFiles] = useState([]); + const api = useContext(APIContext); + const context = useContext(ObjectContext); - async function updateRelatedFiles(){ - try{ - let response = await api.getListOfRelatedFiles(context.object.sha256); + function RelatedFileItem({ file_name, file_size, sha256 }) { + return ( + + + + + + + ); + } + + async function updateRelatedFiles() { + try { + let response = await api.getListOfRelatedFiles( + context.object.sha256 + ); setRelatedFiles(response.data.related_files); } catch (error) { console.log(error); } } - const api = useContext(APIContext); - const context = useContext(ObjectContext); - - const getRelatedFiles = useCallback(updateRelatedFiles, [ - api, - context - ]); + const getRelatedFiles = useCallback(updateRelatedFiles, [api, context]); useEffect(() => { getRelatedFiles(); @@ -61,15 +73,10 @@ function ShowRelatedFiles(){ return (
- {file_name} - - {file_size} B - - { - - }} - > - -  Download - -
{file_name}{file_size} B + { + console.log(sha256); + await api.downloadRelatedFile(sha256); + }} + > + +  Download + + + { + await api.deleteRelatedFile(sha256); + }} + > + +  Remove + +
- - - + + + + {relatedFiles.map((related_file) => ( @@ -78,16 +85,65 @@ function ShowRelatedFiles(){ ); } -export default function RelatedFilesTab(){ +export default function RelatedFilesTab() { + const [showModal, setShowModal] = useState(); + const [file, setFile] = useState(null); + const context = useContext(ObjectContext); + const api = useContext(APIContext); + + async function handleSubmit() { + try { + await api.uploadRelatedFile(file, context.object.sha256); + } catch (error) { + console.log(error); + } + } + return ( - , - ]} - /> - ) -} \ No newline at end of file +
+ { + setShowModal(true); + }} + />, + ]} + /> + { + setShowModal(false); + }} + > +
{ + handleSubmit(); + setShowModal(false); + }} + > + setFile(data)} + />{" "} +
+ + + +
+
+ ); +} diff --git a/mwdb/web/src/components/ShowObject/index.js b/mwdb/web/src/components/ShowObject/index.js index f8af59d65..afb75a817 100644 --- a/mwdb/web/src/components/ShowObject/index.js +++ b/mwdb/web/src/components/ShowObject/index.js @@ -1,7 +1,7 @@ export { default as ShowObject } from "./ShowObject"; export { default as RelationsTab } from "./Views/RelationsTab"; -export { default as RelatedFilesTab } from "./Views/RelatedFilesTab" +export { default as RelatedFilesTab } from "./Views/RelatedFilesTab"; export { default as LatestConfigTab } from "./Views/LatestConfigTab"; export { default as DownloadAction } from "./Actions/DownloadAction"; From 55ea824d2907c97ad278b53539a1825dfaec8c37 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 13:25:50 +0100 Subject: [PATCH 10/33] working download --- mwdb/web/src/commons/api/index.js | 5 ++++- .../components/ShowObject/Views/RelatedFilesTab.js | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 064095908..537e26030 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -451,7 +451,10 @@ function uploadRelatedFile(file, masterFileDhash) { } function downloadRelatedFile(id) { - return axios.get(`/related_file/${id}/download`); + return axios.get(`/related_file/${id}/download`, { + responseType: "arraybuffer", + responseEncoding: "binary", + }); } function deleteRelatedFile(id) { diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index e20726aa9..334d5290b 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -26,11 +26,18 @@ function ShowRelatedFiles() {
+ - + - - - - + + + + {relatedFiles.map((related_file) => ( From 2c7cdcef78614346a05b830f5242627eee8f015c Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 14 Dec 2022 10:18:16 +0100 Subject: [PATCH 16/33] auto rerender tab after change --- .../ShowObject/Views/RelatedFilesTab.js | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index f3d56d905..5f4448c2b 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -15,8 +15,19 @@ import { APIContext } from "@mwdb-web/commons/api/context"; import { humanFileSize } from "@mwdb-web/commons/helpers"; import ReactModal from "react-modal"; +async function updateRelatedFiles(api, context) { + const { setObjectError, updateObjectData } = context; + try { + let response = await api.getListOfRelatedFiles(context.object.sha256); + updateObjectData({ + related_files: response.data.related_files, + }); + } catch (error) { + setObjectError(error); + } +} + function ShowRelatedFiles() { - const [relatedFiles, setRelatedFiles] = useState([]); const api = useContext(APIContext); const context = useContext(ObjectContext); const { setObjectError, updateObjectData } = context; @@ -52,7 +63,7 @@ function ShowRelatedFiles() { className="nav-link" onClick={async () => { await api.deleteRelatedFile(sha256); - updateRelatedFiles(); + updateRelatedFiles(api, context); }} > @@ -63,19 +74,6 @@ function ShowRelatedFiles() { ); } - async function updateRelatedFiles() { - try { - let response = await api.getListOfRelatedFiles( - context.object.sha256 - ); - setRelatedFiles(response.data.related_files); - updateObjectData({ - related_files: response.data.related_files, - }); - } catch (error) { - setObjectError(error); - } - } const getRelatedFiles = useCallback(updateRelatedFiles, [ api, setObjectError, @@ -83,10 +81,18 @@ function ShowRelatedFiles() { context.object.sha256, ]); + // JS throws a warning "Line 92:8: React Hook useEffect has missing dependencies: 'api' and 'context'" + // Those dependencies are skipped on purpose + // To disable this warning I used 'eslint-disable-next-line' useEffect(() => { - getRelatedFiles(); + getRelatedFiles(api, context); + // eslint-disable-next-line }, [getRelatedFiles]); + if (!context.object.related_files) { + return "Loading..."; + } + return (
- File name - - File size - - Download - File nameFile sizeDownloadRemove
{file_size} B { - console.log(sha256); - await api.downloadRelatedFile(sha256); + let content = await api.downloadRelatedFile(sha256); + let blob = new Blob([content.data], { + type: "application/octet-stream", + }); + let tempLink = document.createElement("a"); + tempLink.style.display = "none"; + tempLink.href = window.URL.createObjectURL(blob); + tempLink.download = file_name; + tempLink.click(); }} > From d247ee2f454a00946172990b2c885caf6820049c Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 13:27:20 +0100 Subject: [PATCH 11/33] little fix in removing related files --- mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 334d5290b..9445b1f8a 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -46,7 +46,7 @@ function ShowRelatedFiles() { { await api.deleteRelatedFile(sha256); From d589b0cd4088e84c986c930fe0494856e0477358 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 14:00:40 +0100 Subject: [PATCH 12/33] Working upload --- .../src/components/ShowObject/Views/RelatedFilesTab.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 9445b1f8a..369ce622d 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -130,15 +130,23 @@ export default function RelatedFilesTab() { }} >
{ handleSubmit(); setShowModal(false); }} > setFile(data)} + onChange={() => + setFile( + document.forms["RelatedFileUploadForm"][ + "RelatedFileUploadField" + ].files[0] + ) + } />{" "}
From eb666673f46383871e852f685f755370db452b7c Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 17:21:24 +0100 Subject: [PATCH 13/33] almost working front-end with better error handling --- .../ShowObject/Views/RelatedFilesTab.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 369ce622d..5ea9c7fd8 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -18,6 +18,7 @@ function ShowRelatedFiles() { const [relatedFiles, setRelatedFiles] = useState([]); const api = useContext(APIContext); const context = useContext(ObjectContext); + const { setObjectError, updateObjectData } = context; function RelatedFileItem({ file_name, file_size, sha256 }) { return ( @@ -50,6 +51,7 @@ function ShowRelatedFiles() { className="nav-link" onClick={async () => { await api.deleteRelatedFile(sha256); + updateRelatedFiles(); }} > @@ -66,12 +68,19 @@ function ShowRelatedFiles() { context.object.sha256 ); setRelatedFiles(response.data.related_files); + updateObjectData({ + related_files: response.data.related_files, + }); } catch (error) { - console.log(error); + setObjectError(error); } } - - const getRelatedFiles = useCallback(updateRelatedFiles, [api, context]); + const getRelatedFiles = useCallback(updateRelatedFiles, [ + api, + setObjectError, + updateObjectData, + context.object.sha256, + ]); useEffect(() => { getRelatedFiles(); @@ -97,12 +106,13 @@ export default function RelatedFilesTab() { const [file, setFile] = useState(null); const context = useContext(ObjectContext); const api = useContext(APIContext); + const { setObjectError } = context; async function handleSubmit() { try { await api.uploadRelatedFile(file, context.object.sha256); } catch (error) { - console.log(error); + setObjectError(error); } } @@ -134,6 +144,7 @@ export default function RelatedFilesTab() { onSubmit={() => { handleSubmit(); setShowModal(false); + ShowRelatedFiles().updateRelatedFiles(); }} > Date: Tue, 13 Dec 2022 17:48:38 +0100 Subject: [PATCH 14/33] visual improvements --- .../ShowObject/Views/RelatedFilesTab.js | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 5ea9c7fd8..5c72ea2e0 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -13,6 +13,7 @@ import { ObjectContext } from "@mwdb-web/commons/context"; import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; import { APIContext } from "@mwdb-web/commons/api/context"; import ReactModal from "react-modal"; +import { useDropzone } from "react-dropzone"; function ShowRelatedFiles() { const [relatedFiles, setRelatedFiles] = useState([]); @@ -108,6 +109,17 @@ export default function RelatedFilesTab() { const api = useContext(APIContext); const { setObjectError } = context; + const modalStyle = { + content: { + top: "50%", + left: "50%", + right: "auto", + bottom: "auto", + marginRight: "-50%", + transform: "translate(-50%, -50%)", + }, + }; + async function handleSubmit() { try { await api.uploadRelatedFile(file, context.object.sha256); @@ -138,6 +150,7 @@ export default function RelatedFilesTab() { onRequestClose={() => { setShowModal(false); }} + style={modalStyle} > {" "} -
- + /> + - ); From 3ee25b09c44b6848ccd611d1263aa13710060d05 Mon Sep 17 00:00:00 2001 From: Repumba Date: Tue, 13 Dec 2022 18:01:22 +0100 Subject: [PATCH 15/33] more visual improvements --- .../components/ShowObject/Views/RelatedFilesTab.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 5c72ea2e0..f3d56d905 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -12,8 +12,8 @@ import { import { ObjectContext } from "@mwdb-web/commons/context"; import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; import { APIContext } from "@mwdb-web/commons/api/context"; +import { humanFileSize } from "@mwdb-web/commons/helpers"; import ReactModal from "react-modal"; -import { useDropzone } from "react-dropzone"; function ShowRelatedFiles() { const [relatedFiles, setRelatedFiles] = useState([]); @@ -23,9 +23,9 @@ function ShowRelatedFiles() { function RelatedFileItem({ file_name, file_size, sha256 }) { return ( -
{file_name}{file_size} B{humanFileSize(file_size)}
File nameFile sizeDownloadRemoveFile nameFile sizeDownloadRemove
@@ -95,7 +101,7 @@ function ShowRelatedFiles() { - {relatedFiles.map((related_file) => ( + {context.object.related_files.map((related_file) => ( ))}
Download Remove
@@ -123,6 +129,7 @@ export default function RelatedFilesTab() { async function handleSubmit() { try { await api.uploadRelatedFile(file, context.object.sha256); + updateRelatedFiles(api, context); } catch (error) { setObjectError(error); } From 252cc2fc910b79253c3367d6971cb1c2aa89350b Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 14 Dec 2022 12:38:58 +0100 Subject: [PATCH 17/33] rename variables for clarity --- mwdb/model/file.py | 10 +++++----- .../src/components/ShowObject/Views/RelatedFilesTab.js | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 8965c3d19..e50250a69 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -382,7 +382,7 @@ def create( cls, file_name, file_stream, - related_object_dhash, + main_obj_dhash, ): file_stream.seek(0, os.SEEK_END) file_size = file_stream.tell() @@ -394,9 +394,9 @@ def create( new_related_file = ( db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() ) - related_object = ( + main_obj = ( db.session.query(Object) - .filter(Object.dhash == related_object_dhash) + .filter(Object.dhash == main_obj_dhash) .first() ) @@ -405,13 +405,13 @@ def create( raise FileExistsError("Related file with this sha256 already exists") # If related file doesn't exist - if related_object is None: + if main_obj is None: raise ValueError( "There is no object with this sha256 or you don't have access" ) new_related_file = RelatedFile( - object_id=related_object.id, + object_id=main_obj.id, file_name=secure_filename(file_name), file_size=file_size, sha256=sha256, diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 5f4448c2b..6289539b1 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -81,7 +81,7 @@ function ShowRelatedFiles() { context.object.sha256, ]); - // JS throws a warning "Line 92:8: React Hook useEffect has missing dependencies: 'api' and 'context'" + // JS throws a warning "Line 90:8: React Hook useEffect has missing dependencies: 'api' and 'context'" // Those dependencies are skipped on purpose // To disable this warning I used 'eslint-disable-next-line' useEffect(() => { @@ -164,7 +164,6 @@ export default function RelatedFilesTab() { onSubmit={() => { handleSubmit(); setShowModal(false); - ShowRelatedFiles().updateRelatedFiles(); }} > Date: Wed, 14 Dec 2022 14:40:40 +0100 Subject: [PATCH 18/33] RelatedFiles Capabilities, migration and small visual improvement --- docs/user-guide/9-Sharing-objects.rst | 47 ++++++++++----- mwdb/core/capabilities.py | 6 ++ mwdb/model/file.py | 4 +- ...5_assign_new_capabilities_related_files.py | 60 +++++++++++++++++++ mwdb/resources/file.py | 31 +++++++--- mwdb/web/src/commons/auth/capabilities.js | 6 ++ .../ShowObject/Views/RelatedFilesTab.js | 5 +- 7 files changed, 130 insertions(+), 29 deletions(-) create mode 100644 mwdb/model/migrations/versions/02f584212ea5_assign_new_capabilities_related_files.py diff --git a/docs/user-guide/9-Sharing-objects.rst b/docs/user-guide/9-Sharing-objects.rst index dfb5b1e4b..d8bb111f2 100644 --- a/docs/user-guide/9-Sharing-objects.rst +++ b/docs/user-guide/9-Sharing-objects.rst @@ -155,47 +155,47 @@ By default, ``admin`` private group has enabled all capabilities. All other grou Each capability has its own name and scope: -* +* **manage_users - Managing users and groups (system administration)** Allows to access all users and groups in MWDB. Rules described in *Who is who?* don't apply to users with that permission. Enables user to create new user accounts, new groups and change their capabilities and membership. Allows to manage attribute keys, define new ones, delete and set the group permissions for them. -* +* **share_queried_objects - Query for all objects in system** That one is a bit tricky and will be possibly deprecated. MWDB will automatically share object and all descendants with group if member directly accessed it via identifier (knows the hash e.g. have direct link to the object). It can be used for bot accounts, so they have access only to these objects that are intended to be processed by them. Internally, we abandoned that idea, so that capability may not be stable. -* +* **access_all_objects - Has access to all new uploaded objects into system** Capability used by ``everything`` group, useful when you want to make additional "everything" that is separate from the original one. Keep in mind that it applies only to the **uploads made during the capability was enabled**\ , so if you want the new group to be truly "everything", you may need to share the old objects manually. -* +* **sharing_with_all - Can share objects with all groups in system** Implies the access to the list of all group names, but without access to the membership information and management features. Allows to share object with arbitrary group in MWDB. -* +* **adding_tags - Can add tags** Allows to tag objects. This feature is disabled by default, as you may want to have only tags from automated analyses. -* +* **removing_tags - Can remove tags** Allows to remove tags. Tag doesn't have "owner", so user will be able to remove all tags from the object. -* +* **adding_comments - Can add comments** Allows to add comments to the objects. Keep in mind that comments are public. -* +* **removing_comments - Can remove (all) comments** Allows to remove **all** comments, not only these authored by the user. -* +* **adding_parents - Can add parents** Allows to add new relationships by specifying object parent during upload or adding new relationship between existing objects. @@ -210,22 +210,22 @@ Each capability has its own name and scope: Enables upload of files. Enabled by default for ``registered`` group. -* +* **adding_configs - Can upload configs** Enables upload of configurations. Configurations are intended to be uploaded by automated systems or trusted entities that follow the conventions. -* +* **adding_blobs - Can upload text blobs** Enables upload of blobs. Blobs may have similar meaning as configurations in terms of user roles. -* +* **reading_all_attributes - Has access to all attributes of object (including hidden)** With that capability, you can read all the attributes, even if you don't have ``read`` permission for that attribute key. It allows to list hidden attribute values. -* +* **adding_all_attributes - Can add all attributes to object** Enables group to add all the attributes, even if it doesn't have ``set`` permission for that attribute key. @@ -235,12 +235,12 @@ Each capability has its own name and scope: Allows to remove attribute from object. To remove attribute, you need to have ``set`` permission for key. Combined with ``adding_all_attributes``\ , allows to remove all attributes. -* +* **unlimited_requests - API requests are not rate-limited for this group** Disables rate limiting for users from that group, if rate limiting feature is enabled. -* +* **removing_objects - Can remove objects** Can remove all accessible objects from the MWDB. May be quite destructive, we suggest to keep that capability enabled only for ``admin`` account. @@ -255,7 +255,7 @@ Each capability has its own name and scope: Allows to use personalization features like favorites or quick queries. -* +* **karton_assign - Can assign existing analysis to the object** Allows to assign Karton analysis to the object by setting ``karton`` attribute or using dedicated API. @@ -264,6 +264,21 @@ Each capability has its own name and scope: **karton_reanalyze - Can resubmit any object for analysis** Can manually resubmit object to Karton. +* + **access_related_files - Can view and download RelatedFiles** + + Allows to view list of RelatedFiles and download them. + +* + **adding_related_files - Can upload new RelatedFiles** + + Allows to upload new RelatedFiles. + +* + **removing_related_files - removing_related_files** + + Allows to remove existing RelatedFiles. + User capabilities are the sum of all group capabilities. If you want to enable capability system-wide (e.g. enable all users to add tags), enable that capability for ``registered`` group or ``public`` group if you want to include guests. diff --git a/mwdb/core/capabilities.py b/mwdb/core/capabilities.py index 4c5718975..f21a2e8d4 100644 --- a/mwdb/core/capabilities.py +++ b/mwdb/core/capabilities.py @@ -45,6 +45,12 @@ class Capabilities(object): karton_reanalyze = "karton_reanalyze" # Can remove Karton analysis from the object karton_unassign = "karton_unassign" + # Can view and download RelatedFiles + access_related_files = "access_related_files" + # Can upload new RelatedFiles + adding_related_files = "adding_related_files" + # Can remove existing RelatedFiles + removing_related_files = "removing_related_files" @classmethod def all(cls): diff --git a/mwdb/model/file.py b/mwdb/model/file.py index e50250a69..3a60f6027 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -395,9 +395,7 @@ def create( db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() ) main_obj = ( - db.session.query(Object) - .filter(Object.dhash == main_obj_dhash) - .first() + db.session.query(Object).filter(Object.dhash == main_obj_dhash).first() ) # If file already exists diff --git a/mwdb/model/migrations/versions/02f584212ea5_assign_new_capabilities_related_files.py b/mwdb/model/migrations/versions/02f584212ea5_assign_new_capabilities_related_files.py new file mode 100644 index 000000000..5da6d05bf --- /dev/null +++ b/mwdb/model/migrations/versions/02f584212ea5_assign_new_capabilities_related_files.py @@ -0,0 +1,60 @@ +"""assign new capabilities for related files + +Revision ID: 02f584212ea5 +Revises: 3c610b0ddebc +Create Date: 2022-12-14 12:03:22.613573 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "02f584212ea5" +down_revision = "3c610b0ddebc" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + UPDATE public.group + SET capabilities = array_append(capabilities, 'access_related_files') + WHERE name='public' OR array_position(capabilities, 'manage_users') IS NOT NULL; + """ + ) + op.execute( + """ + UPDATE public.group + SET capabilities = array_append(capabilities, 'adding_related_files') + WHERE name='registered' OR array_position(capabilities, 'manage_users') IS NOT NULL; + """ + ) + op.execute( + """ + UPDATE public.group + SET capabilities = array_append(capabilities, 'removing_related_files') + WHERE array_position(capabilities, 'manage_users') IS NOT NULL; + """ + ) + + +def downgrade(): + op.execute( + """ + UPDATE public.group + SET capabilities = array_remove(capabilities, 'access_related_files'); + """ + ) + op.execute( + """ + UPDATE public.group + SET capabilities = array_remove(capabilities, 'adding_related_files'); + """ + ) + op.execute( + """ + UPDATE public.group + SET capabilities = array_remove(capabilities, 'removing_related_files'); + """ + ) diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index 29fcc53bc..b9dd3afc4 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -590,12 +590,16 @@ def post(self, identifier): @rate_limited_resource class RelatedFileDownloadResource(Resource): + @requires_authorization + @requires_capabilities(Capabilities.access_related_files) def get(self, identifier): """ --- summary: Download related file description: | Returns related file contents. + + Requires `access_related_files` capability. security: - bearerAuth: [] tags: @@ -615,6 +619,8 @@ def get(self, identifier): schema: type: string format: binary + 403: + description: When user doesn't have `access_related_files` capability 404: description: | When related file doesn't exist @@ -684,15 +690,20 @@ def get(self, identifier): "There is no file with provided sha256 or you don't have access to it" ) - related_files = db.session.query(RelatedFile).filter( - RelatedFile.object_id == master_object.id - ) - schema = RelatedFileResponseSchema() + if g.auth_user.has_rights(Capabilities.access_related_files): + related_files = ( + db.session.query(RelatedFile) + .filter(RelatedFile.object_id == master_object.id) + .all() + ) + else: + related_files = [] + schema = RelatedFileResponseSchema() return schema.dump({"related_files": related_files}) @requires_authorization - @requires_capabilities(Capabilities.adding_files) + @requires_capabilities(Capabilities.adding_related_files) def post(self, identifier): """ --- @@ -700,7 +711,7 @@ def post(self, identifier): description: | Uploads a new related file. - Requires `adding_files` capability. + Requires `adding_related_files` capability. security: - bearerAuth: [] tags: @@ -730,6 +741,8 @@ def post(self, identifier): description: OK 400: description: RelatedFile is empty + 403: + description: When user doesn't have `adding_related_files` capability 404: description: | There is no file with provided sha256 or you don't have access to it @@ -755,7 +768,7 @@ def post(self, identifier): return Response("OK") @requires_authorization - @requires_capabilities(Capabilities.removing_objects) + @requires_capabilities(Capabilities.removing_related_files) def delete(self, identifier): """ --- @@ -763,7 +776,7 @@ def delete(self, identifier): description: | Removes a file from the database along with its references. - Requires `removing_objects` capability. + Requires `removing_related_files` capability. security: - bearerAuth: [] tags: @@ -778,7 +791,7 @@ def delete(self, identifier): 200: description: When related file was deleted 403: - description: When user doesn't have `removing_objects` capability + description: When user doesn't have `removing_related_files` capability 404: description: | When related file doesn't exist diff --git a/mwdb/web/src/commons/auth/capabilities.js b/mwdb/web/src/commons/auth/capabilities.js index 73119f1a4..2d3f2f706 100644 --- a/mwdb/web/src/commons/auth/capabilities.js +++ b/mwdb/web/src/commons/auth/capabilities.js @@ -24,6 +24,9 @@ export const Capability = { kartonAssign: "karton_assign", kartonReanalyze: "karton_reanalyze", removingKarton: "karton_unassign", + accessRelatedFiles: "access_related_files", + addingRelatedFiles: "adding_related_files", + removingRelatedFiles: "removing_related_files", }; export let capabilitiesList = { @@ -56,6 +59,9 @@ export let capabilitiesList = { "Can assign existing analysis to the object (required by karton-mwdb-reporter)", [Capability.kartonReanalyze]: "Can resubmit any object for analysis", [Capability.removingKarton]: "Can remove analysis from object", + [Capability.accessRelatedFiles]: "Can view and download RelatedFiles", + [Capability.addingRelatedFiles]: "Can upload new RelatedFiles", + [Capability.removingRelatedFiles]: "Can remove existing RelatedFiles", }; for (let extraCapabilities of fromPlugin("capabilities")) { diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index 6289539b1..d96c31a32 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -90,7 +90,10 @@ function ShowRelatedFiles() { }, [getRelatedFiles]); if (!context.object.related_files) { - return "Loading..."; + return
Loading...
; + } + if (context.object.related_files.length === 0) { + return
Nothing to show here
; } return ( From 561418f5a3e2e7e2ad4bbd2c03e8c1462cd120e7 Mon Sep 17 00:00:00 2001 From: Repumba Date: Wed, 14 Dec 2022 16:31:36 +0100 Subject: [PATCH 19/33] allow one RelatedFile to be associated with many Files, delete RelatedFile when not linked with anything --- mwdb/app.py | 5 + mwdb/model/file.py | 142 ++++++++++++------ mwdb/resources/file.py | 15 +- mwdb/web/src/commons/api/index.js | 4 +- .../ShowObject/Views/RelatedFilesTab.js | 5 +- 5 files changed, 116 insertions(+), 55 deletions(-) diff --git a/mwdb/app.py b/mwdb/app.py index 088181278..5ef735754 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -44,6 +44,7 @@ FileDownloadZipResource, FileItemResource, FileResource, + RelatedFileDeleteResource, RelatedFileDownloadResource, RelatedFileItemResource, ) @@ -266,6 +267,10 @@ def require_auth(): api.add_resource( RelatedFileDownloadResource, "/related_file//download" ) +api.add_resource( + RelatedFileDeleteResource, + "/related_file//delete/", +) # Config endpoints api.add_resource(ConfigResource, "/config") diff --git a/mwdb/model/file.py b/mwdb/model/file.py index 3a60f6027..cbce92af3 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -6,7 +6,7 @@ import pyzipper from flask import g -from sqlalchemy import or_ +from sqlalchemy import not_, or_ from sqlalchemy.dialects.postgresql.array import ARRAY from sqlalchemy.ext.mutable import MutableList from werkzeug.utils import secure_filename @@ -391,23 +391,32 @@ def create( sha256 = calc_hash(file_stream, hashlib.sha256(), lambda h: h.hexdigest()) - new_related_file = ( - db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() - ) main_obj = ( db.session.query(Object).filter(Object.dhash == main_obj_dhash).first() ) - - # If file already exists - if new_related_file is not None: - raise FileExistsError("Related file with this sha256 already exists") - - # If related file doesn't exist - if main_obj is None: + # If main file doesn't exist or no access + if main_obj is None or not main_obj.has_explicit_access(g.auth_user): raise ValueError( "There is no object with this sha256 or you don't have access" ) + is_new = True + new_related_file = ( + db.session.query(RelatedFile).filter(RelatedFile.sha256 == sha256).first() + ) + # If RelatedFile already exists + if new_related_file is not None: + is_new = False + new_related_file = ( + db.session.query(RelatedFile) + .filter(RelatedFile.sha256 == sha256) + .filter(RelatedFile.object_id == main_obj.id) + .first() + ) + # If RelatedFile related to main_obj already exists + if new_related_file is not None: + raise FileExistsError("Related file with this sha256 already exists") + new_related_file = RelatedFile( object_id=main_obj.id, file_name=secure_filename(file_name), @@ -415,56 +424,69 @@ def create( sha256=sha256, ) - file_stream.seek(0, os.SEEK_SET) - if app_config.mwdb.storage_provider == StorageProviderType.S3: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).put_object( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=new_related_file._calculate_path(), - Body=file_stream, - ) - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - with open(new_related_file._calculate_path(), "wb") as f: - shutil.copyfileobj(file_stream, f) - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} " - f"is not supported" - ) + if is_new: + file_stream.seek(0, os.SEEK_SET) + if app_config.mwdb.storage_provider == StorageProviderType.S3: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).put_object( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=new_related_file._calculate_path(), + Body=file_stream, + ) + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + with open(new_related_file._calculate_path(), "wb") as f: + shutil.copyfileobj(file_stream, f) + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} " + f"is not supported" + ) db.session.add(new_related_file) db.session.commit() @classmethod def access(cls, identifier): - related_file_obj = ( - db.session.query(RelatedFile) - .filter(RelatedFile.sha256 == identifier) - .first() + related_files = ( + db.session.query(RelatedFile).filter(RelatedFile.sha256 == identifier).all() ) - if related_file_obj is None: + # Empty list - no such RelatedFile + if not related_files: return None + main_obj_ids = [rf.object_id for rf in related_files] main_obj = ( db.session.query(Object) - .filter(Object.id == related_file_obj.object_id) + .filter(Object.id.in_(main_obj_ids)) + .filter(g.auth_user.has_access_to_object(Object.id)) .first() ) - if not main_obj.has_explicit_access(g.auth_user): + if main_obj is None: return None - return related_file_obj + return related_files[0] @classmethod - def delete(cls, identifier): + def delete(cls, identifier, main_file_identifier): + main_obj = ( + db.session.query(Object) + .filter(Object.dhash == main_file_identifier) + .first() + ) + if not main_obj.has_explicit_access(g.auth_user): + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) + related_file_obj = ( db.session.query(RelatedFile) .filter(RelatedFile.sha256 == identifier) + .filter(RelatedFile.object_id == main_obj.id) .first() ) @@ -472,15 +494,37 @@ def delete(cls, identifier): raise ValueError( "There is no object with this sha256 or you don't have access" ) - main_obj = ( - db.session.query(Object) - .filter(Object.id == related_file_obj.object_id) + + is_last = False + other_related_file_obj = ( + db.session.query(RelatedFile) + .filter(RelatedFile.sha256 == identifier) + .filter(not_(RelatedFile.object_id == main_obj.id)) .first() ) - if not main_obj.has_explicit_access(g.auth_user): - raise ValueError( - "There is no object with this sha256 or you don't have access" - ) + if other_related_file_obj is None: + is_last = True + + if is_last: + if app_config.mwdb.storage_provider == StorageProviderType.S3: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).delete_object( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=related_file_obj._calculate_path(), + ) + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + os.remove(related_file_obj._calculate_path()) + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} " + f"is not supported" + ) db.session.delete(related_file_obj) db.session.commit() diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index b9dd3afc4..71eb62d5d 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -767,9 +767,11 @@ def post(self, identifier): return Response("OK") + +class RelatedFileDeleteResource(Resource): @requires_authorization @requires_capabilities(Capabilities.removing_related_files) - def delete(self, identifier): + def delete(self, identifier, main_file_identifier): """ --- summary: Delete file @@ -784,9 +786,16 @@ def delete(self, identifier): parameters: - in: path name: identifier + required: true + schema: + type: string + description: RelatedFile identifier (SHA256) + - in: path + name: main_file_identifier + required: true schema: type: string - description: RelatedFile identifier (SHA256/SHA512/SHA1/MD5) + description: Main file identifier (SHA256) responses: 200: description: When related file was deleted @@ -801,7 +810,7 @@ def delete(self, identifier): Request canceled due to database statement timeout. """ try: - RelatedFile.delete(identifier) + RelatedFile.delete(identifier, main_file_identifier) except ValueError: raise NotFound( "There is no file with provided sha256 or you don't have access to it" diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 537e26030..197d53a34 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -457,8 +457,8 @@ function downloadRelatedFile(id) { }); } -function deleteRelatedFile(id) { - return axios.delete(`/related_file/${id}`); +function deleteRelatedFile(id, main_file_id) { + return axios.delete(`/related_file/${id}/delete/${main_file_id}`); } function getListOfRelatedFiles(id) { diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index d96c31a32..f3b40f3c7 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -62,7 +62,10 @@ function ShowRelatedFiles() { to={`/file/${context.object.sha256}/related_files`} className="nav-link" onClick={async () => { - await api.deleteRelatedFile(sha256); + await api.deleteRelatedFile( + sha256, + context.object.sha256 + ); updateRelatedFiles(api, context); }} > From a58c7edf6d145681deb23859381905ed099c10e1 Mon Sep 17 00:00:00 2001 From: Repumba Date: Thu, 15 Dec 2022 14:04:19 +0100 Subject: [PATCH 20/33] implement requested changes --- mwdb/app.py | 12 +- mwdb/core/file_util.py | 102 ++++++++++ mwdb/model/file.py | 187 +++--------------- mwdb/resources/file.py | 177 ++++++++++------- mwdb/web/src/commons/api/index.js | 16 +- mwdb/web/src/commons/auth/capabilities.js | 6 +- .../ShowObject/Views/RelatedFilesTab.js | 123 ++++++------ 7 files changed, 322 insertions(+), 301 deletions(-) create mode 100644 mwdb/core/file_util.py diff --git a/mwdb/app.py b/mwdb/app.py index 5ef735754..a4afb3a4b 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -44,9 +44,8 @@ FileDownloadZipResource, FileItemResource, FileResource, - RelatedFileDeleteResource, - RelatedFileDownloadResource, RelatedFileItemResource, + RelatedFileResource, ) from mwdb.resources.group import GroupListResource, GroupMemberResource, GroupResource from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource @@ -263,13 +262,14 @@ def require_auth(): api.add_resource(FileDownloadZipResource, "/file//download/zip") # RelatedFiles endpoints -api.add_resource(RelatedFileItemResource, "/related_file/") api.add_resource( - RelatedFileDownloadResource, "/related_file//download" + RelatedFileResource, + "///related_file", ) api.add_resource( - RelatedFileDeleteResource, - "/related_file//delete/", + RelatedFileItemResource, + "//" + "/related_file/", ) # Config endpoints diff --git a/mwdb/core/file_util.py b/mwdb/core/file_util.py new file mode 100644 index 000000000..bd8320d4e --- /dev/null +++ b/mwdb/core/file_util.py @@ -0,0 +1,102 @@ +import io +import os +import shutil +import tempfile + +from mwdb.core.config import StorageProviderType, app_config +from mwdb.core.util import get_s3_client + + +def write_to_storage(file_stream, file_object): + file_stream.seek(0, os.SEEK_SET) + if app_config.mwdb.storage_provider == StorageProviderType.S3: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).put_object( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=file_object._calculate_path(), + Body=file_stream, + ) + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + with open(file_object._calculate_path(), "wb") as f: + shutil.copyfileobj(file_stream, f) + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} " f"is not supported" + ) + + +def get_from_storage(file_object): + if app_config.mwdb.storage_provider == StorageProviderType.S3: + # Stream coming from Boto3 get_object is not buffered and not seekable. + # We need to download it to the temporary file first. + stream = tempfile.TemporaryFile(mode="w+b") + try: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).download_fileobj( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=file_object._calculate_path(), + Fileobj=stream, + ) + stream.seek(0, io.SEEK_SET) + return stream + except Exception: + stream.close() + raise + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + return open(file_object._calculate_path(), "rb") + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} is not supported" + ) + + +def delete_from_storage(file_object): + if app_config.mwdb.storage_provider == StorageProviderType.S3: + get_s3_client( + app_config.mwdb.s3_storage_endpoint, + app_config.mwdb.s3_storage_access_key, + app_config.mwdb.s3_storage_secret_key, + app_config.mwdb.s3_storage_region_name, + app_config.mwdb.s3_storage_secure, + app_config.mwdb.s3_storage_iam_auth, + ).delete_object( + Bucket=app_config.mwdb.s3_storage_bucket_name, + Key=file_object._calculate_path(), + ) + elif app_config.mwdb.storage_provider == StorageProviderType.DISK: + os.remove(file_object._calculate_path()) + else: + raise RuntimeError( + f"StorageProvider {app_config.mwdb.storage_provider} " f"is not supported" + ) + + +def iterate_buffer(file_object, chunk_size=1024 * 256): + """ + Iterates over bytes in the file contents + """ + fh = file_object.open() + try: + if hasattr(fh, "stream"): + yield from fh.stream(chunk_size) + else: + while True: + chunk = fh.read(chunk_size) + if chunk: + yield chunk + else: + return + finally: + fh.close() diff --git a/mwdb/model/file.py b/mwdb/model/file.py index cbce92af3..e3987551a 100644 --- a/mwdb/model/file.py +++ b/mwdb/model/file.py @@ -13,15 +13,14 @@ from mwdb.core.auth import AuthScope, generate_token, verify_token from mwdb.core.config import StorageProviderType, app_config -from mwdb.core.karton import send_file_to_karton -from mwdb.core.util import ( - calc_crc32, - calc_hash, - calc_magic, - calc_ssdeep, - get_fd_path, - get_s3_client, +from mwdb.core.file_util import ( + delete_from_storage, + get_from_storage, + iterate_buffer, + write_to_storage, ) +from mwdb.core.karton import send_file_to_karton +from mwdb.core.util import calc_crc32, calc_hash, calc_magic, calc_ssdeep, get_fd_path from . import db from .object import Object @@ -126,28 +125,7 @@ def get_or_create( file_obj.alt_names.append(original_filename) if is_new: - file_stream.seek(0, os.SEEK_SET) - if app_config.mwdb.storage_provider == StorageProviderType.S3: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).put_object( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=file_obj._calculate_path(), - Body=file_stream, - ) - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - with open(file_obj._calculate_path(), "wb") as f: - shutil.copyfileobj(file_stream, f) - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} " - f"is not supported" - ) + write_to_storage(file_stream, file_obj) file_obj.upload_stream = file_stream return file_obj, is_new @@ -223,34 +201,7 @@ def open(self): stream = os.fdopen(dupfd, "rb") stream.seek(0, os.SEEK_SET) return stream - if app_config.mwdb.storage_provider == StorageProviderType.S3: - # Stream coming from Boto3 get_object is not buffered and not seekable. - # We need to download it to the temporary file first. - stream = tempfile.TemporaryFile(mode="w+b") - try: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).download_fileobj( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=self._calculate_path(), - Fileobj=stream, - ) - stream.seek(0, io.SEEK_SET) - return stream - except Exception: - stream.close() - raise - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - return open(self._calculate_path(), "rb") - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} is not supported" - ) + return get_from_storage(self) def read(self): """ @@ -425,54 +376,37 @@ def create( ) if is_new: - file_stream.seek(0, os.SEEK_SET) - if app_config.mwdb.storage_provider == StorageProviderType.S3: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).put_object( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=new_related_file._calculate_path(), - Body=file_stream, - ) - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - with open(new_related_file._calculate_path(), "wb") as f: - shutil.copyfileobj(file_stream, f) - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} " - f"is not supported" - ) + write_to_storage(file_stream, new_related_file) + db.session.add(new_related_file) db.session.commit() @classmethod - def access(cls, identifier): - related_files = ( - db.session.query(RelatedFile).filter(RelatedFile.sha256 == identifier).all() + def access(cls, main_obj_identifier, identifier): + main_obj = ( + db.session.query(Object).filter(Object.dhash == main_obj_identifier).first() ) - # Empty list - no such RelatedFile - if not related_files: - return None + if not main_obj.has_explicit_access(g.auth_user): + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) - main_obj_ids = [rf.object_id for rf in related_files] - main_obj = ( - db.session.query(Object) - .filter(Object.id.in_(main_obj_ids)) - .filter(g.auth_user.has_access_to_object(Object.id)) + related_file = ( + db.session.query(RelatedFile) + .filter(RelatedFile.sha256 == identifier) + .filter(RelatedFile.object_id == main_obj.id) .first() ) - if main_obj is None: - return None + # Empty list - no such RelatedFile + if related_file is None: + raise ValueError( + "There is no object with this sha256 or you don't have access" + ) - return related_files[0] + return related_file @classmethod - def delete(cls, identifier, main_file_identifier): + def delete(cls, main_file_identifier, identifier): main_obj = ( db.session.query(Object) .filter(Object.dhash == main_file_identifier) @@ -506,25 +440,7 @@ def delete(cls, identifier, main_file_identifier): is_last = True if is_last: - if app_config.mwdb.storage_provider == StorageProviderType.S3: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).delete_object( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=related_file_obj._calculate_path(), - ) - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - os.remove(related_file_obj._calculate_path()) - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} " - f"is not supported" - ) + delete_from_storage(related_file_obj) db.session.delete(related_file_obj) db.session.commit() @@ -534,49 +450,10 @@ def open(self): """ Opens the related file stream with contents. """ - if app_config.mwdb.storage_provider == StorageProviderType.S3: - # Stream coming from Boto3 get_object is not buffered and not seekable. - # We need to download it to the temporary file first. - stream = tempfile.TemporaryFile(mode="w+b") - try: - get_s3_client( - app_config.mwdb.s3_storage_endpoint, - app_config.mwdb.s3_storage_access_key, - app_config.mwdb.s3_storage_secret_key, - app_config.mwdb.s3_storage_region_name, - app_config.mwdb.s3_storage_secure, - app_config.mwdb.s3_storage_iam_auth, - ).download_fileobj( - Bucket=app_config.mwdb.s3_storage_bucket_name, - Key=self._calculate_path(), - Fileobj=stream, - ) - stream.seek(0, io.SEEK_SET) - return stream - except Exception: - stream.close() - raise - elif app_config.mwdb.storage_provider == StorageProviderType.DISK: - return open(self._calculate_path(), "rb") - else: - raise RuntimeError( - f"StorageProvider {app_config.mwdb.storage_provider} is not supported" - ) + return get_from_storage(self) def iterate(self, chunk_size=1024 * 256): """ Iterates over bytes in the file contents """ - fh = self.open() - try: - if hasattr(fh, "stream"): - yield from fh.stream(chunk_size) - else: - while True: - chunk = fh.read(chunk_size) - if chunk: - yield chunk - else: - return - finally: - fh.close() + return iterate_buffer(self) diff --git a/mwdb/resources/file.py b/mwdb/resources/file.py index 71eb62d5d..ec7daeffd 100644 --- a/mwdb/resources/file.py +++ b/mwdb/resources/file.py @@ -589,82 +589,29 @@ def post(self, identifier): @rate_limited_resource -class RelatedFileDownloadResource(Resource): - @requires_authorization - @requires_capabilities(Capabilities.access_related_files) - def get(self, identifier): +class RelatedFileResource(Resource): + def get(self, type, main_obj_identifier): """ --- - summary: Download related file + summary: Get list of RelatedFiles description: | - Returns related file contents. - - Requires `access_related_files` capability. + Returns list of RelatedFiles for a File specified by sha256 security: - bearerAuth: [] tags: - related_file parameters: - in: path - name: identifier + name: type schema: type: string - description: RelatedFile identifier (SHA256) - required: true - responses: - 200: - description: RelatedFile contents - content: - application/octet-stream: - schema: - type: string - format: binary - 403: - description: When user doesn't have `access_related_files` capability - 404: - description: | - When related file doesn't exist - or user doesn't have access to it. - 503: - description: | - Request canceled due to database statement timeout. - """ - - if not g.auth_user: - raise Unauthorized("Not authenticated.") - - related_file_obj = RelatedFile.access(identifier) - - if related_file_obj is None: - raise NotFound("Object not found") - - return Response( - related_file_obj.iterate(), - content_type="application/octet-stream", - headers={ - "Content-disposition": f"attachment; filename={related_file_obj.sha256}" - }, - ) - - -@rate_limited_resource -class RelatedFileItemResource(Resource): - def get(self, identifier): - """ - --- - summary: Get list of RelatedFiles - description: | - Returns list of RelatedFiles for a File specified by sha256 - security: - - bearerAuth: [] - tags: - - related_file - parameters: + enum: [file, config, blob, object] + description: Type of object - in: path - name: identifier + name: main_obj_identifier schema: type: string - description: Master File identifier (SHA256) + description: Main object identifier (SHA256) required: true responses: 200: @@ -681,7 +628,7 @@ def get(self, identifier): """ master_object = ( db.session.query(Object) - .filter(Object.dhash == identifier) + .filter(Object.dhash == main_obj_identifier) .filter(g.auth_user.has_access_to_object(Object.id)) ).first() @@ -704,7 +651,7 @@ def get(self, identifier): @requires_authorization @requires_capabilities(Capabilities.adding_related_files) - def post(self, identifier): + def post(self, type, main_obj_identifier): """ --- summary: Upload related file @@ -718,10 +665,16 @@ def post(self, identifier): - related_file parameters: - in: path - name: identifier + name: type + schema: + type: string + enum: [file, config, blob, object] + description: Type of object + - in: path + name: main_obj_identifier schema: type: string - description: Master File identifier (SHA256) + description: Main object identifier (SHA256) required: true requestBody: required: true @@ -754,7 +707,9 @@ def post(self, identifier): """ try: RelatedFile.create( - request.files["file"].filename, request.files["file"].stream, identifier + request.files["file"].filename, + request.files["file"].stream, + main_obj_identifier, ) except EmptyFileError: raise BadRequest("RelatedFile cannot be empty") @@ -768,10 +723,78 @@ def post(self, identifier): return Response("OK") -class RelatedFileDeleteResource(Resource): +class RelatedFileItemResource(Resource): + @requires_authorization + @requires_capabilities(Capabilities.access_related_files) + def get(self, type, main_obj_identifier, identifier): + """ + --- + summary: Download related file + description: | + Returns related file contents. + + Requires `access_related_files` capability. + security: + - bearerAuth: [] + tags: + - related_file + parameters: + - in: path + name: type + schema: + type: string + enum: [file, config, blob, object] + description: Type of object + - in: path + name: main_obj_identifier + required: true + schema: + type: string + description: Main object identifier (SHA256) + - in: path + name: identifier + required: true + schema: + type: string + description: RelatedFile identifier (SHA256) + responses: + 200: + description: RelatedFile contents + content: + application/octet-stream: + schema: + type: string + format: binary + 403: + description: When user doesn't have `access_related_files` capability + 404: + description: | + When related file doesn't exist + or user doesn't have access to it. + 503: + description: | + Request canceled due to database statement timeout. + """ + + if not g.auth_user: + raise Unauthorized("Not authenticated.") + + try: + related_file_obj = RelatedFile.access(main_obj_identifier, identifier) + except ValueError: + raise NotFound("Object not found") + + return Response( + related_file_obj.iterate(), + content_type="application/octet-stream", + headers={ + "Content-disposition": f"attachment; filename={related_file_obj.sha256}" + }, + ) + @requires_authorization @requires_capabilities(Capabilities.removing_related_files) - def delete(self, identifier, main_file_identifier): + def delete(self, type, main_obj_identifier, identifier): """ --- summary: Delete file @@ -785,17 +808,23 @@ def delete(self, identifier, main_file_identifier): - related_file parameters: - in: path - name: identifier + name: type + schema: + type: string + enum: [file, config, blob, object] + description: Type of object + - in: path + name: main_obj_identifier required: true schema: type: string - description: RelatedFile identifier (SHA256) + description: Main object identifier (SHA256) - in: path - name: main_file_identifier + name: identifier required: true schema: type: string - description: Main file identifier (SHA256) + description: RelatedFile identifier (SHA256) responses: 200: description: When related file was deleted @@ -810,7 +839,7 @@ def delete(self, identifier, main_file_identifier): Request canceled due to database statement timeout. """ try: - RelatedFile.delete(identifier, main_file_identifier) + RelatedFile.delete(main_obj_identifier, identifier) except ValueError: raise NotFound( "There is no file with provided sha256 or you don't have access to it" diff --git a/mwdb/web/src/commons/api/index.js b/mwdb/web/src/commons/api/index.js index 197d53a34..a1c839282 100644 --- a/mwdb/web/src/commons/api/index.js +++ b/mwdb/web/src/commons/api/index.js @@ -444,25 +444,25 @@ function uploadFile(file, parent, upload_as, attributes, fileUploadTimeout) { return axios.post(`/file`, formData, { timeout: fileUploadTimeout }); } -function uploadRelatedFile(file, masterFileDhash) { +function uploadRelatedFile(file, mainFileDhash, type = "object") { let formData = new FormData(); formData.append("file", file); - return axios.post(`/related_file/${masterFileDhash}`, formData); + return axios.post(`/${type}/${mainFileDhash}/related_file`, formData); } -function downloadRelatedFile(id) { - return axios.get(`/related_file/${id}/download`, { +function downloadRelatedFile(mainFileDhash, id, type = "object") { + return axios.get(`/${type}/${mainFileDhash}/related_file/${id}`, { responseType: "arraybuffer", responseEncoding: "binary", }); } -function deleteRelatedFile(id, main_file_id) { - return axios.delete(`/related_file/${id}/delete/${main_file_id}`); +function deleteRelatedFile(mainFileDhash, id, type = "object") { + return axios.delete(`/${type}/${mainFileDhash}/related_file/${id}`); } -function getListOfRelatedFiles(id) { - return axios.get(`/related_file/${id}`); +function getListOfRelatedFiles(mainFileDhash, type = "object") { + return axios.get(`/${type}/${mainFileDhash}/related_file`); } function getRemoteNames() { diff --git a/mwdb/web/src/commons/auth/capabilities.js b/mwdb/web/src/commons/auth/capabilities.js index 2d3f2f706..f3cabba79 100644 --- a/mwdb/web/src/commons/auth/capabilities.js +++ b/mwdb/web/src/commons/auth/capabilities.js @@ -59,9 +59,9 @@ export let capabilitiesList = { "Can assign existing analysis to the object (required by karton-mwdb-reporter)", [Capability.kartonReanalyze]: "Can resubmit any object for analysis", [Capability.removingKarton]: "Can remove analysis from object", - [Capability.accessRelatedFiles]: "Can view and download RelatedFiles", - [Capability.addingRelatedFiles]: "Can upload new RelatedFiles", - [Capability.removingRelatedFiles]: "Can remove existing RelatedFiles", + [Capability.accessRelatedFiles]: "Can view and download related files", + [Capability.addingRelatedFiles]: "Can upload new related files", + [Capability.removingRelatedFiles]: "Can remove existing related files", }; for (let extraCapabilities of fromPlugin("capabilities")) { diff --git a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js index f3b40f3c7..4a93d0449 100644 --- a/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js +++ b/mwdb/web/src/components/ShowObject/Views/RelatedFilesTab.js @@ -10,9 +10,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { ObjectContext } from "@mwdb-web/commons/context"; -import { ObjectAction, ObjectTab } from "@mwdb-web/commons/ui"; +import { + ObjectAction, + ObjectTab, + ConfirmationModal, +} from "@mwdb-web/commons/ui"; import { APIContext } from "@mwdb-web/commons/api/context"; -import { humanFileSize } from "@mwdb-web/commons/helpers"; +import { humanFileSize, downloadData } from "@mwdb-web/commons/helpers"; import ReactModal from "react-modal"; async function updateRelatedFiles(api, context) { @@ -27,56 +31,71 @@ async function updateRelatedFiles(api, context) { } } +function RelatedFileItem({ file_name, file_size, sha256 }) { + const api = useContext(APIContext); + const context = useContext(ObjectContext); + const [isConfirmationModalOpen, setConfirmationModalOpen] = useState(null); + + return ( + + {file_name} + {humanFileSize(file_size)} + + { + let content = await api.downloadRelatedFile( + context.object.sha256, + sha256 + ); + downloadData( + content.data, + file_name, + "application/octet-stream" + ); + }} + > + +  Download + + + + setConfirmationModalOpen(true)} + > + +  Remove + + { + setConfirmationModalOpen(false); + }} + onConfirm={async () => { + await api.deleteRelatedFile( + context.object.sha256, + sha256 + ); + updateRelatedFiles(api, context); + setConfirmationModalOpen(false); + }} + message={`Are you sure you want to delete this related file?`} + buttonStyle="btn-success" + confirmText="Yes" + /> + + + ); +} + function ShowRelatedFiles() { const api = useContext(APIContext); const context = useContext(ObjectContext); const { setObjectError, updateObjectData } = context; - function RelatedFileItem({ file_name, file_size, sha256 }) { - return ( - - {file_name} - {humanFileSize(file_size)} - - { - let content = await api.downloadRelatedFile(sha256); - let blob = new Blob([content.data], { - type: "application/octet-stream", - }); - let tempLink = document.createElement("a"); - tempLink.style.display = "none"; - tempLink.href = window.URL.createObjectURL(blob); - tempLink.download = file_name; - tempLink.click(); - }} - > - -  Download - - - - { - await api.deleteRelatedFile( - sha256, - context.object.sha256 - ); - updateRelatedFiles(api, context); - }} - > - -  Remove - - - - ); - } - const getRelatedFiles = useCallback(updateRelatedFiles, [ api, setObjectError, @@ -84,7 +103,7 @@ function ShowRelatedFiles() { context.object.sha256, ]); - // JS throws a warning "Line 90:8: React Hook useEffect has missing dependencies: 'api' and 'context'" + // JS throws a warning "Line ***: React Hook useEffect has missing dependencies: 'api' and 'context'" // Those dependencies are skipped on purpose // To disable this warning I used 'eslint-disable-next-line' useEffect(() => { @@ -177,13 +196,7 @@ export default function RelatedFilesTab() { name="RelatedFileUploadField" type="file" required="required" - onChange={() => - setFile( - document.forms["RelatedFileUploadForm"][ - "RelatedFileUploadField" - ].files[0] - ) - } + onChange={(event) => setFile(event.target.files[0])} />