Loading src/jama/settings.py +1 −0 Original line number Diff line number Diff line Loading @@ -496,6 +496,7 @@ CORS_ALLOWED_ORIGINS = _env_list("JAMA_CORS_ALLOWED_ORIGINS") CORS_ALLOW_HEADERS = ( *default_headers, "x-api-key", "x-collection-id", "x-file-chunk", "x-file-hash", "x-file-name", Loading src/jama/tests.py +12 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,18 @@ from resources.models import ( class SettingsEnvTemplateTestCase(SimpleTestCase): def test_cors_allow_headers_include_upload_headers(self): for header in [ "x-api-key", "x-collection-id", "x-file-chunk", "x-file-hash", "x-file-name", "x-origin-dir", "x-project", ]: self.assertIn(header, jama_settings.CORS_ALLOW_HEADERS) def test_default_env_file_content_lists_jama_settings_with_comments(self): var_dir = Path("/tmp/jama").resolve() content = jama_settings._default_env_file_content("testsecret", var_dir) Loading src/rpc/tests.py +48 −0 Original line number Diff line number Diff line Loading @@ -201,6 +201,7 @@ class ServiceTestCase(TestCase): content_length: str = None, api_key_auth: bool = True, origin_dir: str = None, collection_id: int = None, file_name: bytes = b"test.png", extra_headers: dict = None, ): Loading @@ -213,6 +214,8 @@ class ServiceTestCase(TestCase): headers["HTTP_X_ORIGIN_DIR"] = base64.b64encode( origin_dir.encode("utf-8") ).decode("ascii") if collection_id is not None: headers["HTTP_X_COLLECTION_ID"] = str(collection_id) headers.update( { "HTTP_X_PROJECT": str(self.test_project.pk), Loading Loading @@ -580,6 +583,51 @@ class ServiceTestCase(TestCase): models.Collection.objects.filter(title="readonly collection").exists() ) def test_upload_partial_collection_id_skips_resource_read_access(self): file_instance = self._public_download_test_file() collection = models.Collection.objects.create( title="target collection", parent=self.test_project_root_collection, project=self.test_project, ) models.ObjectPermission.objects.filter( role=self.admin_role, object_class="resource", object_pk=None ).update(object_read=False) response = views.upload_partial( self._upload_partial_request( file_instance.hash, "1/1", collection_id=collection.pk, extra_headers={"HTTP_X_ORIGIN_DIR": "not valid base64"}, ) ) self.assertEqual(response.status_code, 200) self.assertTrue(collection.resources.filter(pk=file_instance.pk).exists()) def test_upload_partial_collection_id_requires_collection_update_access(self): file_instance = self._public_download_test_file() collection = models.Collection.objects.create( title="readonly target collection", parent=self.test_project_root_collection, project=self.test_project, ) models.ObjectPermission.objects.filter( role=self.admin_role, object_class="collection", object_pk=None ).update(object_update=False) response = views.upload_partial( self._upload_partial_request( file_instance.hash, "1/1", collection_id=collection.pk, ) ) self.assertEqual(response.status_code, 403) self.assertFalse(collection.resources.filter(pk=file_instance.pk).exists()) def test_upload_metas_xls_requires_csrf_for_session_auth(self): request = self.factory.post("/rpc/metas/upload/", data={}) request.user = self.test_user Loading src/rpc/views.py +74 −6 Original line number Diff line number Diff line Loading @@ -79,6 +79,7 @@ class UploadRequestContext: @dataclass(frozen=True) class PartialUploadRequest: collection_id: int | None origin_dir: str | None file_hash: str file_name: str Loading Loading @@ -147,6 +148,25 @@ def _add_file_to_origin_collection( return collection def _add_file_to_collection_id( collection_id: int, file_instance: models.File, project: models.Project, acl: UserAccess, ) -> models.Collection: collection = models.Collection.objects.get( pk=collection_id, deleted_at__isnull=True, project=project ) resource = models.Resource.objects.get( pk=file_instance.pk, deleted_at__isnull=True, ptr_project=collection.project, ) acl.check_update(collection) collection.resources.add(resource) return collection def _partial_upload_dir(user: User, project: models.Project, file_hash: str) -> str: return "{}/{}-{}-{}".format( settings.PARTIAL_UPLOADS_DIR, user.pk, project.pk, file_hash Loading Loading @@ -340,6 +360,13 @@ def _upload_request_context( def _parse_partial_upload_request(request: HttpRequest) -> PartialUploadRequest: """Decode and validate the headers that identify an upload chunk.""" collection_id = None origin_dir = None if "X-collection-id" in request.headers: collection_id = int(request.headers["X-collection-id"]) if collection_id < 1: raise ValueError else: origin_dir = request.headers.get("X-origin-dir", None) if origin_dir: origin_dir = _decode_base64_urlquoted_header(origin_dir) Loading @@ -357,6 +384,7 @@ def _parse_partial_upload_request(request: HttpRequest) -> PartialUploadRequest: request.headers["X-file-chunk"] ) return PartialUploadRequest( collection_id=collection_id, origin_dir=origin_dir, file_hash=file_hash, file_name=file_name, Loading Loading @@ -406,6 +434,40 @@ def _attach_file_to_origin_collection( ) def _attach_file_to_collection_id( upload: PartialUploadRequest, file_instance: models.File, context: UploadRequestContext, ) -> None: """Attach the uploaded file to the collection named by X-collection-id.""" if upload.collection_id is None: return collection = _add_file_to_collection_id( upload.collection_id, file_instance, context.project, context.acl, ) logger.info( "User {}({}) added file({}) to collection({})".format( context.user.username, context.user.pk, file_instance.pk, collection.pk ) ) def _attach_file_to_requested_collection( upload: PartialUploadRequest, file_instance: models.File, context: UploadRequestContext, ) -> None: """Attach the uploaded file using X-collection-id, falling back to X-origin-dir.""" if upload.collection_id is not None: _attach_file_to_collection_id(upload, file_instance, context) return _attach_file_to_origin_collection(upload, file_instance, context) def _existing_upload_response( upload: PartialUploadRequest, context: UploadRequestContext ) -> Union[HttpResponse, None]: Loading @@ -418,11 +480,15 @@ def _existing_upload_response( return None _restore_deleted_file_if_needed(file_instance, upload.file_name, context.user) if upload.origin_dir: if upload.collection_id is not None or upload.origin_dir: try: with transaction.atomic(): _attach_file_to_origin_collection(upload, file_instance, context) except ServiceException: _attach_file_to_requested_collection(upload, file_instance, context) except ( models.Collection.DoesNotExist, models.Resource.DoesNotExist, ServiceException, ): return HttpResponse("Forbidden", status=403) return HttpResponse(file_instance.pk, status=200) Loading Loading @@ -506,9 +572,9 @@ def _import_completed_partial_upload( context.user.username, context.user.pk, file_id ) ) if upload.origin_dir: if upload.collection_id is not None or upload.origin_dir: file_instance = models.File.objects.get(id=file_id) _attach_file_to_origin_collection(upload, file_instance, context) _attach_file_to_requested_collection(upload, file_instance, context) return file_id Loading Loading @@ -553,6 +619,8 @@ def _completed_partial_upload_response( return HttpResponse(str(already_exists), status=409) except ConcurrencyError: return HttpResponse("Too Many Requests", status=429) except (models.Collection.DoesNotExist, models.Resource.DoesNotExist): return HttpResponse("Forbidden", status=403) except ServiceException: return HttpResponse("Forbidden", status=403) except FileNotFoundError: Loading Loading
src/jama/settings.py +1 −0 Original line number Diff line number Diff line Loading @@ -496,6 +496,7 @@ CORS_ALLOWED_ORIGINS = _env_list("JAMA_CORS_ALLOWED_ORIGINS") CORS_ALLOW_HEADERS = ( *default_headers, "x-api-key", "x-collection-id", "x-file-chunk", "x-file-hash", "x-file-name", Loading
src/jama/tests.py +12 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,18 @@ from resources.models import ( class SettingsEnvTemplateTestCase(SimpleTestCase): def test_cors_allow_headers_include_upload_headers(self): for header in [ "x-api-key", "x-collection-id", "x-file-chunk", "x-file-hash", "x-file-name", "x-origin-dir", "x-project", ]: self.assertIn(header, jama_settings.CORS_ALLOW_HEADERS) def test_default_env_file_content_lists_jama_settings_with_comments(self): var_dir = Path("/tmp/jama").resolve() content = jama_settings._default_env_file_content("testsecret", var_dir) Loading
src/rpc/tests.py +48 −0 Original line number Diff line number Diff line Loading @@ -201,6 +201,7 @@ class ServiceTestCase(TestCase): content_length: str = None, api_key_auth: bool = True, origin_dir: str = None, collection_id: int = None, file_name: bytes = b"test.png", extra_headers: dict = None, ): Loading @@ -213,6 +214,8 @@ class ServiceTestCase(TestCase): headers["HTTP_X_ORIGIN_DIR"] = base64.b64encode( origin_dir.encode("utf-8") ).decode("ascii") if collection_id is not None: headers["HTTP_X_COLLECTION_ID"] = str(collection_id) headers.update( { "HTTP_X_PROJECT": str(self.test_project.pk), Loading Loading @@ -580,6 +583,51 @@ class ServiceTestCase(TestCase): models.Collection.objects.filter(title="readonly collection").exists() ) def test_upload_partial_collection_id_skips_resource_read_access(self): file_instance = self._public_download_test_file() collection = models.Collection.objects.create( title="target collection", parent=self.test_project_root_collection, project=self.test_project, ) models.ObjectPermission.objects.filter( role=self.admin_role, object_class="resource", object_pk=None ).update(object_read=False) response = views.upload_partial( self._upload_partial_request( file_instance.hash, "1/1", collection_id=collection.pk, extra_headers={"HTTP_X_ORIGIN_DIR": "not valid base64"}, ) ) self.assertEqual(response.status_code, 200) self.assertTrue(collection.resources.filter(pk=file_instance.pk).exists()) def test_upload_partial_collection_id_requires_collection_update_access(self): file_instance = self._public_download_test_file() collection = models.Collection.objects.create( title="readonly target collection", parent=self.test_project_root_collection, project=self.test_project, ) models.ObjectPermission.objects.filter( role=self.admin_role, object_class="collection", object_pk=None ).update(object_update=False) response = views.upload_partial( self._upload_partial_request( file_instance.hash, "1/1", collection_id=collection.pk, ) ) self.assertEqual(response.status_code, 403) self.assertFalse(collection.resources.filter(pk=file_instance.pk).exists()) def test_upload_metas_xls_requires_csrf_for_session_auth(self): request = self.factory.post("/rpc/metas/upload/", data={}) request.user = self.test_user Loading
src/rpc/views.py +74 −6 Original line number Diff line number Diff line Loading @@ -79,6 +79,7 @@ class UploadRequestContext: @dataclass(frozen=True) class PartialUploadRequest: collection_id: int | None origin_dir: str | None file_hash: str file_name: str Loading Loading @@ -147,6 +148,25 @@ def _add_file_to_origin_collection( return collection def _add_file_to_collection_id( collection_id: int, file_instance: models.File, project: models.Project, acl: UserAccess, ) -> models.Collection: collection = models.Collection.objects.get( pk=collection_id, deleted_at__isnull=True, project=project ) resource = models.Resource.objects.get( pk=file_instance.pk, deleted_at__isnull=True, ptr_project=collection.project, ) acl.check_update(collection) collection.resources.add(resource) return collection def _partial_upload_dir(user: User, project: models.Project, file_hash: str) -> str: return "{}/{}-{}-{}".format( settings.PARTIAL_UPLOADS_DIR, user.pk, project.pk, file_hash Loading Loading @@ -340,6 +360,13 @@ def _upload_request_context( def _parse_partial_upload_request(request: HttpRequest) -> PartialUploadRequest: """Decode and validate the headers that identify an upload chunk.""" collection_id = None origin_dir = None if "X-collection-id" in request.headers: collection_id = int(request.headers["X-collection-id"]) if collection_id < 1: raise ValueError else: origin_dir = request.headers.get("X-origin-dir", None) if origin_dir: origin_dir = _decode_base64_urlquoted_header(origin_dir) Loading @@ -357,6 +384,7 @@ def _parse_partial_upload_request(request: HttpRequest) -> PartialUploadRequest: request.headers["X-file-chunk"] ) return PartialUploadRequest( collection_id=collection_id, origin_dir=origin_dir, file_hash=file_hash, file_name=file_name, Loading Loading @@ -406,6 +434,40 @@ def _attach_file_to_origin_collection( ) def _attach_file_to_collection_id( upload: PartialUploadRequest, file_instance: models.File, context: UploadRequestContext, ) -> None: """Attach the uploaded file to the collection named by X-collection-id.""" if upload.collection_id is None: return collection = _add_file_to_collection_id( upload.collection_id, file_instance, context.project, context.acl, ) logger.info( "User {}({}) added file({}) to collection({})".format( context.user.username, context.user.pk, file_instance.pk, collection.pk ) ) def _attach_file_to_requested_collection( upload: PartialUploadRequest, file_instance: models.File, context: UploadRequestContext, ) -> None: """Attach the uploaded file using X-collection-id, falling back to X-origin-dir.""" if upload.collection_id is not None: _attach_file_to_collection_id(upload, file_instance, context) return _attach_file_to_origin_collection(upload, file_instance, context) def _existing_upload_response( upload: PartialUploadRequest, context: UploadRequestContext ) -> Union[HttpResponse, None]: Loading @@ -418,11 +480,15 @@ def _existing_upload_response( return None _restore_deleted_file_if_needed(file_instance, upload.file_name, context.user) if upload.origin_dir: if upload.collection_id is not None or upload.origin_dir: try: with transaction.atomic(): _attach_file_to_origin_collection(upload, file_instance, context) except ServiceException: _attach_file_to_requested_collection(upload, file_instance, context) except ( models.Collection.DoesNotExist, models.Resource.DoesNotExist, ServiceException, ): return HttpResponse("Forbidden", status=403) return HttpResponse(file_instance.pk, status=200) Loading Loading @@ -506,9 +572,9 @@ def _import_completed_partial_upload( context.user.username, context.user.pk, file_id ) ) if upload.origin_dir: if upload.collection_id is not None or upload.origin_dir: file_instance = models.File.objects.get(id=file_id) _attach_file_to_origin_collection(upload, file_instance, context) _attach_file_to_requested_collection(upload, file_instance, context) return file_id Loading Loading @@ -553,6 +619,8 @@ def _completed_partial_upload_response( return HttpResponse(str(already_exists), status=409) except ConcurrencyError: return HttpResponse("Too Many Requests", status=429) except (models.Collection.DoesNotExist, models.Resource.DoesNotExist): return HttpResponse("Forbidden", status=403) except ServiceException: return HttpResponse("Forbidden", status=403) except FileNotFoundError: Loading