Commit 72266aca authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

add x-collection-id for uploads

parent 998b3c54
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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",
+12 −0
Original line number Diff line number Diff line
@@ -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)
+48 −0
Original line number Diff line number Diff line
@@ -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,
    ):
@@ -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),
@@ -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
+74 −6
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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)
@@ -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,
@@ -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]:
@@ -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)

@@ -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


@@ -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: