From 19968b5c103bc935029eb5c80f831f9cbf81570d Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 14 Nov 2023 16:06:59 +0200 Subject: [PATCH] Image descriptor, image preview, refactoring --- .../main/data/upgrade/3.6.1/schema_update.sql | 4 +- .../server/controller/ImageController.java | 99 ++++++++---- .../controller/TbResourceController.java | 32 +--- .../service/install/update/ImagesUpdater.java | 46 +++--- .../install/update/SystemImagesMigrator.java | 2 +- .../resource/DefaultTbImageService.java | 46 +++++- .../service/resource/TbImageService.java | 5 +- .../controller/TbResourceControllerTest.java | 54 +------ .../server/dao/resource/ImageService.java | 4 +- .../server/common/data/ImageDescriptor.java | 29 ++++ .../server/common/data/ResourceType.java | 2 +- .../server/common/data/TbResource.java | 4 + .../server/common/data/TbResourceInfo.java | 14 +- .../server/common/data/util/ImageUtils.java | 96 ------------ dao/pom.xml | 8 + .../server/dao/model/ModelConstants.java | 3 +- .../dao/model/sql/TbResourceEntity.java | 25 ++- .../dao/model/sql/TbResourceInfoEntity.java | 16 +- .../server/dao/resource/BaseImageService.java | 47 +++++- .../dao/resource/BaseResourceService.java | 4 +- .../server/dao/resource/TbResourceDao.java | 2 + .../validator/ResourceDataValidator.java | 15 -- .../dao/sql/resource/JpaTbResourceDao.java | 5 + .../resource/TbResourceInfoRepository.java | 8 +- .../sql/resource/TbResourceRepository.java | 3 + .../server/dao/util/ImageUtils.java | 144 ++++++++++++++++++ .../main/resources/sql/schema-entities.sql | 3 +- pom.xml | 11 ++ .../src/app/shared/models/resource.models.ts | 2 - 29 files changed, 444 insertions(+), 289 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ImageDescriptor.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/util/ImageUtils.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java diff --git a/application/src/main/data/upgrade/3.6.1/schema_update.sql b/application/src/main/data/upgrade/3.6.1/schema_update.sql index a594b5f3c8..c19ea2e4e8 100644 --- a/application/src/main/data/upgrade/3.6.1/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.1/schema_update.sql @@ -27,6 +27,8 @@ $$ END IF; END; $$; -ALTER TABLE resource ADD COLUMN IF NOT EXISTS media_type varchar(255); +ALTER TABLE resource + ADD COLUMN IF NOT EXISTS descriptor varchar, + ADD COLUMN IF NOT EXISTS preview bytea; -- RESOURCES UPDATE END diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index 93d01698cf..756c198a72 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -32,8 +32,13 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ImageDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; @@ -43,7 +48,9 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.resource.TbImageService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; import java.util.function.Supplier; @@ -68,52 +75,75 @@ public class ImageController extends BaseController { private static final String SYSTEM_IMAGE = "system"; private static final String TENANT_IMAGE = "tenant"; - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PostMapping("/api/image") - public TbResourceInfo uploadImage(MultipartFile file) { -// imageService.saveImage() - return null; + public TbResourceInfo uploadImage(@RequestPart MultipartFile file) throws Exception { + SecurityUser user = getCurrentUser(); + TbResource image = new TbResource(); + image.setTenantId(user.getTenantId()); + accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.CREATE, null, image); + + image.setFileName(file.getOriginalFilename()); + image.setTitle(file.getOriginalFilename()); + image.setResourceType(ResourceType.IMAGE); + ImageDescriptor descriptor = new ImageDescriptor(); + descriptor.setMediaType(file.getContentType()); + image.setDescriptor(JacksonUtil.valueToTree(descriptor)); + image.setData(file.getBytes()); + return tbImageService.save(image, user); } - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping(IMAGE_URL) - public TbResourceInfo updateImage(MultipartFile file) { - return null; + public TbResourceInfo updateImage(@PathVariable String type, + @PathVariable String key, + @RequestPart MultipartFile file) throws Exception { + TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE); + ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); + + TbResource image = new TbResource(imageInfo); + image.setData(file.getBytes()); + descriptor.setMediaType(file.getContentType()); + return tbImageService.save(image, getCurrentUser()); } - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping(IMAGE_URL + "/info") - public TbResourceInfo updateImageInfo(@RequestBody TbResourceInfo imageInfo) { - return null; + public TbResourceInfo updateImageInfo(@PathVariable String type, + @PathVariable String key, + @RequestBody TbResourceInfo newImageInfo) throws ThingsboardException { + TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE); + imageInfo.setTitle(newImageInfo.getTitle()); + return tbImageService.save(imageInfo, getCurrentUser()); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = IMAGE_URL, produces = "image/*") public ResponseEntity downloadImage(@PathVariable String type, @PathVariable String key, - @RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException { - TenantId tenantId = getTenantId(type); - TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); - return downloadIfChanged(etag, imageInfo, () -> imageService.getImageData(tenantId, imageInfo.getId()), imageInfo.getMediaType()); + @RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception { + TenantId tenantId = getTenantId(); + TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ); + ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); + return downloadIfChanged(etag, imageInfo, descriptor, () -> imageService.getImageData(tenantId, imageInfo.getId())); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = IMAGE_URL + "/preview", produces = "image/png") public ResponseEntity downloadImagePreview(@PathVariable String type, @PathVariable String key, - @RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException { - TenantId tenantId = getTenantId(type); - TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); - return downloadIfChanged(etag, imageInfo, () -> imageService.getImagePreview(tenantId, imageInfo.getId()), imageInfo.getMediaType()); + @RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception { + TenantId tenantId = getTenantId(); + TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ); + ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); + return downloadIfChanged(etag, imageInfo, descriptor.getPreviewDescriptor(), () -> imageService.getImagePreview(tenantId, imageInfo.getId())); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(IMAGE_URL + "/info") public TbResourceInfo getImageInfo(@PathVariable String type, @PathVariable String key) throws ThingsboardException { - TenantId tenantId = getTenantId(type); - TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); - return checkEntity(getCurrentUser(), imageInfo, Operation.READ); + return checkImageInfo(type, key, Operation.READ); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @@ -128,6 +158,7 @@ public class ImageController extends BaseController { @RequestParam(required = false) String sortProperty, @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) @RequestParam(required = false) String sortOrder) throws ThingsboardException { + // PE: generic permission PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); TenantId tenantId = getTenantId(); if (getCurrentUser().getAuthority() == Authority.SYS_ADMIN) { @@ -141,17 +172,14 @@ public class ImageController extends BaseController { @DeleteMapping(IMAGE_URL) public void deleteImage(@PathVariable String type, @PathVariable String key) throws ThingsboardException { - TenantId tenantId = getTenantId(type); - TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); - checkEntity(getCurrentUser(), imageInfo, Operation.DELETE); + TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.DELETE); tbImageService.delete(imageInfo, getCurrentUser()); } - private ResponseEntity downloadIfChanged(String etag, TbResourceInfo resourceInfo, - Supplier dataSupplier, String mediaType) throws ThingsboardException { - checkEntity(getCurrentUser(), resourceInfo, Operation.READ); + private ResponseEntity downloadIfChanged(String etag, TbResourceInfo imageInfo, ImageDescriptor imageDescriptor, + Supplier dataSupplier) { if (etag != null) { - if (etag.equals(resourceInfo.getEtag())) { + if (etag.equals(imageInfo.getEtag())) { return ResponseEntity.status(HttpStatus.NOT_MODIFIED) .eTag(etag) .build(); @@ -160,15 +188,22 @@ public class ImageController extends BaseController { byte[] data = dataSupplier.get(); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + resourceInfo.getFileName()) - .header("x-filename", resourceInfo.getFileName()) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + imageInfo.getFileName()) + .header("x-filename", imageInfo.getFileName()) .contentLength(data.length) - .header("Content-Type", mediaType) + .header("Content-Type", imageDescriptor.getMediaType()) .cacheControl(CacheControl.noCache()) - .eTag(resourceInfo.getEtag()) + .eTag(imageInfo.getEtag()) .body(new ByteArrayResource(data)); } + private TbResourceInfo checkImageInfo(String imageType, String key, Operation operation) throws ThingsboardException { + TenantId tenantId = getTenantId(imageType); + TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); + checkEntity(getCurrentUser(), checkNotNull(imageInfo), operation); + return imageInfo; + } + private TenantId getTenantId(String imageType) throws ThingsboardException { TenantId tenantId; if (imageType.equals(TENANT_IMAGE)) { diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index a87b26c333..08d7cedd93 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -54,11 +54,10 @@ import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; import java.util.Base64; - import java.util.Collections; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.function.Supplier; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; @@ -110,17 +109,6 @@ public class TbResourceController extends BaseController { .body(resource); } - @ApiOperation(value = "Download Image (downloadImageIfChanged)", notes = DOWNLOAD_RESOURCE_IF_NOT_CHANGED + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/images/{resourceKey}", produces = "image/*") - public ResponseEntity downloadImageIfChanged(@PathVariable("resourceKey") String resourceKey, - @RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException { - TenantId tenantId = getTenantId(); - return downloadResourceIfChanged(ResourceType.IMAGE, etag, - () -> resourceService.findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, resourceKey), - () -> resourceService.findResourceByTenantIdAndKey(tenantId, ResourceType.IMAGE, resourceKey)); - } - @ApiOperation(value = "Download LWM2M Resource (downloadLwm2mResourceIfChanged)", notes = DOWNLOAD_RESOURCE_IF_NOT_CHANGED + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/resource/lwm2m/{resourceId}/download", produces = "application/xml") @@ -291,19 +279,9 @@ public class TbResourceController extends BaseController { private ResponseEntity downloadResourceIfChanged(ResourceType resourceType, String strResourceId, String etag) throws ThingsboardException { checkParameter(RESOURCE_ID, strResourceId); - TenantId tenantId = getTenantId(); TbResourceId resourceId = new TbResourceId(toUUID(strResourceId)); - return downloadResourceIfChanged(resourceType, etag, - () -> resourceService.findResourceInfoById(tenantId, resourceId), - () -> resourceService.findResourceById(tenantId, resourceId)); - } - - private ResponseEntity downloadResourceIfChanged(ResourceType resourceType, String etag, - Supplier resourceInfoSupplier, - Supplier resourceSupplier) throws ThingsboardException { if (etag != null) { - TbResourceInfo tbResourceInfo = resourceInfoSupplier.get(); - checkEntity(getCurrentUser(), tbResourceInfo, Operation.READ); + TbResourceInfo tbResourceInfo = checkResourceInfoId(resourceId, Operation.READ); if (etag.equals(tbResourceInfo.getEtag())) { return ResponseEntity.status(HttpStatus.NOT_MODIFIED) .eTag(tbResourceInfo.getEtag()) @@ -311,16 +289,14 @@ public class TbResourceController extends BaseController { } } - // TODO: rate limits - TbResource tbResource = resourceSupplier.get(); - checkEntity(getCurrentUser(), tbResource, Operation.READ); + TbResource tbResource = checkResourceId(resourceId, Operation.READ); ByteArrayResource resource = new ByteArrayResource(tbResource.getData()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + tbResource.getFileName()) .header("x-filename", tbResource.getFileName()) .contentLength(resource.contentLength()) - .header("Content-Type", tbResource.getMediaType() != null ? tbResource.getMediaType() : resourceType.getDefaultMediaType()) + .header("Content-Type", resourceType.getMediaType()) .cacheControl(CacheControl.noCache()) .eTag(tbResource.getEtag()) .body(resource); diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java b/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java index 9894ed5360..bb5da33631 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/ImagesUpdater.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.util.ImageUtils; +import org.thingsboard.server.dao.util.ImageUtils; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.resource.ImageService; @@ -38,7 +38,6 @@ import org.thingsboard.server.dao.resource.ImageService; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; -import java.util.List; import java.util.Map; import java.util.Optional; @@ -199,41 +198,44 @@ public class ImagesUpdater { return new ImageSaveResult(imageLink, !imageLink.equals(data)); } + @SneakyThrows private String saveImage(TenantId tenantId, String name, String key, byte[] imageData, String mediaType, String existingImageQuery) { - TbResourceInfo resourceInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); - if (resourceInfo == null && !tenantId.isSysTenantId() && existingImageQuery != null) { + TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); + if (imageInfo == null && !tenantId.isSysTenantId() && existingImageQuery != null) { // TODO: need to search among tenant images too (custom widgets) // List existingSystemImages = imageService.findByTenantIdAndDataAndKeyStartingWith(TenantId.SYS_TENANT_ID, imageData, existingImageQuery); // if (!existingSystemImages.isEmpty()) { -// resourceInfo = existingSystemImages.get(0); +// imageInfo = existingSystemImages.get(0); // if (existingSystemImages.size() > 1) { // log.warn("Found more than one system image resources for key {}", existingImageQuery); // } -// String link = imageService.getImageLink(resourceInfo); +// String link = imageService.getImageLink(imageInfo); // log.info("Using system image {} for {}", link, key); // return link; // } } - TbResource resource; - if (resourceInfo == null) { - resource = new TbResource(); - resource.setTenantId(tenantId); - resource.setResourceType(ResourceType.IMAGE); - resource.setResourceKey(key); + TbResource image; + if (imageInfo == null) { + image = new TbResource(); + image.setTenantId(tenantId); + image.setResourceType(ResourceType.IMAGE); + image.setResourceKey(key); } else if (tenantId.isSysTenantId()) { - resource = new TbResource(resourceInfo); + image = new TbResource(imageInfo); } else { - return imageService.getImageLink(resourceInfo); + return imageService.getImageLink(imageInfo); } - resource.setTitle(name); - resource.setFileName(key); - resource.setMediaType(mediaType); - resource.setData(imageData); - resource = imageService.saveImage(resource); - log.info("[{}] {} image '{}' ({})", tenantId, resourceInfo == null ? "Created" : "Updated", - resource.getTitle(), resource.getResourceKey()); - return imageService.getImageLink(resourceInfo); + image.setTitle(name); + image.setFileName(key); + image.setDescriptor(JacksonUtil.newObjectNode() + .put("mediaType", mediaType)); + image.setData(imageData); + + TbResourceInfo savedImage = imageService.saveImage(image); + log.info("[{}] {} image '{}' ({})", tenantId, imageInfo == null ? "Created" : "Updated", + image.getTitle(), image.getResourceKey()); + return imageService.getImageLink(savedImage); } private String getText(JsonNode jsonNode, String field) { diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/SystemImagesMigrator.java b/application/src/main/java/org/thingsboard/server/service/install/update/SystemImagesMigrator.java index 52ac77b733..41e912a173 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/SystemImagesMigrator.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/SystemImagesMigrator.java @@ -24,7 +24,7 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.util.MediaTypeUtils; +import org.thingsboard.server.dao.util.ImageUtils; import java.io.IOException; import java.nio.charset.StandardCharsets; diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java index 0c58f6a10a..188dff20d5 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java @@ -17,29 +17,65 @@ package org.thingsboard.server.service.resource; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @Service @TbCoreComponent @RequiredArgsConstructor -public class DefaultTbImageService implements TbImageService { +public class DefaultTbImageService extends AbstractTbEntityService implements TbImageService { private final ImageService imageService; @Override - public TbResource save(TbResourceInfo imageInfo, MultipartFile imageFile, User user) { - return null; + public TbResourceInfo save(TbResource image, User user) throws Exception { + ActionType actionType = image.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + TenantId tenantId = image.getTenantId(); + image.setResourceKey(image.getFileName()); // TODO: generate unique resource key file_name+idx + try { + TbResourceInfo savedImage = imageService.saveImage(image); + notificationEntityService.logEntityAction(tenantId, savedImage.getId(), savedImage, actionType, user); + return savedImage; + } catch (Exception e) { + image.setData(null); + notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), image, actionType, user, e); + throw e; + } + } + + @Override + public TbResourceInfo save(TbResourceInfo imageInfo, User user) { + TenantId tenantId = imageInfo.getTenantId(); + TbResourceId imageId = imageInfo.getId(); + try { + imageInfo = imageService.saveImageInfo(imageInfo); + notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user); + return imageInfo; + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user, e); + throw e; + } } @Override public void delete(TbResourceInfo imageInfo, User user) { - + TenantId tenantId = imageInfo.getTenantId(); + TbResourceId imageId = imageInfo.getId(); + try { + imageService.deleteImage(tenantId, imageId); + notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.DELETED, user, imageId.toString()); + } catch (Exception e) { + notificationEntityService.logEntityAction(tenantId, imageId, ActionType.DELETED, user, e, imageId.toString()); + throw e; + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java b/application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java index 7039d0edaa..a1f6cf3e7e 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java @@ -15,14 +15,15 @@ */ package org.thingsboard.server.service.resource; -import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.User; public interface TbImageService { - TbResource save(TbResourceInfo imageInfo, MultipartFile imageFile, User user); + TbResourceInfo save(TbResource image, User user) throws Exception; + + TbResourceInfo save(TbResourceInfo imageInfo, User user); void delete(TbResourceInfo imageInfo, User user); diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index 638d09bfdd..05770f960f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -620,10 +620,9 @@ public class TbResourceControllerTest extends AbstractControllerTest { @Test public void testUpdateResourceData_nonUpdatableResourceType() throws Exception { TbResource resource = new TbResource(); - resource.setResourceType(ResourceType.IMAGE); + resource.setResourceType(ResourceType.PKCS_12); resource.setTitle("My resource"); - resource.setFileName("image.png"); - resource.setMediaType("image/png"); + resource.setFileName("3.pks"); resource.setBase64Data(TEST_DATA); TbResource savedResource = save(resource); resource.setEtag(savedResource.getEtag()); @@ -638,7 +637,6 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedResource = doPost("/api/resource", savedResource, TbResource.class); assertThat(savedResource.getTitle()).isEqualTo("Updated resource"); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); - assertThat(savedResource.getMediaType()).isEqualTo(resource.getMediaType()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA); } @@ -665,54 +663,6 @@ public class TbResourceControllerTest extends AbstractControllerTest { assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(newData); } - @Test - public void testSaveImage_systemLevel() throws Exception { - loginSysAdmin(); - TbResource resource = new TbResource(); - resource.setResourceType(ResourceType.IMAGE); - resource.setTitle("System image"); - resource.setFileName("image.png"); - resource.setMediaType("image/png"); - resource.setBase64Data(TEST_DATA); - - TbResource savedResource = save(resource); - - loginTenantAdmin(); - MockHttpServletResponse imageResponse = doGet(getImageLink(savedResource)).andExpect(status().isOk()) - .andReturn().getResponse(); - assertThat(imageResponse.getContentAsByteArray()) - .isEqualTo(download(savedResource.getId())) - .isEqualTo(Base64.getDecoder().decode(TEST_DATA)); - assertThat(imageResponse.getContentType()).isEqualTo("image/png"); - - loginSysAdmin(); - doDelete("/api/resource/" + savedResource.getId()).andExpect(status().isOk()); - } - - @Test - public void testSaveImage_tenantLevel() throws Exception { - loginTenantAdmin(); - TbResource resource = new TbResource(); - resource.setResourceType(ResourceType.IMAGE); - resource.setTitle("My image"); - resource.setFileName("image.jpg"); - resource.setMediaType("image/jpeg"); - resource.setBase64Data(TEST_DATA); - - TbResource savedResource = save(resource); - String imageLink = getImageLink(savedResource); - - MockHttpServletResponse imageResponse = doGet(imageLink).andExpect(status().isOk()) - .andReturn().getResponse(); - assertThat(imageResponse.getContentAsByteArray()) - .isEqualTo(download(savedResource.getId())) - .isEqualTo(Base64.getDecoder().decode(TEST_DATA)); - assertThat(imageResponse.getContentType()).isEqualTo("image/jpeg"); - - loginDifferentTenant(); - doGet(imageLink).andExpect(status().isNotFound()); - } - private TbResource save(TbResource tbResource) throws Exception { return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() { }); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index a09a00296f..3ae45e86c5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.page.PageLink; public interface ImageService { - TbResource saveImage(TbResource image); + TbResourceInfo saveImage(TbResource image) throws Exception; TbResourceInfo saveImageInfo(TbResourceInfo imageInfo); @@ -38,6 +38,8 @@ public interface ImageService { byte[] getImagePreview(TenantId tenantId, TbResourceId imageId); + void deleteImage(TenantId tenantId, TbResourceId imageId); + String getImageLink(TbResourceInfo imageInfo); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ImageDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/ImageDescriptor.java new file mode 100644 index 0000000000..2bd7c59a85 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ImageDescriptor.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ImageDescriptor { + private String mediaType; + private int width; + private int height; + private long size; + private ImageDescriptor previewDescriptor; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java index 9ea39c43fd..b3222a9682 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java @@ -27,7 +27,7 @@ public enum ResourceType { IMAGE(null, true, false); @Getter - private final String defaultMediaType; + private final String mediaType; @Getter private final boolean customerAccess; @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 7c44943deb..3d6c9ad95b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -37,6 +37,9 @@ public class TbResource extends TbResourceInfo { @JsonIgnore private byte[] data; + @JsonIgnore + private byte[] preview; + public TbResource() { super(); } @@ -53,6 +56,7 @@ public class TbResource extends TbResourceInfo { super(resource); this.base64Data = resource.base64Data; this.data = resource.data; + this.preview = resource.preview; } @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index 5b0fc9433d..31ef6b8ea1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -16,6 +16,9 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; @@ -55,9 +58,8 @@ public class TbResourceInfo extends BaseData implements HasName, H @Length(fieldName = "file name") @ApiModelProperty(position = 9, value = "Resource file name.", example = "19.xml", accessMode = ApiModelProperty.AccessMode.READ_ONLY) private String fileName; - @ApiModelProperty(position = 10, value = "Resource media type.", example = "image/png", accessMode = ApiModelProperty.AccessMode.READ_ONLY) - private String mediaType; - private ObjectNode descriptor; + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private JsonNode descriptor; public TbResourceInfo() { super(); @@ -76,7 +78,6 @@ public class TbResourceInfo extends BaseData implements HasName, H this.searchText = resourceInfo.searchText; this.etag = resourceInfo.etag; this.fileName = resourceInfo.fileName; - this.mediaType = resourceInfo.mediaType; this.descriptor = resourceInfo.descriptor; } @@ -106,4 +107,9 @@ public class TbResourceInfo extends BaseData implements HasName, H return searchText != null ? searchText : title; } + @JsonIgnore + public T getDescriptor(Class type) throws JsonProcessingException { + return descriptor != null ? mapper.treeToValue(descriptor, type) : null; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/ImageUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/ImageUtils.java deleted file mode 100644 index f67051a3da..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/ImageUtils.java +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright © 2016-2023 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.data.util; - -import lombok.AccessLevel; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.util.MimeType; -import org.springframework.util.MimeTypeUtils; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Map; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class ImageUtils { - - private static final Map mediaTypeMappings = Map.of( - "jpeg", "jpg", - "svg+xml", "svg" - ); - - public static String mediaTypeToFileExtension(String mimeType) { - String subtype = MimeTypeUtils.parseMimeType(mimeType).getSubtype(); - return mediaTypeMappings.getOrDefault(subtype, subtype); - } - - public static String fileExtensionToMediaType(String type, String extension) { - String subtype = mediaTypeMappings.entrySet().stream() - .filter(mapping -> mapping.getValue().equals(extension)) - .map(Map.Entry::getKey).findFirst().orElse(extension); - return new MimeType(type, subtype).toString(); - } - - public static ImageInfo processImage(byte[] imageData) throws IOException { - BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageData)); - ImageThumbnail thumbnail = getImageThumbnail(image, 250); - return new ImageInfo(image.getWidth(), image.getHeight(), thumbnail); - } - - private static ImageThumbnail getImageThumbnail(BufferedImage originalImage, int maxDimension) throws IOException { - int originalWidth = originalImage.getWidth(); - int originalHeight = originalImage.getHeight(); - int thumbnailWidth; - int thumbnailHeight; - if (originalWidth <= maxDimension && originalHeight <= maxDimension) { - thumbnailWidth = originalWidth; - thumbnailHeight = originalHeight; - } else { - double aspectRatio = (double) originalWidth / originalHeight; - if (originalWidth > originalHeight) { - thumbnailWidth = maxDimension; - thumbnailHeight = (int) (maxDimension / aspectRatio); - } else { - thumbnailWidth = (int) (maxDimension * aspectRatio); - thumbnailHeight = maxDimension; - } - } - BufferedImage thumbnail = new BufferedImage(thumbnailWidth, thumbnailHeight, BufferedImage.TYPE_INT_RGB); - thumbnail.getGraphics().drawImage(originalImage, 0, 0, thumbnailWidth, thumbnailHeight, null); - ByteArrayOutputStream os = new ByteArrayOutputStream(); - ImageIO.write(thumbnail, "ignored", os); - return new ImageThumbnail(thumbnail.getWidth(), thumbnail.getHeight(), os.toByteArray()); - } - - @Data - public static class ImageInfo { - private final int width; - private final int height; - private final ImageThumbnail thumbnail; - } - - @Data - public static class ImageThumbnail { - private final int width; - private final int height; - private final byte[] data; - } - -} diff --git a/dao/pom.xml b/dao/pom.xml index 673a08e0c1..2ac19b9a1e 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -234,6 +234,14 @@ io.hypersistence hypersistence-utils-hibernate-55 + + org.apache.xmlgraphics + batik-transcoder + + + org.apache.xmlgraphics + batik-codec + diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 3a77a92eb7..21cea59ee3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -499,7 +499,8 @@ public class ModelConstants { public static final String RESOURCE_FILE_NAME_COLUMN = "file_name"; public static final String RESOURCE_DATA_COLUMN = "data"; public static final String RESOURCE_ETAG_COLUMN = "etag"; - public static final String RESOURCE_MEDIA_TYPE_COLUMN = "media_type"; + public static final String RESOURCE_DESCRIPTOR_COLUMN = "descriptor"; + public static final String RESOURCE_PREVIEW_COLUMN = "preview"; /** * Ota Package constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java index f26bf100d7..41c5ebc920 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.dao.model.sql; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.util.mapping.JsonStringType; import javax.persistence.Column; import javax.persistence.Entity; @@ -30,10 +33,11 @@ import javax.persistence.Table; import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DATA_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; -import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_MEDIA_TYPE_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_PREVIEW_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -43,8 +47,9 @@ import static org.thingsboard.server.dao.model.ModelConstants.SEARCH_TEXT_PROPER @Data @EqualsAndHashCode(callSuper = true) @Entity +@TypeDef(name = "json", typeClass = JsonStringType.class) @Table(name = RESOURCE_TABLE_NAME) -public class TbResourceEntity extends BaseSqlEntity implements BaseEntity { +public class TbResourceEntity extends BaseSqlEntity { @Column(name = RESOURCE_TENANT_ID_COLUMN, columnDefinition = "uuid") private UUID tenantId; @@ -70,8 +75,12 @@ public class TbResourceEntity extends BaseSqlEntity implements BaseE @Column(name = RESOURCE_ETAG_COLUMN) private String etag; - @Column(name = RESOURCE_MEDIA_TYPE_COLUMN) - private String mediaType; + @Type(type = "json") + @Column(name = RESOURCE_DESCRIPTOR_COLUMN) + private JsonNode descriptor; + + @Column(name = RESOURCE_PREVIEW_COLUMN) + private byte[] preview; public TbResourceEntity() { } @@ -91,7 +100,8 @@ public class TbResourceEntity extends BaseSqlEntity implements BaseE this.fileName = resource.getFileName(); this.data = resource.getData(); this.etag = resource.getEtag(); - this.mediaType = resource.getMediaType(); + this.descriptor = resource.getDescriptor(); + this.preview = resource.getPreview(); } @Override @@ -106,7 +116,8 @@ public class TbResourceEntity extends BaseSqlEntity implements BaseE resource.setFileName(fileName); resource.setData(data); resource.setEtag(etag); - resource.setMediaType(mediaType); + resource.setDescriptor(descriptor); + resource.setPreview(preview); return resource; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java index d74f5a3c8e..9ab5024b32 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java @@ -15,24 +15,28 @@ */ package org.thingsboard.server.dao.model.sql; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.util.mapping.JsonStringType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Table; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN; -import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_MEDIA_TYPE_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN; @@ -42,6 +46,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.SEARCH_TEXT_PROPER @Data @EqualsAndHashCode(callSuper = true) @Entity +@TypeDef(name = "json", typeClass = JsonStringType.class) @Table(name = RESOURCE_TABLE_NAME) public class TbResourceInfoEntity extends BaseSqlEntity implements BaseEntity { @@ -66,8 +71,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen @Column(name = RESOURCE_FILE_NAME_COLUMN) private String fileName; - @Column(name = RESOURCE_MEDIA_TYPE_COLUMN) - private String mediaType; + @Type(type = "json") + @Column(name = RESOURCE_DESCRIPTOR_COLUMN) + private JsonNode descriptor; public TbResourceInfoEntity() { } @@ -84,7 +90,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen this.searchText = resource.getSearchText(); this.hashCode = resource.getEtag(); this.fileName = resource.getFileName(); - this.mediaType = resource.getMediaType(); + this.descriptor = resource.getDescriptor(); } @Override @@ -98,7 +104,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity implemen resource.setSearchText(searchText); resource.setEtag(hashCode); resource.setFileName(fileName); - resource.setMediaType(mediaType); + resource.setDescriptor(descriptor); return resource; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 29a6f98409..c046ca1c33 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -15,7 +15,11 @@ */ package org.thingsboard.server.dao.resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ImageDescriptor; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; @@ -25,10 +29,13 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; +import org.thingsboard.server.dao.util.ImageUtils; +import org.thingsboard.server.dao.util.ImageUtils.ProcessedImage; import java.util.Set; @Service +@Slf4j public class BaseImageService extends BaseResourceService implements ImageService { public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator) { @@ -36,18 +43,39 @@ public class BaseImageService extends BaseResourceService implements ImageServic } @Override - public TbResource saveImage(TbResource image) { + public TbResourceInfo saveImage(TbResource image) throws Exception { resourceValidator.validate(image, TbResourceInfo::getTenantId); - if (image.getData() != null) { - } - // generate preview, etc. - return saveResource(image, false); + ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class); + Pair result = processImage(image.getData(), descriptor); + image.setDescriptor(JacksonUtil.valueToTree(result.getLeft())); + image.setPreview(result.getRight()); + + image = saveResource(image, false); + return new TbResourceInfo(image); + } + + private Pair processImage(byte[] data, ImageDescriptor descriptor) throws Exception { + ProcessedImage image = ImageUtils.processImage(data, descriptor.getMediaType(), 250); + ProcessedImage preview = image.getPreview(); + + descriptor.setWidth(image.getWidth()); + descriptor.setHeight(image.getHeight()); + descriptor.setSize(image.getSize()); + + ImageDescriptor previewDescriptor = new ImageDescriptor(); + previewDescriptor.setWidth(preview.getWidth()); + previewDescriptor.setHeight(preview.getHeight()); + previewDescriptor.setMediaType(preview.getMediaType()); + previewDescriptor.setSize(preview.getSize()); + descriptor.setPreviewDescriptor(previewDescriptor); + + return Pair.of(descriptor, preview.getData()); } @Override public TbResourceInfo saveImageInfo(TbResourceInfo imageInfo) { - return null; + return saveResource(new TbResource(imageInfo)); } @Override @@ -80,7 +108,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic @Override public byte[] getImagePreview(TenantId tenantId, TbResourceId imageId) { - return new byte[0]; + return resourceDao.getResourcePreview(tenantId, imageId); + } + + @Override + public void deleteImage(TenantId tenantId, TbResourceId imageId) { + deleteResource(tenantId, imageId); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 30748e5ab3..bb46af22ff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.resource; -import com.datastax.oss.driver.api.core.uuid.Uuids; import com.google.common.hash.Hashing; import com.google.common.util.concurrent.ListenableFuture; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey; @@ -46,7 +46,6 @@ import org.thingsboard.server.dao.service.validator.ResourceDataValidator; import java.util.List; import java.util.Optional; -import java.util.UUID; import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -54,6 +53,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @Service("TbResourceDaoService") @Slf4j @AllArgsConstructor +@Primary public class BaseResourceService extends AbstractCachedEntityService implements ResourceService { public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index 4bf6f752a0..43b79c5213 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -43,4 +43,6 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao byte[] getResourceData(TenantId tenantId, TbResourceId resourceId); + byte[] getResourcePreview(TenantId tenantId, TbResourceId resourceId); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java index 4d5027492d..affde231f5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java @@ -17,8 +17,6 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; -import org.springframework.http.InvalidMediaTypeException; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResource; @@ -94,19 +92,6 @@ public class ResourceDataValidator extends DataValidator { long maxSumResourcesDataInBytes = profileConfiguration.getMaxResourcesInBytes(); validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, resource.getData().length, TB_RESOURCE); } - if (resource.getResourceType().getDefaultMediaType() != null) { - resource.setMediaType(resource.getResourceType().getDefaultMediaType()); - } else { - if (resource.getMediaType() == null) { - throw new DataValidationException("Media type is required"); - } else { - try { - MediaType.parseMediaType(resource.getMediaType()); - } catch (InvalidMediaTypeException e) { - throw new DataValidationException("Invalid media type", e); - } - } - } if (StringUtils.isEmpty(resource.getFileName())) { throw new DataValidationException("Resource file name should be specified!"); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index 3d163e0d4f..28bebf3e4f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -99,6 +99,11 @@ public class JpaTbResourceDao extends JpaAbstractDao findAllTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, @Param("systemAdminId") UUID sysadminId, - @Param("resourceTypes") Collection resourceTypes, + @Param("resourceTypes") List resourceTypes, @Param("searchText") String searchText, Pageable pageable); @Query("SELECT ri FROM TbResourceInfoEntity ri WHERE " + "ri.tenantId = :tenantId " + - "AND (:resourceTypes IS NULL OR ri.resourceType IN :resourceTypes)" + + "AND ri.resourceType IN :resourceTypes " + "AND (:searchText IS NULL OR ilike(ri.title, CONCAT('%', :searchText, '%')) = true)") Page findTenantResourcesByTenantId(@Param("tenantId") UUID tenantId, - @Param("resourceTypes") Collection resourceTypes, + @Param("resourceTypes") List resourceTypes, @Param("searchText") String searchText, Pageable pageable); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index 41c5e005dd..6a47e95ed6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -84,4 +84,7 @@ public interface TbResourceRepository extends JpaRepository mediaTypeMappings = Map.of( + "jpeg", "jpg", + "svg+xml", "svg" + ); + + public static String mediaTypeToFileExtension(String mimeType) { + String subtype = MimeTypeUtils.parseMimeType(mimeType).getSubtype(); + return mediaTypeMappings.getOrDefault(subtype, subtype); + } + + public static String fileExtensionToMediaType(String type, String extension) { + String subtype = mediaTypeMappings.entrySet().stream() + .filter(mapping -> mapping.getValue().equals(extension)) + .map(Map.Entry::getKey).findFirst().orElse(extension); + return new MimeType(type, subtype).toString(); + } + + public static ProcessedImage processImage(byte[] data, String mediaType, int thumbnailMaxDimension) throws Exception { + if (mediaTypeToFileExtension(mediaType).equals("svg")) { + return processSvgImage(data, thumbnailMaxDimension); + } + + BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(data)); + ProcessedImage image = new ProcessedImage(); + image.setMediaType(mediaType); + image.setWidth(bufferedImage.getWidth()); + image.setHeight(bufferedImage.getHeight()); + image.setData(data); + image.setSize(data.length); + + ProcessedImage preview = new ProcessedImage(); + int[] thumbnailDimensions = getThumbnailDimensions(image.getWidth(), image.getHeight(), thumbnailMaxDimension); + preview.setWidth(thumbnailDimensions[0]); + preview.setHeight(thumbnailDimensions[1]); + + if (preview.getWidth() == image.getWidth() && preview.getHeight() == image.getHeight()) { + if (mediaType.equals("image/png")) { + preview.setMediaType(mediaType); + preview.setData(null); + preview.setSize(data.length); + image.setPreview(preview); + return image; + } + } + + BufferedImage thumbnail = new BufferedImage(preview.getWidth(), preview.getHeight(), BufferedImage.TYPE_INT_RGB); + thumbnail.getGraphics().drawImage(bufferedImage, 0, 0, preview.getWidth(), preview.getHeight(), null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(thumbnail, "png", out); + + preview.setMediaType("image/png"); + preview.setData(out.toByteArray()); + preview.setSize(preview.getData().length); + image.setPreview(preview); + return image; + } + + public static ProcessedImage processSvgImage(byte[] data, int thumbnailMaxDimension) throws Exception { + ProcessedImage image = new ProcessedImage(); + image.setWidth(0); + image.setHeight(0); + image.setData(data); + image.setSize(data.length); + + PNGTranscoder transcoder = new PNGTranscoder(); + transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_WIDTH, (float) thumbnailMaxDimension); + transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_HEIGHT, (float) thumbnailMaxDimension); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + transcoder.transcode(new TranscoderInput(new ByteArrayInputStream(data)), new TranscoderOutput(out)); + byte[] pngThumbnail = out.toByteArray(); + + ProcessedImage preview = new ProcessedImage(); + preview.setWidth(thumbnailMaxDimension); + preview.setHeight(thumbnailMaxDimension); + preview.setMediaType("image/png"); + preview.setData(pngThumbnail); + preview.setSize(pngThumbnail.length); + image.setPreview(preview); + return image; + } + + private static int[] getThumbnailDimensions(int originalWidth, int originalHeight, int maxDimension) { + if (originalWidth <= maxDimension && originalHeight <= maxDimension) { + return new int[]{originalWidth, originalHeight}; + } + int thumbnailWidth; + int thumbnailHeight; + double aspectRatio = (double) originalWidth / originalHeight; + if (originalWidth > originalHeight) { + thumbnailWidth = maxDimension; + thumbnailHeight = (int) (maxDimension / aspectRatio); + } else { + thumbnailWidth = (int) (maxDimension * aspectRatio); + thumbnailHeight = maxDimension; + } + return new int[]{thumbnailWidth, thumbnailHeight}; + } + + @Data + public static class ProcessedImage { + private String mediaType; + private int width; + private int height; + private byte[] data; + private long size; + private ProcessedImage preview; + } + +} diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index db59878c39..6b30151536 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -716,7 +716,8 @@ CREATE TABLE IF NOT EXISTS resource ( file_name varchar(255) NOT NULL, data bytea, etag varchar, - media_type varchar(255), + descriptor varchar, + preview bytea, CONSTRAINT resource_unq_key UNIQUE (tenant_id, resource_type, resource_key) ); diff --git a/pom.xml b/pom.xml index d5b517c48e..aceeb67cc2 100755 --- a/pom.xml +++ b/pom.xml @@ -153,6 +153,7 @@ 1.12.1 6.4.2 1.34.1 + 1.14 @@ -2029,6 +2030,16 @@ google-oauth-client ${google-oauth-client.version} + + org.apache.xmlgraphics + batik-transcoder + ${apache-xmlgraphics.version} + + + org.apache.xmlgraphics + batik-codec + ${apache-xmlgraphics.version} + diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index b9a73a416c..61d413f60e 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -61,8 +61,6 @@ export interface ResourceInfo extends Omit, 'name' | 'lab title?: string; resourceType: ResourceType; fileName: string; - mediaType: string; - link: string; } export interface Resource extends ResourceInfo {