This is an automated email from the ASF dual-hosted git repository.

Arsnael pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


The following commit(s) were added to refs/heads/master by this push:
     new bbce4eae6d [ENHANCEMENT] BlobResolver::validateAccess allow to 
validate user access to a blobId without fetch the full content (#3051)
bbce4eae6d is described below

commit bbce4eae6df4e6b4596c3ffeb17b3568222d476b
Author: Trần Hồng Quân <[email protected]>
AuthorDate: Thu May 28 08:45:17 2026 +0700

    [ENHANCEMENT] BlobResolver::validateAccess allow to validate user access to 
a blobId without fetch the full content (#3051)
---
 .../upload/CassandraUploadRepository.java          |  6 ++
 .../postgres/upload/PostgresUploadRepository.java  |  6 ++
 .../james/jmap/api/upload/UploadRepository.java    |  2 +
 .../james/jmap/api/upload/UploadService.java       |  2 +
 .../jmap/api/upload/UploadServiceDefaultImpl.java  |  5 ++
 .../memory/upload/InMemoryUploadRepository.java    | 10 +++
 .../jmap/api/upload/UploadRepositoryContract.scala | 34 ++++++++-
 .../rfc8621/contract/CustomMethodContract.scala    |  4 +
 .../apache/james/jmap/routes/DownloadRoutes.scala  | 86 +++++++++++++++++-----
 9 files changed, 136 insertions(+), 19 deletions(-)

diff --git 
a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
 
b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
index a83c9eecc0..c88659fca5 100644
--- 
a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
+++ 
b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
@@ -82,6 +82,12 @@ public class CassandraUploadRepository implements 
UploadRepository {
             .switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
     }
 
+    @Override
+    public Mono<Boolean> validateAccess(UploadId id, Username user) {
+        return uploadDAO.retrieve(user, id)
+            .hasElement();
+    }
+
     @Override
     public Mono<Boolean> delete(UploadId id, Username user) {
         return uploadDAO.delete(user, id);
diff --git 
a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
 
b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
index fbec035da7..ca6c2ecbaa 100644
--- 
a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
+++ 
b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
@@ -88,6 +88,12 @@ public class PostgresUploadRepository implements 
UploadRepository {
             .switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
     }
 
+    @Override
+    public Mono<Boolean> validateAccess(UploadId id, Username user) {
+        return uploadDAOFactory.create(user.getDomainPart()).get(id, user)
+            .hasElement();
+    }
+
     @Override
     public Mono<Boolean> delete(UploadId id, Username user) {
         return uploadDAOFactory.create(user.getDomainPart()).delete(id, user);
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
index 60c7d207ac..568688bd4b 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
@@ -34,6 +34,8 @@ public interface UploadRepository {
 
     Publisher<Upload> retrieve(UploadId id, Username user);
 
+    Publisher<Boolean> validateAccess(UploadId id, Username user);
+
     Publisher<Boolean> delete(UploadId id, Username user);
 
     Publisher<UploadMetaData> listUploads(Username user);
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
index 46eee4c2ea..f4185a40e0 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
@@ -32,4 +32,6 @@ public interface UploadService {
     Publisher<UploadMetaData> upload(InputStream data, ContentType 
contentType, Username user);
 
     Publisher<Upload> retrieve(UploadId id, Username user);
+
+    Publisher<Boolean> validateAccess(UploadId id, Username user);
 }
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
index e981c9459c..4df983bd84 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
@@ -71,6 +71,11 @@ public class UploadServiceDefaultImpl implements 
UploadService {
         return uploadRepository.retrieve(id, user);
     }
 
+    @Override
+    public Publisher<Boolean> validateAccess(UploadId id, Username user) {
+        return uploadRepository.validateAccess(id, user);
+    }
+
     private Mono<Void> cleanupUploadIfNeeded(Username username, UploadMetaData 
notCleanUpload) {
         return Mono.from(uploadUsageRepository.getSpaceUsage(username))
             .map(QuotaSizeUsage::asLong)
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
index 72788eb724..523d7f3351 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
@@ -98,6 +98,16 @@ public class InMemoryUploadRepository implements 
UploadRepository {
             .switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
     }
 
+    @Override
+    public Publisher<Boolean> validateAccess(UploadId id, Username user) {
+        Preconditions.checkNotNull(id);
+        Preconditions.checkNotNull(user);
+
+        return Mono.justOrEmpty(uploadStore.get(id))
+            .filter(pair -> user.equals(pair.left))
+            .hasElement();
+    }
+
     @Override
     public Publisher<Boolean> delete(UploadId id, Username user) {
         return Mono.justOrEmpty(uploadStore.get(id))
diff --git 
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
 
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
index 2bdb85804e..774298c7bd 100644
--- 
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
+++ 
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
@@ -29,7 +29,7 @@
  import org.apache.james.core.Username
  import org.apache.james.jmap.api.model.Size.sanitizeSize
  import org.apache.james.jmap.api.model.{Upload, UploadId, UploadMetaData, 
UploadNotFoundException}
- import 
org.apache.james.jmap.api.upload.UploadRepositoryContract.{CONTENT_TYPE, 
DATA_STRING, UPLOAD_BUCKET, USER}
+ import org.apache.james.jmap.api.upload.UploadRepositoryContract.{ALICE, 
CONTENT_TYPE, DATA_STRING, UPLOAD_BUCKET, USER}
  import org.apache.james.mailbox.model.ContentType
  import org.apache.james.utils.UpdatableTickingClock
  import org.assertj.core.api.Assertions.{assertThat, assertThatCode, 
assertThatThrownBy}
@@ -43,6 +43,7 @@
    private lazy val CONTENT_TYPE: ContentType = ContentType
      .of("text/html")
    private lazy val DATA_STRING: String = "123321"
+   private lazy val ALICE: Username = Username.of("Alice")
    private lazy val USER: Username = Username.of("Bob")
    private lazy val UPLOAD_BUCKET: BucketName = BucketName.of("jmap-uploads")
  }
@@ -59,6 +60,37 @@
 
    def data(): InputStream = IOUtils.toInputStream(DATA_STRING, 
StandardCharsets.UTF_8)
 
+   @Test
+   def validateAccessShouldReturnTrueWhenUploadBelongsToUser(): Unit = {
+     val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), 
CONTENT_TYPE, USER)).block().uploadId
+
+     assertThat(SMono.fromPublisher(testee.validateAccess(uploadId, 
USER)).block())
+       .isTrue
+   }
+
+   @Test
+   def validateAccessShouldReturnFalseWhenUploadDoesNotExist(): Unit =
+     assertThat(SMono.fromPublisher(testee.validateAccess(randomUploadId(), 
USER)).block())
+       .isFalse
+
+   @Test
+   def validateAccessShouldReturnFalseWhenUploadBelongsToAnotherUser(): Unit = 
{
+     val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), 
CONTENT_TYPE, ALICE)).block().uploadId
+
+     assertThat(SMono.fromPublisher(testee.validateAccess(uploadId, 
USER)).block())
+       .isFalse
+   }
+
+   @Test
+   def validateAccessShouldReturnFalseWhenUploadWasDeleted(): Unit = {
+     val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), 
CONTENT_TYPE, USER)).block().uploadId
+
+     SMono.fromPublisher(testee.delete(uploadId, USER)).block()
+
+     assertThat(SMono.fromPublisher(testee.validateAccess(uploadId, 
USER)).block())
+       .isFalse
+   }
+
    @Test
    def uploadShouldSuccess(): Unit = {
      val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), 
CONTENT_TYPE, USER)).block().uploadId
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
index 9d1a0864be..a35a7a0eaa 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
@@ -20,6 +20,7 @@
 package org.apache.james.jmap.rfc8621.contract
 
 import java.io.{ByteArrayInputStream, InputStream}
+import java.lang.Boolean.valueOf
 import java.net.URI
 import java.nio.charset.StandardCharsets
 
@@ -228,6 +229,9 @@ class CustomBlobResolver extends BlobResolver {
     } else {
       SMono.just(NonApplicable)
     }
+
+  override def validateAccess(blobId: org.apache.james.jmap.mail.BlobId, 
mailboxSession: MailboxSession): SMono[java.lang.Boolean] =
+    SMono.just(valueOf(blobId.equals(CustomBlob.blobId)))
 }
 
 class CustomMethod extends Method {
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
index ffbd68c5cb..a91155f186 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
@@ -19,6 +19,7 @@
 package org.apache.james.jmap.routes
 
 import java.io.InputStream
+import java.lang.Boolean.{FALSE, TRUE, valueOf}
 import java.nio.charset.StandardCharsets
 import java.util.concurrent.Callable
 import java.util.function.Consumer
@@ -87,6 +88,8 @@ case class Applicable(blob: SMono[Blob]) extends 
BlobResolutionResult {
 
 trait BlobResolver {
   def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
Publisher[BlobResolutionResult]
+
+  def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
Publisher[java.lang.Boolean]
 }
 
 trait Blob {
@@ -157,41 +160,61 @@ case class EmailBodyPartBlob(blobId: BlobId, part: 
MinimalEmailBodyPart) extends
 
 class MessageBlobResolver @Inject()(val messageIdFactory: MessageId.Factory,
                                     val messageIdManager: MessageIdManager) 
extends BlobResolver {
+  private def asMessageId(blobId: BlobId): Option[MessageId] =
+    Try(messageIdFactory.fromString(blobId.value.value)).toOption
+
   override def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[BlobResolutionResult] = {
-    Try(messageIdFactory.fromString(blobId.value.value)) match {
-      case Failure(_) => SMono.just(NonApplicable)
-      case Success(messageId) => 
SMono.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
 FetchGroup.FULL_CONTENT, mailboxSession))
+    asMessageId(blobId) match {
+      case None => SMono.just(NonApplicable)
+      case Some(messageId) => 
SMono.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
 FetchGroup.FULL_CONTENT, mailboxSession))
         .map(message => Applicable(SMono.just(MessageBlob(blobId, message))))
         .switchIfEmpty(SMono.just(NonApplicable))
     }
   }
+
+  override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[java.lang.Boolean] =
+    asMessageId(blobId) match {
+      case None => SMono.just(FALSE)
+      case Some(messageId) => 
SFlux.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
 FetchGroup.MINIMAL, mailboxSession))
+        .hasElements
+        .map(valueOf)
+    }
 }
 
 class UploadResolver @Inject()(val uploadService: UploadService) extends 
BlobResolver {
   private val prefix = "uploads-"
 
+  private def asUploadId(blobId: BlobId): Option[UploadId] =
+    Option(blobId.value.value)
+      .filter(_.startsWith(prefix))
+      .flatMap(value => 
Try(UploadId.from(value.substring(prefix.length))).toOption)
+
   override def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[BlobResolutionResult] = {
-    if (!blobId.value.value.startsWith(prefix)) {
-      SMono.just(NonApplicable)
-    } else {
-      val uploadIdAsString = blobId.value.value.substring(prefix.length)
-      Try(UploadId.from(uploadIdAsString)) match {
-        case Failure(_) => SMono.just(NonApplicable)
-        case Success(uploadId) => 
SMono.fromPublisher(uploadService.retrieve(uploadId, mailboxSession.getUser))
-            .map(upload => Applicable(SMono.just(UploadedBlob(blobId, 
upload))))
-            .onErrorResume {
-              case _: UploadNotFoundException => SMono.just(NonApplicable)
-              case e => SMono.error[BlobResolutionResult](e)
-            }
+    asUploadId(blobId) match {
+      case None => SMono.just(NonApplicable)
+      case Some(uploadId) => 
SMono.fromPublisher(uploadService.retrieve(uploadId, mailboxSession.getUser))
+        .map(upload => Applicable(SMono.just(UploadedBlob(blobId, upload))))
+        .onErrorResume {
+          case _: UploadNotFoundException => SMono.just(NonApplicable)
+          case e => SMono.error[BlobResolutionResult](e)
       }
     }
   }
+
+  override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[java.lang.Boolean] =
+    asUploadId(blobId) match {
+      case None => SMono.just(FALSE)
+      case Some(uploadId) => 
SMono.fromPublisher(uploadService.validateAccess(uploadId, 
mailboxSession.getUser))
+    }
 }
 
 class AttachmentBlobResolver @Inject()(val attachmentManager: 
AttachmentManager, val attachmentIdFactory: AttachmentIdFactory) extends 
BlobResolver {
+  private def asAttachmentId(blobId: BlobId): Option[AttachmentId] =
+    Try(attachmentIdFactory.from(blobId.value.value)).toOption
+
   override def resolve(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[BlobResolutionResult] =
-    Try(attachmentIdFactory.from(blobId.value.value)) match {
-      case Success(attachmentId) =>
+    asAttachmentId(blobId) match {
+      case Some(attachmentId) =>
         SMono(attachmentManager.getAttachmentReactive(attachmentId, 
mailboxSession))
           .map(attachmentMetadata => 
Applicable(SMono(attachmentManager.loadReactive(attachmentMetadata, 
mailboxSession))
             .map(content => AttachmentBlob(attachmentMetadata, content))))
@@ -199,7 +222,19 @@ class AttachmentBlobResolver @Inject()(val 
attachmentManager: AttachmentManager,
             case e: AttachmentNotFoundException =>  
SMono.just(NonApplicable.asInstanceOf[BlobResolutionResult])
             case e => SMono.error[BlobResolutionResult](e)
           }
-      case _ => SMono.just(NonApplicable)
+      case None => SMono.just(NonApplicable)
+    }
+
+  override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[java.lang.Boolean] =
+    asAttachmentId(blobId) match {
+      case Some(attachmentId) =>
+        SMono(attachmentManager.getAttachmentReactive(attachmentId, 
mailboxSession))
+          .map(_ => TRUE)
+          .onErrorResume {
+            case _: AttachmentNotFoundException => SMono.just(FALSE)
+            case e => SMono.error[java.lang.Boolean](e)
+          }
+      case None => SMono.just(FALSE)
     }
 }
 
@@ -244,6 +279,14 @@ class MessagePartBlobResolver @Inject()(val 
messageIdFactory: MessageId.Factory,
           .switchIfEmpty(SMono.just(NonApplicable))
     }
   }
+
+  override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[java.lang.Boolean] =
+    asMessageAndPartIds(blobId) match {
+      case Failure(_) => SMono.just(FALSE)
+      case Success((messageId, _)) => 
SFlux.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
 FetchGroup.MINIMAL, mailboxSession))
+        .hasElements
+        .map(valueOf)
+    }
 }
 
 class BlobResolvers(blobResolvers: Set[BlobResolver]) {
@@ -262,6 +305,13 @@ class BlobResolvers(blobResolvers: Set[BlobResolver]) {
       }
       .concatMap(result => 
result.asOption.getOrElse(SMono.error(BlobNotFoundException(blobId))))
       .next().switchIfEmpty(SMono.error(BlobNotFoundException(blobId)))
+
+  def validateAccess(blobId: BlobId, mailboxSession: MailboxSession): 
SMono[java.lang.Boolean] =
+    SFlux.fromIterable(blobResolvers)
+      .concatMap(resolver => resolver.validateAccess(blobId, mailboxSession))
+      .filter(_.booleanValue())
+      .next()
+      .switchIfEmpty(SMono.just(FALSE))
 }
 
 class DownloadRoutes @Inject()(@Named(InjectionKeys.RFC_8621) val 
authenticator: Authenticator,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to