From bb9a9a307f88413d38c8352cbd74d6218f7931e1 Mon Sep 17 00:00:00 2001 From: Shrikant Sharat Kandula Date: Tue, 9 Mar 2021 17:03:20 +0530 Subject: [PATCH] APIs for profile photos (#3260) * Add API for uploading profile photos for current user * Add delete and get APIs for profile photos * Add test for uploading and deleting profile photo * Added negative tests for upload profile photo API --- .../server/controllers/AssetController.java | 21 +-- .../server/controllers/UserController.java | 34 +++++ .../com/appsmith/server/domains/UserData.java | 5 + .../server/services/AnalyticsService.java | 6 + .../server/services/AssetService.java | 7 + .../server/services/AssetServiceImpl.java | 89 +++++++++++++ .../services/OrganizationServiceImpl.java | 62 +++------ .../server/services/UserDataService.java | 17 +++ .../server/services/UserDataServiceImpl.java | 121 ++++++++++++++++++ .../server/services/UserDataServiceTest.java | 95 ++++++++++++++ .../src/test/resources/test_assets/apple.svg | 13 ++ 11 files changed, 408 insertions(+), 62 deletions(-) create mode 100644 app/server/appsmith-server/src/test/resources/test_assets/apple.svg diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java index 8ae02015fe..af735039d6 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/AssetController.java @@ -4,11 +4,6 @@ import com.appsmith.server.constants.Url; import com.appsmith.server.services.AssetService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.buffer.DefaultDataBuffer; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,21 +21,7 @@ public class AssetController { @GetMapping("/{id}") public Mono getById(@PathVariable String id, ServerWebExchange exchange) { - log.debug("Returning asset with ID '{}'.", id); - - final ServerHttpResponse response = exchange.getResponse(); - response.setStatusCode(HttpStatus.OK); - - final Mono imageBufferMono = service.getById(id) - .map(asset -> { - final String contentType = asset.getContentType(); - if (contentType != null) { - response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType); - } - return new DefaultDataBufferFactory().wrap(asset.getData()); - }); - - return response.writeWith(imageBufferMono); + return service.makeImageResponse(exchange, id); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java index 47ef8f41bf..42e0a66836 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java @@ -2,6 +2,7 @@ package com.appsmith.server.controllers; import com.appsmith.server.constants.Url; import com.appsmith.server.domains.User; +import com.appsmith.server.domains.UserData; import com.appsmith.server.dtos.InviteUsersDTO; import com.appsmith.server.dtos.ResetUserPasswordDTO; import com.appsmith.server.dtos.ResponseDTO; @@ -14,6 +15,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ServerWebExchange; @@ -142,4 +146,34 @@ public class UserController extends BaseController { .thenReturn(new ResponseDTO<>(HttpStatus.OK.value(), null, null)); } + @PostMapping(value = "/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> uploadProfilePhoto(@RequestPart("file") Mono fileMono) { + return fileMono + .flatMap(userDataService::saveProfilePhoto) + .map(url -> new ResponseDTO<>(HttpStatus.OK.value(), url, null)); + } + + @DeleteMapping("/photo") + public Mono> deleteProfilePhoto() { + return userDataService + .deleteProfilePhoto() + .map(ignored -> new ResponseDTO<>(HttpStatus.OK.value(), null, null)); + } + + @GetMapping("/photo") + public Mono getProfilePhoto(ServerWebExchange exchange) { + return userDataService.makeProfilePhotoResponse(exchange) + .switchIfEmpty(Mono.fromRunnable(() -> { + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + })); + } + + @GetMapping("/photo/{email}") + public Mono getProfilePhoto(ServerWebExchange exchange, @PathVariable String email) { + return userDataService.makeProfilePhotoResponse(exchange, email) + .switchIfEmpty(Mono.fromRunnable(() -> { + exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND); + })); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java index b397e347e7..0d5b30df11 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java @@ -3,6 +3,7 @@ package com.appsmith.server.domains; import com.appsmith.external.models.BaseDomain; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.springframework.data.mongodb.core.mapping.Document; @@ -14,11 +15,15 @@ import org.springframework.data.mongodb.core.mapping.Document; @Setter @ToString @Document +@NoArgsConstructor public class UserData extends BaseDomain { @JsonIgnore String userId; + // The ID of the asset which has the profile photo of this user. + private String profilePhotoAssetId; + // The version where this user has last viewed the release notes. private String releaseNotesViewedVersion; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java index 90b58d8ce6..1ab4073352 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AnalyticsService.java @@ -68,6 +68,12 @@ public class AnalyticsService { TrackMessage.Builder messageBuilder = TrackMessage.builder(event).userId(userId); if (!CollectionUtils.isEmpty(properties)) { + // Segment throws an NPE if any value in `properties` is null. + for (final Map.Entry entry : properties.entrySet()) { + if (entry.getValue() == null) { + properties.put(entry.getKey(), ""); + } + } messageBuilder = messageBuilder.properties(properties); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java index 40ae3d4ce5..fd412cb042 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetService.java @@ -1,10 +1,17 @@ package com.appsmith.server.services; import com.appsmith.server.domains.Asset; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface AssetService { Mono getById(String id); + Mono upload(Part filePart, int i); + + Mono remove(String assetId); + + Mono makeImageResponse(ServerWebExchange exchange, String assetId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java index 5919413c72..50239f5458 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AssetServiceImpl.java @@ -1,12 +1,26 @@ package com.appsmith.server.services; import com.appsmith.server.domains.Asset; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.repositories.AssetRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.Part; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Service; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Set; + @Slf4j @Service @RequiredArgsConstructor @@ -14,9 +28,84 @@ public class AssetServiceImpl implements AssetService { private final AssetRepository repository; + private final AnalyticsService analyticsService; + + private static final Set ALLOWED_CONTENT_TYPES = Set.of(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG); + @Override public Mono getById(String id) { return repository.findById(id); } + @Override + public Mono upload(Part filePart, int maxFileSizeKB) { + if (filePart == null) { + return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image.")); + } + + // The reason we restrict file types here is to avoid having to deal with dangerous image types such as SVG, + // which can have arbitrary HTML/JS inside of them. + final MediaType contentType = filePart.headers().getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + return Mono.error(new AppsmithException( + AppsmithError.VALIDATION_FAILURE, + "Please upload a valid image. Only JPEG and PNG are allowed." + )); + } + + final Flux contentCache = filePart.content().cache(); + + return contentCache.count() + .defaultIfEmpty(0L) + .flatMap(count -> { + // Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB. + // So we multiply the count of chunks with 4 to get an estimate on the file size in KB. + if (4 * count > maxFileSizeKB) { + return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, maxFileSizeKB)); + } + return DataBufferUtils.join(contentCache); + }) + .flatMap(dataBuffer -> { + byte[] data = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(data); + DataBufferUtils.release(dataBuffer); + return repository.save(new Asset(contentType, data)); + }) + .flatMap(analyticsService::sendCreateEvent); + } + + /** + * This function hard-deletes (read: not archive) the asset given by the ID. It is intended to be used to delete an + * old asset when a user uploads a new one. For example, when a new profile photo or an organization logo is, + * uploaded, this method is used to completely delete the old one, if any. + * @param assetId The ID string of the asset to delete. + * @return empty Mono + */ + @Override + public Mono remove(String assetId) { + final Asset tempAsset = new Asset(); + tempAsset.setId(assetId); + return repository.deleteById(assetId) + .then(analyticsService.sendDeleteEvent(tempAsset)) + .then(); + } + + @Override + public Mono makeImageResponse(ServerWebExchange exchange, String assetId) { + log.debug("Returning asset with ID '{}'.", assetId); + return getById(assetId) + .flatMap(asset -> { + final String contentType = asset.getContentType(); + final ServerHttpResponse response = exchange.getResponse(); + + response.setStatusCode(HttpStatus.OK); + + if (contentType != null) { + response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType); + } + + return response.writeWith(Mono.just(new DefaultDataBufferFactory().wrap(asset.getData()))); + }); + } + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java index 71d11eff9f..c4913d73dd 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/OrganizationServiceImpl.java @@ -19,8 +19,6 @@ import com.appsmith.server.repositories.PluginRepository; import com.appsmith.server.repositories.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; @@ -28,6 +26,7 @@ import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; @@ -55,6 +54,7 @@ public class OrganizationServiceImpl extends BaseService uploadLogo(String organizationId, Part filePart) { - return repository - .findById(organizationId, MANAGE_ORGANIZATIONS) - .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId))) - .flatMap(organization -> { - if (filePart != null && filePart.headers().getContentType() != null) { - // Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB - // To limit file size to 250KB, we only allow 63 (250/4 = 62.5) such chunks to be derived from the incoming FilePart - return filePart.content().count().flatMap(count -> { - if (count > (int) Math.ceil(Constraint.ORGANIZATION_LOGO_SIZE_KB / 4.0)) { - return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, Constraint.ORGANIZATION_LOGO_SIZE_KB)); - } else { - return Mono.zip(Mono.just(organization), DataBufferUtils.join(filePart.content())); - } - }); - } else { - return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image.")); - } - }) + final Mono findOrganizationMono = repository.findById(organizationId, MANAGE_ORGANIZATIONS) + .switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId))); + + // We don't execute the upload Mono if we don't find the organization. + final Mono uploadAssetMono = assetService.upload(filePart, Constraint.ORGANIZATION_LOGO_SIZE_KB); + + return findOrganizationMono + .flatMap(organization -> Mono.zip(Mono.just(organization), uploadAssetMono)) .flatMap(tuple -> { final Organization organization = tuple.getT1(); - final DataBuffer dataBuffer = tuple.getT2(); + final Asset uploadedAsset = tuple.getT2(); final String prevAssetId = organization.getLogoAssetId(); - byte[] data = new byte[dataBuffer.readableByteCount()]; - dataBuffer.read(data); - DataBufferUtils.release(dataBuffer); - - return assetRepository - .save(new Asset(filePart.headers().getContentType(), data)) - .flatMap(asset -> { - organization.setLogoAssetId(asset.getId()); - Mono savedOrganization = repository.save(organization); - Mono createdAsset = analyticsService.sendCreateEvent(asset); - return savedOrganization.zipWith(createdAsset); - }) - .flatMap(savedTuple -> { - Organization savedOrganization = savedTuple.getT1(); - if (prevAssetId != null) { - return assetRepository.findById(prevAssetId) - .flatMap(asset -> assetRepository.delete(asset).thenReturn(asset)) - .flatMap(analyticsService::sendDeleteEvent) - .thenReturn(savedOrganization); - } else { + organization.setLogoAssetId(uploadedAsset.getId()); + return repository.save(organization) + .flatMap(savedOrganization -> { + if (StringUtils.isEmpty(prevAssetId)) { return Mono.just(savedOrganization); + } else { + return assetService.remove(prevAssetId).thenReturn(savedOrganization); } }); }); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java index 843e2285ab..288b8a2bbf 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataService.java @@ -2,6 +2,8 @@ package com.appsmith.server.services; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import org.springframework.http.codec.multipart.Part; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; public interface UserDataService { @@ -9,9 +11,24 @@ public interface UserDataService { Mono getForUser(String userId); + Mono getForCurrentUser(); + + Mono getForUserEmail(String email); + + Mono updateForCurrentUser(UserData updates); + Mono setViewedCurrentVersionReleaseNotes(User user); Mono setViewedCurrentVersionReleaseNotes(User user, String version); Mono ensureViewedCurrentVersionReleaseNotes(User user); + + Mono saveProfilePhoto(Part filePart); + + Mono deleteProfilePhoto(); + + Mono makeProfilePhotoResponse(ServerWebExchange exchange, String email); + + Mono makeProfilePhotoResponse(ServerWebExchange exchange); + } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java index 5edf5c64c7..d1ac5c2966 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java @@ -1,26 +1,46 @@ package com.appsmith.server.services; +import com.appsmith.server.domains.Asset; +import com.appsmith.server.domains.QUserData; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.repositories.UserDataRepository; import com.appsmith.server.solutions.ReleaseNotesService; +import com.mongodb.DBObject; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.ReactiveMongoTemplate; import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import javax.validation.Validator; +import java.util.Map; + +import static com.appsmith.server.repositories.BaseAppsmithRepositoryImpl.fieldName; @Service public class UserDataServiceImpl extends BaseService implements UserDataService { private final UserService userService; + private final SessionUserService sessionUserService; + + private final AssetService assetService; + private final ReleaseNotesService releaseNotesService; + private static final int MAX_PROFILE_PHOTO_SIZE_KB = 250; + @Autowired public UserDataServiceImpl(Scheduler scheduler, Validator validator, @@ -29,11 +49,15 @@ public class UserDataServiceImpl extends BaseService getForCurrentUser() { + return sessionUserService.getCurrentUser() + .map(User::getEmail) + .flatMap(this::getForUserEmail); + } + + @Override + public Mono getForUserEmail(String email) { + return userService.findByEmail(email) + .flatMap(this::getForUser); + } + + @Override + public Mono updateForCurrentUser(UserData updates) { + return sessionUserService.getCurrentUser() + .flatMap(user -> userService.findByEmail(user.getEmail())) + .flatMap(user -> { + // If a UserData document exists for this user, update it. If not, create one. + updates.setUserId(user.getId()); + final Mono updaterMono = update(user.getId(), updates); + final Mono creatorMono = Mono.just(updates).flatMap(this::create); + return updaterMono.switchIfEmpty(creatorMono); + }); + } + + @Override + public Mono update(String userId, UserData resource) { + if (userId == null) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, fieldName(QUserData.userData.userId))); + } + + Query query = new Query(Criteria.where(fieldName(QUserData.userData.userId)).is(userId)); + + // In case the update is not used to update the policies, then set the policies to null to ensure that the + // existing policies are not overwritten. + if (resource.getPolicies().isEmpty()) { + resource.setPolicies(null); + } + + DBObject update = getDbObject(resource); + + Update updateObj = new Update(); + Map updateMap = update.toMap(); + updateMap.entrySet().stream().forEach(entry -> updateObj.set(entry.getKey(), entry.getValue())); + + return mongoTemplate.updateFirst(query, updateObj, resource.getClass()) + .flatMap(updateResult -> updateResult.getMatchedCount() == 0 ? Mono.empty() : repository.findByUserId(userId)) + .flatMap(analyticsService::sendUpdateEvent); + } + @Override public Mono setViewedCurrentVersionReleaseNotes(User user) { final String version = releaseNotesService.getReleasedVersion(); @@ -86,4 +161,50 @@ public class UserDataServiceImpl extends BaseService saveProfilePhoto(Part filePart) { + final Mono prevAssetIdMono = getForCurrentUser() + .map(userData -> ObjectUtils.defaultIfNull(userData.getProfilePhotoAssetId(), "")); + + final Mono uploaderMono = assetService.upload(filePart, MAX_PROFILE_PHOTO_SIZE_KB); + + return Mono.zip(prevAssetIdMono, uploaderMono) + .flatMap(tuple -> { + final String oldAssetId = tuple.getT1(); + final Asset uploadedAsset = tuple.getT2(); + final UserData updates = new UserData(); + updates.setProfilePhotoAssetId(uploadedAsset.getId()); + final Mono updateMono = updateForCurrentUser(updates); + if (StringUtils.isEmpty(oldAssetId)) { + return updateMono; + } else { + return assetService.remove(oldAssetId).then(updateMono); + } + }); + } + + @Override + public Mono deleteProfilePhoto() { + return getForCurrentUser() + .flatMap(userData -> Mono.justOrEmpty(userData.getProfilePhotoAssetId())) + .flatMap(assetService::remove); + } + + @Override + public Mono makeProfilePhotoResponse(ServerWebExchange exchange, String email) { + return getForUserEmail(email) + .flatMap(userData -> makeProfilePhotoResponse(exchange, userData)); + } + + @Override + public Mono makeProfilePhotoResponse(ServerWebExchange exchange) { + return getForCurrentUser() + .flatMap(userData -> makeProfilePhotoResponse(exchange, userData)); + } + + private Mono makeProfilePhotoResponse(ServerWebExchange exchange, UserData userData) { + return Mono.justOrEmpty(userData.getProfilePhotoAssetId()) + .flatMap(assetId -> assetService.makeImageResponse(exchange, assetId)); + } + } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java index 283252c535..cf314e6c3e 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/UserDataServiceTest.java @@ -1,16 +1,31 @@ package com.appsmith.server.services; +import com.appsmith.server.domains.Asset; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.repositories.AssetRepository; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import reactor.util.function.Tuple2; + +import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; @@ -25,6 +40,9 @@ public class UserDataServiceTest { @Autowired private UserDataService userDataService; + @Autowired + private AssetRepository assetRepository; + private Mono userMono; @Before @@ -72,4 +90,81 @@ public class UserDataServiceTest { .verifyComplete(); } + @Test + @WithUserDetails(value = "api_user") + public void testUploadAndDeleteProfilePhoto_validImage() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG); + + Mono> loadProfileImageMono = userDataService.getForUserEmail("api_user") + .flatMap(userData -> Mono.zip( + Mono.just(userData), + assetRepository.findById(userData.getProfilePhotoAssetId()) + )); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + final Mono> saveAndGetMono = saveMono.then(loadProfileImageMono); + final Mono> deleteAndGetMono = saveMono.then(userDataService.deleteProfilePhoto()).then(loadProfileImageMono); + + StepVerifier.create(saveAndGetMono) + .assertNext(tuple -> { + final UserData userData = tuple.getT1(); + assertThat(userData.getProfilePhotoAssetId()).isNotNull(); + + final Asset asset = tuple.getT2(); + assertThat(asset).isNotNull(); + DataBuffer buffer = DataBufferUtils.join(dataBufferFlux).block(Duration.ofSeconds(3)); + byte[] res = new byte[buffer.readableByteCount()]; + buffer.read(res); + assertThat(asset.getData()).isEqualTo(res); + }) + .verifyComplete(); + + StepVerifier.create(deleteAndGetMono) + // Should be empty since the profile photo has been deleted. + .verifyComplete(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testUploadProfilePhoto_invalidImageFormat() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_GIF); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + + StepVerifier.create(saveMono) + .expectErrorMatches(error -> error instanceof AppsmithException) + .verify(); + } + + @Test + @WithUserDetails(value = "api_user") + public void testUploadProfilePhoto_invalidImageSize() { + FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS); + Flux dataBufferFlux = DataBufferUtils + .read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096) + .repeat(70) // So the file size looks like it's much larger than what it actually is. + .cache(); + + Mockito.when(filepart.content()).thenReturn(dataBufferFlux); + Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG); + + final Mono saveMono = userDataService.saveProfilePhoto(filepart).cache(); + + StepVerifier.create(saveMono) + .expectErrorMatches(error -> error instanceof AppsmithException) + .verify(); + } + } diff --git a/app/server/appsmith-server/src/test/resources/test_assets/apple.svg b/app/server/appsmith-server/src/test/resources/test_assets/apple.svg new file mode 100644 index 0000000000..31ff74313f --- /dev/null +++ b/app/server/appsmith-server/src/test/resources/test_assets/apple.svg @@ -0,0 +1,13 @@ + + + + + + +