From cb097bb6376cc34a622adb918aded2f27c9e76fb Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 15 Oct 2024 17:08:15 +0300 Subject: [PATCH] Change dashboard export structure; images export-import improvements --- .../controller/DashboardController.java | 67 +++-- .../server/controller/ImageController.java | 51 +--- .../controller/WidgetTypeController.java | 25 +- .../entitiy/AbstractTbEntityService.java | 3 + .../dashboard/DefaultTbDashboardService.java | 37 +++ .../entitiy/dashboard/TbDashboardService.java | 6 + .../type/DefaultWidgetTypeService.java | 40 +++ .../widgets/type/TbWidgetTypeService.java | 8 + .../resource/DefaultTbImageService.java | 66 ++++- .../service/resource/TbImageService.java | 6 + .../server/controller/AbstractWebTest.java | 21 ++ .../controller/DashboardControllerTest.java | 35 +++ .../controller/ImageControllerTest.java | 27 +- .../server/dao/resource/ImageService.java | 7 +- ...xportData.java => ResourceExportData.java} | 11 +- .../server/common/data/TbResourceInfo.java | 7 +- .../data/dashboard/DashboardExportData.java | 28 ++ .../common/data/widget/WidgetExportData.java | 27 ++ .../thingsboard/common/util/JacksonUtil.java | 53 +++- .../server/dao/resource/BaseImageService.java | 245 +++++++++--------- .../thingsboard/rest/client/RestClient.java | 8 +- ui-ngx/src/app/core/http/dashboard.service.ts | 6 +- .../import-export/import-export.service.ts | 7 +- .../src/app/shared/models/dashboard.models.ts | 5 + 24 files changed, 554 insertions(+), 242 deletions(-) rename common/data/src/main/java/org/thingsboard/server/common/data/{ImageExportData.java => ResourceExportData.java} (91%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/dashboard/DashboardExportData.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetExportData.java diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 2949df35a7..a602b257f9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -26,7 +26,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +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; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -42,6 +45,7 @@ import org.thingsboard.server.common.data.HomeDashboard; import org.thingsboard.server.common.data.HomeDashboardInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.dashboard.DashboardExportData; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; @@ -111,8 +115,7 @@ public class DashboardController extends BaseController { notes = "Get the server time (milliseconds since January 1, 1970 UTC). " + "Used to adjust view of the dashboards according to the difference between browser and server time.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/dashboard/serverTime") @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "1636023857137"))) public long getServerTime() throws ThingsboardException { return System.currentTimeMillis(); @@ -124,8 +127,7 @@ public class DashboardController extends BaseController { "It also impacts the 'Grouping interval' in case of any other 'Data aggregation function' is selected. " + "The actual value of the limit is configurable in the system configuration file.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/dashboard/maxDatapointsLimit", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/dashboard/maxDatapointsLimit") @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "5000"))) public long getMaxDatapointsLimit() throws ThingsboardException { return maxDatapointsLimit; @@ -134,8 +136,7 @@ public class DashboardController extends BaseController { @ApiOperation(value = "Get Dashboard Info (getDashboardInfoById)", notes = "Get the information about the dashboard based on 'dashboardId' parameter. " + DASHBOARD_INFO_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/dashboard/info/{dashboardId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/dashboard/info/{dashboardId}") public DashboardInfo getDashboardInfoById( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { @@ -148,8 +149,7 @@ public class DashboardController extends BaseController { notes = "Get the dashboard based on 'dashboardId' parameter. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/dashboard/{dashboardId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/dashboard/{dashboardId}") public Dashboard getDashboardById( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId, @@ -164,6 +164,15 @@ public class DashboardController extends BaseController { return result; } + @GetMapping(value = "/dashboard/{dashboardId}/export") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + public DashboardExportData exportDashboard(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) UUID id) throws ThingsboardException { + DashboardId dashboardId = new DashboardId(id); + Dashboard dashboard = checkDashboardId(dashboardId, Operation.READ); + return tbDashboardService.exportDashboard(getTenantId(), dashboard, getCurrentUser()); + } + @ApiOperation(value = "Create Or Update Dashboard (saveDashboard)", notes = "Create or update the Dashboard. When creating dashboard, platform generates Dashboard Id as " + UUID_WIKI_LINK + "The newly created Dashboard id will be present in the response. " + @@ -172,24 +181,30 @@ public class DashboardController extends BaseController { "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Dashboard entity. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/dashboard", method = RequestMethod.POST) - @ResponseBody - public Dashboard saveDashboard( - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the dashboard.") - @RequestBody Dashboard dashboard) throws Exception { + @PostMapping(value = "/dashboard") + public Dashboard saveDashboard(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the dashboard.") + @RequestBody Dashboard dashboard) throws Exception { dashboard.setTenantId(getTenantId()); checkEntity(dashboard.getId(), dashboard, Resource.DASHBOARD); return tbDashboardService.save(dashboard, getCurrentUser()); } + @PostMapping(value = "/dashboard/import") + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + public Dashboard importDashboard(@RequestBody DashboardExportData exportData) throws Exception { + Dashboard dashboard = exportData.getDashboard(); + dashboard.setTenantId(getTenantId()); + dashboard.setId(null); + checkEntity(dashboard.getId(), dashboard, Resource.DASHBOARD); + return tbDashboardService.importDashboard(exportData, getCurrentUser()); + } + @ApiOperation(value = "Delete the Dashboard (deleteDashboard)", notes = "Delete the Dashboard." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/dashboard/{dashboardId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) - public void deleteDashboard( - @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) - @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { + @DeleteMapping(value = "/dashboard/{dashboardId}") + public void deleteDashboard(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) + @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { checkParameter(DASHBOARD_ID, strDashboardId); DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); Dashboard dashboard = checkDashboardId(dashboardId, Operation.DELETE); @@ -200,8 +215,7 @@ public class DashboardController extends BaseController { notes = "Assign the Dashboard to specified Customer or do nothing if the Dashboard is already assigned to that Customer. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/dashboard/{dashboardId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/{customerId}/dashboard/{dashboardId}") public Dashboard assignDashboardToCustomer( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -222,8 +236,7 @@ public class DashboardController extends BaseController { notes = "Unassign the Dashboard from specified Customer or do nothing if the Dashboard is already assigned to that Customer. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/dashboard/{dashboardId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/customer/{customerId}/dashboard/{dashboardId}") public Dashboard unassignDashboardFromCustomer( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -243,8 +256,7 @@ public class DashboardController extends BaseController { "Returns the Dashboard object. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/dashboard/{dashboardId}/customers", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/dashboard/{dashboardId}/customers") public Dashboard updateDashboardCustomers( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId, @@ -261,8 +273,7 @@ public class DashboardController extends BaseController { notes = "Adds the list of Customers to the existing list of assignments for the Dashboard. Keeps previous assignments to customers that are not in the provided list. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/dashboard/{dashboardId}/customers/add", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/dashboard/{dashboardId}/customers/add") public Dashboard addDashboardCustomers( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId, @@ -279,8 +290,7 @@ public class DashboardController extends BaseController { notes = "Removes the list of Customers from the existing list of assignments for the Dashboard. Keeps other assignments to customers that are not in the provided list. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/dashboard/{dashboardId}/customers/remove", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/dashboard/{dashboardId}/customers/remove") public Dashboard removeDashboardCustomers( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId, @@ -655,4 +665,5 @@ public class DashboardController extends BaseController { } return customerIds; } + } 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 88b33389a5..0c04e586ef 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -40,7 +40,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.ImageDescriptor; -import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; @@ -61,7 +61,6 @@ 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.Base64; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; @@ -193,53 +192,19 @@ public class ImageController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = IMAGE_URL + "/export") - public ImageExportData exportImage(@Parameter(description = IMAGE_TYPE_PARAM_DESCRIPTION, schema = @Schema(allowableValues = {"tenant", "system"}), required = true) - @PathVariable String type, - @Parameter(description = IMAGE_KEY_PARAM_DESCRIPTION, required = true) - @PathVariable String key) throws Exception { + public ResourceExportData exportImage(@Parameter(description = IMAGE_TYPE_PARAM_DESCRIPTION, schema = @Schema(allowableValues = {"tenant", "system"}), required = true) + @PathVariable String type, + @Parameter(description = IMAGE_KEY_PARAM_DESCRIPTION, required = true) + @PathVariable String key) throws Exception { TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ); - ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); - byte[] data = imageService.getImageData(imageInfo.getTenantId(), imageInfo.getId()); - return ImageExportData.builder() - .mediaType(descriptor.getMediaType()) - .fileName(imageInfo.getFileName()) - .title(imageInfo.getTitle()) - .subType(imageInfo.getResourceSubType().name()) - .resourceKey(imageInfo.getResourceKey()) - .isPublic(imageInfo.isPublic()) - .publicResourceKey(imageInfo.getPublicResourceKey()) - .data(Base64.getEncoder().encodeToString(data)) - .build(); + return tbImageService.exportImage(imageInfo); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping("/api/image/import") - public TbResourceInfo importImage(@RequestBody ImageExportData imageData) throws Exception { + public TbResourceInfo importImage(@RequestBody ResourceExportData imageData) 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(imageData.getFileName()); - if (StringUtils.isNotEmpty(imageData.getTitle())) { - image.setTitle(imageData.getTitle()); - } else { - image.setTitle(imageData.getFileName()); - } - if (StringUtils.isNotEmpty(imageData.getSubType())) { - image.setResourceSubType(ResourceSubType.valueOf(imageData.getSubType())); - } else { - image.setResourceSubType(ResourceSubType.IMAGE); - } - image.setResourceType(ResourceType.IMAGE); - image.setResourceKey(imageData.getResourceKey()); - image.setPublic(imageData.isPublic()); - image.setPublicResourceKey(imageData.getPublicResourceKey()); - ImageDescriptor descriptor = new ImageDescriptor(); - descriptor.setMediaType(imageData.getMediaType()); - image.setDescriptorValue(descriptor); - image.setData(Base64.getDecoder().decode(imageData.getData())); - return tbImageService.save(image, user); + return tbImageService.importImage(imageData, false, user); } @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index 03f1c52faf..48b6946d0c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -21,7 +21,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -39,12 +41,12 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.DeprecatedFilter; +import org.thingsboard.server.common.data.widget.WidgetExportData; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeFilter; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.resource.ImageService; @@ -56,6 +58,7 @@ import org.thingsboard.server.service.security.permission.Resource; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.INLINE_IMAGES; @@ -93,8 +96,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type Details (getWidgetTypeById)", notes = "Get the Widget Type Details based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetType/{widgetTypeId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetType/{widgetTypeId}") public WidgetTypeDetails getWidgetTypeById( @Parameter(description = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("widgetTypeId") String strWidgetTypeId, @@ -109,6 +111,14 @@ public class WidgetTypeController extends AutoCommitController { return result; } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/widgetType/{widgetTypeId}/export") + public WidgetExportData exportWidgetType(@Parameter(description = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("widgetTypeId") UUID widgetTypeId) throws ThingsboardException { + WidgetTypeDetails widgetTypeDetails = checkWidgetTypeId(new WidgetTypeId(widgetTypeId), Operation.READ); + return tbWidgetTypeService.exportWidgetType(getTenantId(), widgetTypeDetails, getCurrentUser()); + } + @ApiOperation(value = "Get Widget Type Info (getWidgetTypeInfoById)", notes = "Get the Widget Type Info based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @@ -151,6 +161,15 @@ public class WidgetTypeController extends AutoCommitController { return tbWidgetTypeService.save(widgetTypeDetails, updateExistingByFqn != null && updateExistingByFqn, currentUser); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @PostMapping(value = "/widgetType/import") + public WidgetTypeDetails importWidgetType(@RequestBody WidgetExportData exportData) throws Exception { + WidgetTypeDetails widgetTypeDetails = exportData.getWidgetTypeDetails(); + widgetTypeDetails.setTenantId(getTenantId()); + checkEntity(widgetTypeDetails.getId(), widgetTypeDetails, Resource.WIDGET_TYPE); + return tbWidgetTypeService.importWidgetType(exportData, getCurrentUser()); + } + @ApiOperation(value = "Delete widget type (deleteWidgetType)", notes = "Deletes the Widget Type. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 438cb58b91..fa676903fe 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -35,6 +35,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.security.permission.AccessControlService; import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; @@ -71,6 +72,8 @@ public abstract class AbstractTbEntityService { @Autowired(required = false) @Lazy private EntitiesVersionControlService vcService; + @Autowired + protected AccessControlService accessControlService; protected boolean isTestProfile() { return Set.of(this.env.getActiveProfiles()).contains("test"); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java index ee8b2d7e47..471b9cb45e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/DefaultTbDashboardService.java @@ -20,9 +20,13 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceExportData; +import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.ShortCustomerInfo; +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.dashboard.DashboardExportData; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; @@ -30,10 +34,16 @@ import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +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.HashSet; +import java.util.List; import java.util.Set; @Service @@ -42,6 +52,8 @@ import java.util.Set; public class DefaultTbDashboardService extends AbstractTbEntityService implements TbDashboardService { private final DashboardService dashboardService; + private final ImageService imageService; + private final TbImageService tbImageService; @Override public Dashboard save(Dashboard dashboard, User user) throws Exception { @@ -283,4 +295,29 @@ public class DefaultTbDashboardService extends AbstractTbEntityService implement } } + @Override + public DashboardExportData exportDashboard(TenantId tenantId, Dashboard dashboard, SecurityUser user) throws ThingsboardException { + List images = imageService.inlineImages(dashboard); + for (TbResourceInfo imageInfo : images) { + accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.READ, imageInfo.getId(), imageInfo); + } + + DashboardExportData exportData = new DashboardExportData(); + exportData.setDashboard(dashboard); + exportData.setResources(images.stream() + .map(tbImageService::exportImage) + .toList()); + return exportData; + } + + @Override + public Dashboard importDashboard(DashboardExportData exportData, SecurityUser user) throws Exception { + for (ResourceExportData resourceExportData : exportData.getResources()) { + if (resourceExportData.getType() == ResourceType.IMAGE) { + tbImageService.importImage(resourceExportData, true, user); + } + } + return save(exportData.getDashboard(), user); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java index 10a727e160..f923372643 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/dashboard/TbDashboardService.java @@ -18,12 +18,14 @@ package org.thingsboard.server.service.entitiy.dashboard; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.dashboard.DashboardExportData; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; import java.util.Set; @@ -47,4 +49,8 @@ public interface TbDashboardService extends SimpleTbEntityService { Dashboard unassignDashboardFromCustomer(Dashboard dashboard, Customer customer, User user) throws ThingsboardException; + DashboardExportData exportDashboard(TenantId tenantId, Dashboard dashboard, SecurityUser user) throws ThingsboardException; + + Dashboard importDashboard(DashboardExportData exportData, SecurityUser user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java index 76027f3621..6fe14a53dc 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/DefaultWidgetTypeService.java @@ -18,15 +18,27 @@ package org.thingsboard.server.service.entitiy.widgets.type; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ResourceExportData; +import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.StringUtils; +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.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetExportData; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +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.List; @Service @TbCoreComponent @@ -35,6 +47,8 @@ public class DefaultWidgetTypeService extends AbstractTbEntityService implements private final WidgetTypeService widgetTypeService; + private final ImageService imageService; + private final TbImageService tbImageService; @Override public WidgetTypeDetails save(WidgetTypeDetails entity, User user) throws Exception { @@ -75,4 +89,30 @@ public class DefaultWidgetTypeService extends AbstractTbEntityService implements throw e; } } + + @Override + public WidgetExportData exportWidgetType(TenantId tenantId, WidgetTypeDetails widgetTypeDetails, SecurityUser user) throws ThingsboardException { + List images = imageService.inlineImages(widgetTypeDetails); + for (TbResourceInfo imageInfo : images) { + accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.READ, imageInfo.getId(), imageInfo); + } + + WidgetExportData exportData = new WidgetExportData(); + exportData.setWidgetTypeDetails(widgetTypeDetails); + exportData.setResources(images.stream() + .map(tbImageService::exportImage) + .toList()); + return exportData; + } + + @Override + public WidgetTypeDetails importWidgetType(WidgetExportData exportData, SecurityUser user) throws Exception { + for (ResourceExportData resourceExportData : exportData.getResources()) { + if (resourceExportData.getType() == ResourceType.IMAGE) { + tbImageService.importImage(resourceExportData, true, user); + } + } + return save(exportData.getWidgetTypeDetails(), user); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java index ff165e6499..1b200bcb2e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/widgets/type/TbWidgetTypeService.java @@ -16,11 +16,19 @@ package org.thingsboard.server.service.entitiy.widgets.type; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetExportData; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; public interface TbWidgetTypeService extends SimpleTbEntityService { WidgetTypeDetails save(WidgetTypeDetails widgetTypeDetails, boolean updateExistingByFqn, User user) throws Exception; + WidgetExportData exportWidgetType(TenantId tenantId, WidgetTypeDetails widgetTypeDetails, SecurityUser user) throws ThingsboardException; + + WidgetTypeDetails importWidgetType(WidgetExportData exportData, SecurityUser user) throws Exception; + } 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 78f446af4d..156769d54f 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 @@ -24,7 +24,9 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ImageDescriptor; -import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.ResourceExportData; +import org.thingsboard.server.common.data.ResourceSubType; +import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbImageDeleteResult; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; @@ -37,13 +39,19 @@ import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; +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.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.StringUtils.isNotEmpty; + @Service @Slf4j @TbCoreComponent @@ -89,7 +97,7 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb try { var oldEtag = getEtag(image); TbResourceInfo existingImage = null; - if (image.getId() == null && StringUtils.isNotEmpty(image.getResourceKey())) { + if (image.getId() == null && isNotEmpty(image.getResourceKey())) { existingImage = imageService.getImageInfoByTenantIdAndKey(tenantId, image.getResourceKey()); if (existingImage != null) { image.setId(existingImage.getId()); @@ -174,6 +182,60 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb } } + @Override + public ResourceExportData exportImage(TbResourceInfo imageInfo) { + ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); + byte[] data = imageService.getImageData(imageInfo.getTenantId(), imageInfo.getId()); + return ResourceExportData.builder() + .mediaType(descriptor.getMediaType()) + .fileName(imageInfo.getFileName()) + .title(imageInfo.getTitle()) + .type(ResourceType.IMAGE) + .subType(imageInfo.getResourceSubType()) + .resourceKey(imageInfo.getResourceKey()) + .isPublic(imageInfo.isPublic()) + .publicResourceKey(imageInfo.getPublicResourceKey()) + .data(Base64.getEncoder().encodeToString(data)) + .build(); + } + + @Override + public TbResourceInfo importImage(ResourceExportData imageData, boolean checkExisting, SecurityUser user) throws Exception { + TbResource image = new TbResource(); + image.setTenantId(user.getTenantId()); + accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.CREATE, null, image); + + byte[] data = Base64.getDecoder().decode(imageData.getData()); + if (checkExisting) { + String etag = imageService.calculateImageEtag(data); + TbResourceInfo existingImage = imageService.findSystemOrTenantImageByEtag(user.getTenantId(), etag); + if (existingImage != null) { + return existingImage; + } + } + + image.setFileName(imageData.getFileName()); + if (isNotEmpty(imageData.getTitle())) { + image.setTitle(imageData.getTitle()); + } else { + image.setTitle(imageData.getFileName()); + } + if (imageData.getSubType() != null) { + image.setResourceSubType(imageData.getSubType()); + } else { + image.setResourceSubType(ResourceSubType.IMAGE); + } + image.setResourceType(ResourceType.IMAGE); + image.setResourceKey(imageData.getResourceKey()); + image.setPublic(imageData.isPublic()); + image.setPublicResourceKey(imageData.getPublicResourceKey()); + ImageDescriptor descriptor = new ImageDescriptor(); + descriptor.setMediaType(imageData.getMediaType()); + image.setDescriptorValue(descriptor); + image.setData(data); + return save(image, user); + } + private void evictFromCache(TenantId tenantId, List toEvict) { toEvict.forEach(this::evictETags); clusterService.broadcastToCore(TransportProtos.ToCoreNotificationMsg.newBuilder() 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 be69f1448f..56716f79b7 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,11 +15,13 @@ */ package org.thingsboard.server.service.resource; +import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.TbImageDeleteResult; 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.dao.resource.ImageCacheKey; +import org.thingsboard.server.service.security.model.SecurityUser; public interface TbImageService { @@ -35,4 +37,8 @@ public interface TbImageService { void evictETags(ImageCacheKey imageCacheKey); + ResourceExportData exportImage(TbResourceInfo imageInfo); + + TbResourceInfo importImage(ResourceExportData imageData, boolean checkExisting, SecurityUser user) throws Exception; + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index af20c71e5b..d37d04d52d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -41,18 +41,22 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.context.WebApplicationContext; @@ -74,6 +78,7 @@ import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; @@ -131,6 +136,7 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle; import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; @@ -1162,4 +1168,19 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return oAuth2Client; } + protected TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception { + return this.uploadImage(httpMethod, url, null, filename, mediaType, content); + } + + protected TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String subType, String filename, String mediaType, byte[] content) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", filename, mediaType, content); + var request = MockMvcRequestBuilders.multipart(httpMethod, url).file(file); + if (StringUtils.isNotEmpty(subType)) { + var imageSubTypePart = new MockPart("imageSubType", subType.getBytes(StandardCharsets.UTF_8)); + request.part(imageSubTypePart); + } + setJwtToken(request); + return readResponse(mockMvc.perform(request).andExpect(status().isOk()), TbResourceInfo.class); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java index d427c26d05..6f8d62a207 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java @@ -26,18 +26,22 @@ import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpMethod; import org.springframework.test.context.ContextConfiguration; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ShortCustomerInfo; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.dashboard.DashboardExportData; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; @@ -49,6 +53,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -575,7 +580,37 @@ public class DashboardControllerTest extends AbstractControllerTest { .andReturn().getResponse().getContentAsString(); String errorMessage = JacksonUtil.toJsonNode(response).get("message").asText(); assertThat(errorMessage).containsIgnoringCase("referenced by an asset profile"); + } + @Test + public void testExportImportDashboard() throws Exception { + TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", "image12.png", "image/png", ImageControllerTest.PNG_IMAGE); + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard"); + dashboard.setConfiguration(JacksonUtil.newObjectNode() + .put("someImage", "tb-image;/api/images/tenant/" + imageInfo.getResourceKey())); + dashboard = doPost("/api/dashboard", dashboard, Dashboard.class); + + DashboardExportData dashboardExportData = doGet("/api/dashboard/" + dashboard.getUuidId() + "/export", DashboardExportData.class); + String imageRef = dashboardExportData.getDashboard().getConfiguration().get("someImage").asText(); + assertThat(imageRef).isEqualTo("tb-image:" + Base64.getEncoder().encodeToString(imageInfo.getResourceKey().getBytes()) + ":" + + Base64.getEncoder().encodeToString(imageInfo.getName().getBytes()) + ":" + + Base64.getEncoder().encodeToString(imageInfo.getResourceSubType().name().getBytes()) + ":" + + imageInfo.getEtag() + ";data:image/png;base64,"); + + List resources = dashboardExportData.getResources(); + assertThat(resources).singleElement().satisfies(resource -> { + assertThat(resource.getResourceKey()).isEqualTo(imageInfo.getResourceKey()); + assertThat(resource.getData()).isEqualTo(Base64.getEncoder().encodeToString(ImageControllerTest.PNG_IMAGE)); + }); + + doDelete("/api/dashboard/" + dashboard.getId()).andExpect(status().isOk()); + doDelete("/api/images/tenant/" + imageInfo.getResourceKey()).andExpect(status().isOk()); + + Dashboard importedDashboard = doPost("/api/dashboard/import", dashboardExportData, Dashboard.class); + assertThat(importedDashboard.getConfiguration().get("someImage").asText()).isEqualTo("tb-image;/api/images/tenant/" + imageInfo.getResourceKey()); + TbResourceInfo importedImageInfo = doGet("/api/images/tenant/" + imageInfo.getResourceKey() + "/info", TbResourceInfo.class); + assertThat(importedImageInfo.getEtag()).isEqualTo(imageInfo.getEtag()); } private Dashboard createDashboard(String title) { diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index 9b9da4e797..811eddc4f4 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -22,13 +22,10 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.TestPropertySource; -import org.springframework.mock.web.MockPart; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ImageDescriptor; -import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.StringUtils; @@ -39,7 +36,6 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.resource.TbResourceRepository; -import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; @@ -52,7 +48,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) public class ImageControllerTest extends AbstractControllerTest { - private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg=="); + protected static final byte[] PNG_IMAGE = Base64.getDecoder().decode("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"); private static final byte[] JPEG_IMAGE = Base64.getDecoder().decode("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"); private static final byte[] SVG_IMAGE = Base64.getDecoder().decode("PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJzdmc0NDA4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHg9IjAiIHk9IjAiIHZpZXdCb3g9IjAgMCAxNTAgMTUwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGU+LnN0MntmaWxsOiMyNzg2MjJ9PC9zdHlsZT48ZyBpZD0ibGF5ZXIxIj48ZyBpZD0icGF0aDY4ODEtMy01LTUtMS04LTQtNC03LTgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNDYuNDM4IC0yNzYuMDI4KSIgb3BhY2l0eT0iLjg5MiI+PHJhZGlhbEdyYWRpZW50IGlkPSJTVkdJRF8xXyIgY3g9IjMwODUuMjE1IiBjeT0iMzE3OC40NTgiIHI9IjQ5LjkwMSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCguNjc5MyAuMDA3NiAtLjUwOSAuNTYxMiAtMjMyLjYyOSAtMTQxMS43MjUpIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9Ii4xODgiLz48L3JhZGlhbEdyYWRpZW50PjxwYXRoIGQ9Ik0yODUuNiAzODguNWMxMC4zLTEyLjQgNC40LTIyLjQtMTQuNC0yMi40LTE4LjkgMC00Mi40IDEwLTUzLjkgMjIuNC0xNi44IDE4IC40IDIzLjUtLjIgMzUtLjEgMS44IDMuOSAxLjggNyAwIDE5LjgtMTEuNSA0Ni41LTE3IDYxLjUtMzUiIGZpbGw9InVybCgjU1ZHSURfMV8pIi8+PC9nPjxwYXRoIGlkPSJwYXRoNjg4MS0zLTUtNS0xLTgtNC00IiBjbGFzcz0ic3QyIiBkPSJNMTI0LjcgNjkuMWMtLjktMjcuNS0yMi4zLTQ5LjgtNDkuOC00OS44cy00OSAyMi4zLTQ5LjggNDkuOGMtMS4zIDQwLjEgMzAuNyA1Mi4yIDQ0LjcgNzggMi4yIDQgOCA0IDEwLjEgMCAxNC4xLTI1LjggNDYuMS0zNy45IDQ0LjgtNzgiLz48L2c+PGcgaWQ9Imc0OTI4Ij48Y2lyY2xlIGlkPSJwYXRoNDk3OCIgY2xhc3M9InN0MiIgY3g9Ijc0LjkiIGN5PSI2OS4xIiByPSI0OS45Ii8+PGcgaWQ9Imc0OTE1Ij48cGF0aCBpZD0icGF0aDY4ODMtMi0zLTUtMi00LTktNC05IiBkPSJNNzQuOCAxMDYuNGMtMjAuNiAwLTM3LjQtMTYuNy0zNy40LTM3LjQgMC0yMC42IDE2LjctMzcuNCAzNy40LTM3LjQgMjAuNiAwIDM3LjQgMTYuNyAzNy40IDM3LjRzLTE2LjcgMzcuNC0zNy40IDM3LjQiIGZpbGw9IiNmZmYiLz48L2c+PC9nPjxwYXRoIGNsYXNzPSJzdDIiIGQ9Ik05NS45IDQ2LjZWNDloLTEwdi0yLjVsMTAgLjF6bS0yIDUuM2gtOHYyLjVoOHYtMi41em0tOCA3LjloNnYtMi41aC02djIuNXptNCAyLjloLTR2Mi41aDR2LTIuNXptLTQgNy44aDJWNjhoLTJ2Mi41em0xLjUgMTRjMCA2LjktNS41IDEyLjUtMTIuMyAxMi41cy0xMi4zLTUuNi0xMi4zLTEyLjVjMC00LjUgMi4zLTguNSA2LjEtMTAuN1Y0NS41YzAtMy41IDIuOC02LjMgNi4yLTYuM3M2LjIgMi44IDYuMiA2LjN2MjguM2MzLjggMi4yIDYuMSA2LjMgNi4xIDEwLjd6bS0yLjQgMGMwLTMuOC0yLjEtNy4yLTUuNC04LjlsLS43LS4zVjQ1LjVjMC0yLjEtMS43LTMuOC0zLjgtMy44LTIuMSAwLTMuOCAxLjctMy44IDMuOHYyOS44bC0uNy4zYy0zLjMgMS43LTUuNCA1LjEtNS40IDguOSAwIDUuNSA0LjQgMTAgOS45IDEwUzg1IDkwIDg1IDg0LjV6bS0yLjEgMGMwIDQuNC0zLjUgOC03LjggOHMtNy44LTMuNi03LjgtOGMwLTMuNiAyLjQtNi44IDUuOC03LjdsLjUtLjFWNjEuNWgzLjF2MTUuMmwuNS4xYzMuMyAxIDUuNyA0LjEgNS43IDcuN3ptLTcuNC01LjNjLS4yLS44LTEtMS40LTEuOS0xLjItMyAuNy01IDMuMy01IDYuNCAwIC45LjcgMS42IDEuNiAxLjZzMS42LS43IDEuNi0xLjZjMC0xLjYgMS4xLTMgMi42LTMuMy43LS4yIDEuMy0xIDEuMS0xLjl6Ii8+PC9zdmc+Cg=="); private static final byte[] PNG_IMAGE_5KB = Base64.getDecoder().decode("UklGRmgVAABXRUJQVlA4IFwVAAAQXQCdASovARgBPjEYikQiIaERWJT8IAMEtLdxzAXItG42oJUm33RgT7z8x3Vo4W33cTP1C/sPaN/Qvxw/dD118CHgH1u/cTnbxI/jP1f+r/279e/y1/Af853s+qr1BfxH+L/1j8if7X+1HHX6j5gvqD8v/uH5Q/6P9wvap/gPy79zvEA/kP8n/uP5b/33/8/UX+0/2/jQfUvUD/kH9Z/y/+R/bb/Rf//7Yf4b/V/47/Ifth7Svy7+5f7z/NfvH/pPsE/jX8z/yv9y/x3/K/v3///9v3cevL9qPY7/Vf/m/n+OTn1cUaLSCzh0SUJyC2T6uKNFpBZw6JKE5BbJ9XFCPGQ7gy2OKNDbAmIci2T6ldoAjqAYo2SvT4eprwmLSCdP0CSx4sY/4M53bpaJKB8vg6Rh1mbAsTHAfSL8Ab3XyM9a4+K+HmwfYyPO6QPBxTjdW2zJU1MlpriXMVKKfcb2q/5sJyQTqWFn5egpm4d1PN9r0e6BNOfY7KHecp3mOM9iHLkzVLouEz5pr2/Cn8zxr3h6Rt3XfGyjIrvxn58wVxPe50SUZsTJyLy2PlYOXPOssIXLWte7TW8TL9bO7wfCrq2w9Mu8LiMBvXJkZGyrQWSKW/cTdUk6XzcV1Tj4fboFRq/cXKzJkQKaSCOMSdFogqPXFg/mwvHy7/sx6rwthOKMejNSzxZhSaE+mAlqQnIJg3TGtnfNxYtPtDegFVcUZWhUaLR/PaJILOHRJQnILZPq4o0WhVj6hCy48rg+tiY2hLhndLjUugRw2t1OAmv+EaktDIY2b3b7p3cWkFnDlYgoKNjubMwJU+95GAOUAClSCXxlLmhJA3NEB7sq9MwEqTWXE3fEs4jyY+8SJ9XFGi0gs4dElCcfRnJAKA9D90NvEq+s4MrwaFDIkX68WsZDQ7HJAJWei9sZ9TZIn1weDUDtLKaFW7tWWOhawgSDD/s8FraBQMyDC+YTGE/CSVxg3z8PI4CyJDgxHizqtedzYlgA/v+3lgAAAAY9gQZZrj7UiwhCdE84FJl//+UAhYQ0JU2xKdjIc9IEw0vm1rHrMFhLEQprGoM7R1xvFCz+NIR32BkpcStRDFVWAqfgTsjePbc8XU1hQ6V//SgGLNLWc14CydLQpzoCiQZDO0qis2FnaSZ3oUvpS3WS78e8LF4/JcCjqwMdRJ5uIVqOnt6SY4oILuttcMnrWzWxDt+VtsEWniozDs4k76rTKzcuYwHOFDXUAFoWc0XOIxA3+lkr03mt+Vr7k16NkzbRQp+XoJyvRXzLjmNmNHm8HJoFHdzQAAnfStaG9LEHz0tPR30DQDuiStdzUiALV7yeSoAXQ5pfvYqKBZ1v1lrHoCOAYpnyb4N9N+bmQ/B/ATWYtRGe7ZyqQUFQvoYzfwCpiBdlYMx4Oxb4n79oGcwPlvPkAhaSSfdmPKbLTL+3f7gs5n280IdRgDF3dFiEmSENeXeZuORJtlREu9H8BC6eJEChCHsgc/lWldYzLe1nFVm8s/IOuGc1X7klUdMWeOZ+KVuB6C4AgBbYbges76NdF5mb5t/h+50jsOifCPYPJy2wVLp80ab35r7JcX1fZByiaSd+w+A2AJ7o/x1nenAArMS0U9inuan8ZXntpNLk74yuJDQJ29C/s5NcSATusQ8a9RzZUernKFkGtsmOSlw5qN3BY0dikE+uM+Bitd6GvPXf/e+jeGZBAOhyklPTt+DHw5ty8Neug+mX0bTLp+TJ3KATUFHm6DP82CdZL+LLsLI5kLGgTkVg2gOXB1Z5xYwrP03SH9N3C9S3VSErVReB2o9FA1rcQSH2/TE80MbYARbFCVjV2SkynYfbpmmVdhs0vDRJTF4APCxi6Inexa0eBL5a9WdKPZ+bsf0UVraFugM8zepyoIlJqd12rTawWAf9KpEWxEs5Km3voKyguq8j4lynjB/aVk/rruDHx+FwQJNG/Q4X430YVoR4UlRvMoYaIKZznGzflc0wUgQJ1d057R73AQ/kpD/oVP3n9qFHlAl/rKntQZlrmOPJaPCOCCdtzj7tiqsBaBpw3oRi0zPBBKfjzPjRYnEjNgBMZPaeRo5rN0AzL9jyjrXOiy7G/z9olzZwyNa0KAHQoJILQZr9O1ytN0QhF0UINMDB/xVsNZ2gxChu0Y3swcH/BTwgtfdjUoM39rJ5z/2RX3s3tyqejhXeh3KKE8hwZJz9+5BWizzIUEm0iX5A5KTz2eZt8/oDlpgSg/7wXUKqFDvjCDyrZ4Ww09oMoOj2UwFbN6hQ48cCEi67TL6I3RDfGtKylreyL5GC+tDZKj+2Dqagtbnx4mzhdlzdlHDI23QCCIX1AW+2UJq6N4EjvP7HnY05p97r/voal+79nHhaZlo97L7omIky7dGLgYfykGje66mn9+EskD6IrOfpKQfLXqn8+U6EsbaxFl207imk+z0soKATrQpxWe0YoV0+wRjwxmgM7HR8KbnziopsuQZzs/qp4vngRqDxmSJrDEtSPGsjpVDAzaSHKZKEohEwfihu69BfUhvpx1QVri7gOvggkr/OhA5Dneegmntmtauy6WBdW1zoipSYivx5S56frh1QGUap7FPVOgCmBHVJHg8RqpSR9qtHh8PcxogixkqU5xaZApxMWcYAI1meCOJnv9V3LeGpfIafMobrUAq4V1oPYXS16Lef/FXjZlXscjVKJTYQQoNO483gs06U8Hk1ldWQkpdrpGhVyEuVXW92cqL7uWdNJRbRDR2gWDPXKB7yUYeOy4G6MVCW7t9XZEK8ov/48ibtl5SEYRx+Us60RR5iyx5R13IR92yTv8dgHiLQs1uqvo8xzKs/EJc547F2NunqC+wklrZxJolpKwq8DZJDU0yFBEenqO9QkPrA4Se3vuxE4o3Tzeshx7duS/1T0cmTlVxXWobEsl3vMdhSjicJmopYbEABvHesDn9RrNkpssuHnubyR0PF+Q4jOwtMd2dTo1f/OUD/JMvv9w4NCNQKKK6usGXuDYbna4S5l8+kJNPBHzuao9G35x779UFSvkytpHNZ6MHgswLuhz9PZUWjIleEt5GWIXeUx8wvrSxUst4wNH/XcKfDj9A9/ch6zuPk7r6BlytA01TqN1ZEvrUp8tbdHuJtC84wzESuutE5jhYRiRiEXgnsQfVQMSAZxZtXO9b6rjjwbW1dY0KeVtvso/IJe+cbj4/p//gfVWemHwKUuqNb+gQf4f0FgCCSm50hTEVXxBxwF8pviRlAey7GrbucMPTubFBm4kUVAYY8ayCLB58bR4w3p1/wVNC9gSQPxIEkeDkghgDTzfFLq/xk4wr4UILatmhYq4ScdnidEixEfU1VbY8lWu/CYvG9mNvllL8oel9mqLJEk/19nYfG/5I2XwlhC4M5KBJ+WHjax//C4RqIDDySfA7iUQ1h12GQ0qcADTRofK4NQNgtIlUdaDVTV6ufXKDl2bx0WUFQfQDYw56ktiREW2nAjmc8nztOaYQ0UzPYZ7+rnauQ7GKTsqZLJjaAO+G/tHoYdEptbu88eqle/uACl93391q2F8rkl+Oztq7IKcdxhyGJB+QB1OMDpGiuj7eVM/uX6xqYKpuW4iyZE9IxrO7UgxiEJZr1fQ2H7omvv/tY6b/1Ubv+oSs3QAOwvoH1LppEqRoN815X+ehkjX85yjtNS7PF1CI5eySU6c7Nv83p/Vi9lkKM4ZrzXKG7pAn//FCX4pllU0g7BQUt10kPFyjJBzcg7nSVBxMr4Dm1/FAXEjAni/HiAPUfAC3MhZ1UGjjltNXQzwgOHc02GbOqoetyL33DqCd7wuObYLxf0ofzvR7yFHTeoigkoH5D6Jxf5QFazgymrEyqYGgIFtwvKSIb+Gp7LA2c0MOsKy2mhPJkPeBa3osE7dugTeTHdW1eKkGz+BU8A7sr2ELI89gACOVgn1uZV0N1n4UZnmTeNMAy7qRjgn1+HClOI85wKU75A5Up/yFvCuyywIZ+j2yUsoiAzB6I312wLzetJ0yAgKKhRxzAoGbosOQ2VxUamW33RLbfs4gPWwo/WW1lPWwuu2RraWOZI4uKdc+cFIVunrmGe6xmyw9Ss53DTHUohi+qUcha+zsMKwU/knFIWyYMtSgjRY4cPrauhQZH+GYip4lD0NejPVFcFvvw8Cxo5GZQclCBgjDR9vF6SnM/mDbnTAqqE5SnqQm/1oZaHpt98fiITsJMrTOtFtLTeHYLrCZ60tVXrOJCG/4cM2iWEMwzUrBYd2dnw2FaqNSlv9zI6cYT1sWmLqKDN9VnD1yYNczsdvYzIfKMB5ofOGy7XODND9s/meBMfWi7Zpw1mG+7bVBWPo9U7HfaaloRGpWVl1P/x0nrcVcc2yE1NHYSWQRVnNzmt/h5n4YDP6t7cbEt4LwBRJC48Mx5/H66gLUqvl9N2nErTFfb16l+dR067XQYtmLQk3sXUv6U+EJZQ6ImNe4jeuqibKHyO14MgLutyQsFgsrQe4x74DlTAWPOySua3xJSsDVmk/VogqgaRSjXJtkkbFxh9tlDhUjQrZabZ0ZsHLpaN800QcAA8UWBQStkmDmijcwhP495GWiZ/u2okAAVIYd1YhvNfKmhIbUydHudeYGWQlHr/gqI3Ooi4RwF87rAa27jxFyNm8dvioUuTQOXaw8ZnSwLB//7E0BiBMS26ptUXS2jj2EZcvt9eVARfcdVpPjb1yhy10aQ6uks3/PArLHYK1VT7fj73Qg901k4FU7jOa3FMozzTMSeKLe6zSbscNFxEuX0kKtBcSZbQYWLKSHSvctlYOKJnCz1bJEt/JmnhHJa5fUyoJ1q2UwKbaghxp5MVDs5/fSDbbU99U3DUtTDzhW38T53VhqBnkjfpfDx7LlA2SpvfuXg7FxT8nZMRWcw/BTJIEgY9Y9yMUA255OGlElXE3PXiF8TYyCraj/IwD4a+ulY6ykboosNeSdTrbSp6UD8EQWcWvbyyg9vESsMf6kHbqqHVs+Xj1PTOv/Nx9+H21MSLLFpTxpdZ6ro/bnTwrb6q17qSlvbibGv/YvSj8FQs1c5e0fvu6U0kKaedV9SOA70NNs3sa8i/L/201gL5t3CuG/hg0J43e0MSSD1DZqb1GgMB+A/2n/a6ZYsJHfgn89DHjagOj7DUcmEFcTfkq46YG03LCH+9OpkmNk8x33xMwu+PGh96FqiaD9hcvc+eBYtsMaI4Z2P/gUZuevcNjuioPczyzsWWJuKQBpbydJyEc+wYqm3ZDOdoSfuwhJEeM1SeMcVq/j6jlF0X/F6bPXCrdU01qXZ0WlCYxdhX/FV6aCKTAdkh3Grdq+JXqx4oyK39+0Z4oOC/+IfpuZYAabf9cH7Fo4OqhFv+n8MjUofcxr7vE3fvSVBt5cUBtwveg2Ot05MXb9Zg9n/E77D7nD+qBPf1kjn/3bGn6obE3Z+vSV5KpTrJwNBNb7S4iO3NteJH7ZcA/+0AbZ5HthwWT3ANXEs3UFtpKV4wGtyNEhWGnhxWwZBHL1294L19OdL8m1NQQaop7nQPnd/Xuwepb34jbJV9vmGJWnFS0ck/KJrrFSdN9ASZjrItEKTuHl+e57ncxMGR3VSO7unQK9m2SM+AWsGoOlzPPjglS0VXmJ3IXenIZcsZJGPXrX1KxA5bmrac2e8ugMALSiCcE2SBamiDZnyTW0OXVo21SKSHyZKCeMmolaDuSOPZchMrYW13vpXk6izK9FYGRJ3MXLIZVEoUcYOCbQPsWYzRgGTTt46eRzLB2cy//3DE3nC98mR5t9BRlKg1thn7xjKX46V0mclyuT0DN1pHiazdzpD9vTTdNseD5rAdmZBON/6up/Eq4WuabVvIC/iWzj6jcdpAMBaMiepSbc3G+XKtNfxTHkliHzv7l27j/oL/4HceK4AgaeXJNlc50EX6uv6z80pR3l8UGATcNQdPkwx3yhOGZk+Q8iIN9IwdqIftvV8Zx7CoFmznfiaMmo4mhZ9hLU/HHvqEv/3S3WmT9Kcric1CaYV7sujl8x4usPXOkhuJjGWW4fPh5TUB7A9S51+NntbxdNVpnQZK1M/SBKbHJcb5id3qC5z3swqW3yEB21gMmBeuvgeROyla8FIECHUQkmQT3PM77w5ULvskgj0flDnjkHKGP+yq/taAEXO44HNZCoENPrJ8fpxltug5pPmWFn6mdHx+8BX9eJ1PsR67hOwwpZQ2/BH+JFp/WhlPqNdPaHVhk/UrAH2z78nyWmA0M1bWgZUIhMR36v2wVtFUwU4m27tS5mr+8Ef+O0ViGQt68q62NolXZNRJE+q0wXsc0CRpbbNzH1Nv3cgBVZWb0OrvjmwMgvnUTJbtXUrfawCdtW0/YdGh80DC3/OWC1duXi1qEoLBo6zjJ7idT/BPiNp8Oid13JFyYbav10tAzM9QjBf/lpKfP51yyWNE76fL/9imf91c6voX9XQw/njN0YUCedMFkBIFVnHcGESeVNsVAvBCkzLvwkmmrXn8tMnyBa/rXziLt+aQnhMnqxOrijzfW6+HK3KCSzeulmOFOxSWzS0q11zG3TMaLEo98/MLQH7QAkUMBsMjsdYl83wbDW+iuW4ZTnxwPKkXZfviEo37kB6Pws1sEgAo95g0OSPAm1ua1KdmfdRZ/mVZzJNm4YHc6Bcxoo/oeIO4mAKv1DaHbLwVo9nvf5BAl4MGyqXV+Q5v0IbmFWHb4lkmg+5BGL5P2PqKK/5B3hR0t3N+aTcNu4M1kcBJyonDDrMqywFTGCwvIvm0P87IDhKgyTK4zK3n5ziRB3DHIZ/X7ifuofAY3oFshzufSrCp6Xn5d2nK5TWqeG9SkDitYR9kv4QXSCULTMJVbswKG7SRg8/upamBQwt6S2yqJgeHT/e9ipIiBCZY+2jJT04AYKTcdWCm/1i5fuGtmxjuwB2Rvc/ySWTxNwkd43WHPwJ1njGkqaFkslMdub5ny9xmQf/U681lo8UvCElGfx18ZghoO/vaHbUb227GS8hkJsDZMYHgGOG56IVj1SgZ/zahxSVfwKfOjGhQ2+q4yWih2wMg5liteeeJyVpqbXlaBeh4saLK8Ze1lodiiwdGmC8rKxBXr7mG/4/YALhE16kdBVDd2M7BJHX7Gr6OS1KYtd5MIM/Q59SvWSbAZARciPowYdYSl3AN8TyhzdfL0pGB9y9nXrSj1Vfde0aPRrL0bnidlKNbJUe3ZyQZ+73wQATL+aQKeaHcmnvyo9vYUkxUe9rM1rDP8nioxsYcpHVn6qUWbrAnzYeUxUxbP47i/pxl1jljnRUvor9KYbmrofoAAAAAA=="); @@ -200,7 +196,7 @@ public class ImageControllerTest extends AbstractControllerTest { String filename = "my_png_image.png"; uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE); - ImageExportData exportData = doGet("/api/images/tenant/" + filename + "/export", ImageExportData.class); + ResourceExportData exportData = doGet("/api/images/tenant/" + filename + "/export", ResourceExportData.class); assertThat(exportData.getMediaType()).isEqualTo("image/png"); assertThat(exportData.getFileName()).isEqualTo(filename); assertThat(exportData.getTitle()).isEqualTo(filename); @@ -227,7 +223,7 @@ public class ImageControllerTest extends AbstractControllerTest { public void testExportImportLargeImage() throws Exception { String filename = "my_png_image.png"; uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE_5KB); - ImageExportData exportData = doGet("/api/images/tenant/" + filename + "/export", ImageExportData.class); + ResourceExportData exportData = doGet("/api/images/tenant/" + filename + "/export", ResourceExportData.class); doPut("/api/image/import", exportData).andExpect(status().isPayloadTooLarge()); } @@ -395,19 +391,4 @@ public class ImageControllerTest extends AbstractControllerTest { .andReturn().getResponse().getContentAsByteArray(); } - private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception { - return this.uploadImage(httpMethod, url, null, filename, mediaType, content); - } - - private TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String subType, String filename, String mediaType, byte[] content) throws Exception { - MockMultipartFile file = new MockMultipartFile("file", filename, mediaType, content); - var request = MockMvcRequestBuilders.multipart(httpMethod, url).file(file); - if (StringUtils.isNotEmpty(subType)) { - var imageSubTypePart = new MockPart("imageSubType", subType.getBytes(StandardCharsets.UTF_8)); - request.part(imageSubTypePart); - } - setJwtToken(request); - return readResponse(mockMvc.perform(request).andExpect(status().isOk()), TbResourceInfo.class); - } - } 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 96b97933aa..f27395c698 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 @@ -27,6 +27,8 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import java.util.List; + public interface ImageService { TbResourceInfo saveImage(TbResource image); @@ -59,13 +61,14 @@ public interface ImageService { void inlineImage(HasImage entity); - void inlineImages(Dashboard dashboard); + List inlineImages(Dashboard dashboard); - void inlineImages(WidgetTypeDetails widgetTypeDetails); + List inlineImages(WidgetTypeDetails widgetTypeDetails); void inlineImageForEdge(HasImage entity); void inlineImagesForEdge(Dashboard dashboard); void inlineImagesForEdge(WidgetTypeDetails widgetTypeDetails); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceExportData.java similarity index 91% rename from common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java rename to common/data/src/main/java/org/thingsboard/server/common/data/ResourceExportData.java index 3be7b6f92d..de531b55c3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceExportData.java @@ -28,15 +28,16 @@ import lombok.extern.slf4j.Slf4j; @NoArgsConstructor @AllArgsConstructor @Builder -public class ImageExportData { +public class ResourceExportData { - private String mediaType; - private String fileName; private String title; - private String subType; + private ResourceType type; + private ResourceSubType subType; private String resourceKey; - private boolean isPublic; + private String fileName; private String publicResourceKey; + private boolean isPublic; + private String mediaType; private String data; } 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 8537fe68ed..b89ee23057 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 @@ -17,11 +17,11 @@ 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 io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; @@ -134,11 +134,12 @@ public class TbResourceInfo extends BaseData implements HasName, H return title; } - public T getDescriptor(Class type) throws JsonProcessingException { + @SneakyThrows + public T getDescriptor(Class type) { return descriptor != null ? mapper.treeToValue(descriptor, type) : null; } - public void updateDescriptor(Class type, UnaryOperator updater) throws JsonProcessingException { + public void updateDescriptor(Class type, UnaryOperator updater) { T descriptor = getDescriptor(type); descriptor = updater.apply(descriptor); setDescriptorValue(descriptor); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/dashboard/DashboardExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/dashboard/DashboardExportData.java new file mode 100644 index 0000000000..91e9f59328 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/dashboard/DashboardExportData.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2024 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.dashboard; + +import lombok.Data; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.ResourceExportData; + +import java.util.List; + +@Data +public class DashboardExportData { + private Dashboard dashboard; + private List resources; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetExportData.java new file mode 100644 index 0000000000..04e710510d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetExportData.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2024 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.widget; + +import lombok.Data; +import org.thingsboard.server.common.data.ResourceExportData; + +import java.util.List; + +@Data +public class WidgetExportData { + private WidgetTypeDetails widgetTypeDetails; + private List resources; +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 02af9b53f8..77ded5d3ff 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -32,7 +32,9 @@ import com.fasterxml.jackson.databind.type.CollectionType; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.common.collect.Lists; +import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.KvEntry; @@ -46,10 +48,13 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.UUID; +import java.util.function.BiFunction; import java.util.function.UnaryOperator; import java.util.regex.Pattern; @@ -265,7 +270,7 @@ public class JacksonUtil { return value != null ? OBJECT_MAPPER.readTree(value) : null; } catch (IOException e) { throw new IllegalArgumentException("The given InputStream value: " - + value + " cannot be transformed to a JsonNode", e); + + value + " cannot be transformed to a JsonNode", e); } } @@ -427,4 +432,50 @@ public class JacksonUtil { } } + public static void replaceAll(JsonNode root, String pathPrefix, BiFunction processor) { + Queue tasks = new LinkedList<>(); + tasks.add(new JsonNodeProcessingTask(pathPrefix, root)); + while (!tasks.isEmpty()) { + JsonNodeProcessingTask task = tasks.poll(); + JsonNode node = task.getNode(); + if (node == null) { + continue; + } + String currentPath = StringUtils.isBlank(task.getPath()) ? "" : (task.getPath() + "."); + if (node.isObject()) { + ObjectNode on = (ObjectNode) node; + for (Iterator it = on.fieldNames(); it.hasNext(); ) { + String childName = it.next(); + JsonNode childValue = on.get(childName); + if (childValue.isTextual()) { + on.put(childName, processor.apply(currentPath + childName, childValue.asText())); + } else if (childValue.isObject() || childValue.isArray()) { + tasks.add(new JsonNodeProcessingTask(currentPath + childName, childValue)); + } + } + } else if (node.isArray()) { + ArrayNode childArray = (ArrayNode) node; + for (int i = 0; i < childArray.size(); i++) { + JsonNode element = childArray.get(i); + if (element.isObject()) { + tasks.add(new JsonNodeProcessingTask(currentPath + "." + i, element)); + } else if (element.isTextual()) { + childArray.set(i, processor.apply(currentPath + "." + i, element.asText())); + } + } + } + } + } + + @Data + public static class JsonNodeProcessingTask { + private final String path; + private final JsonNode node; + + public JsonNodeProcessingTask(String path, JsonNode node) { + this.path = path; + this.node = node; + } + } + } 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 dc4b6c28fc..6aa685c40b 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,6 @@ */ package org.thingsboard.server.dao.resource; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -56,12 +55,12 @@ import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; import org.thingsboard.server.dao.util.ImageUtils; import org.thingsboard.server.dao.util.ImageUtils.ProcessedImage; -import org.thingsboard.server.dao.util.JsonNodeProcessingTask; import org.thingsboard.server.dao.util.JsonPathProcessingTask; import org.thingsboard.server.dao.widget.WidgetTypeDao; import org.thingsboard.server.dao.widget.WidgetsBundleDao; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.HashMap; @@ -72,8 +71,12 @@ import java.util.Map; import java.util.Optional; import java.util.Queue; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; import java.util.regex.Pattern; +import static org.apache.commons.lang3.ArrayUtils.get; + @Service @Slf4j public class BaseImageService extends BaseResourceService implements ImageService { @@ -293,6 +296,9 @@ public class BaseImageService extends BaseResourceService implements ImageServic @Override public TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, String etag) { + if (StringUtils.isEmpty(etag)) { + return null; + } log.trace("Executing findSystemOrTenantImageByEtag [{}] [{}]", tenantId, etag); return resourceInfoDao.findSystemOrTenantImageByEtag(tenantId, ResourceType.IMAGE, etag); } @@ -430,60 +436,67 @@ public class BaseImageService extends BaseResourceService implements ImageServic return base64ToImageUrl(tenantId, name, data, false); } - private static final Pattern TB_IMAGE_METADATA_PATTERN = Pattern.compile("^tb-image:([^:]*):([^:]*):?([^:]*)?;data:(.*);.*"); + private static final Pattern TB_IMAGE_METADATA_PATTERN = Pattern.compile("^tb-image:([^;]+);data:(.*);.*"); private UpdateResult base64ToImageUrl(TenantId tenantId, String name, String data, boolean strict) { if (StringUtils.isBlank(data)) { return UpdateResult.of(false, data); } + + String resourceKey = null; + String resourceName = null; + String resourceSubType = null; + String etag = null; + String mediaType; var matcher = TB_IMAGE_METADATA_PATTERN.matcher(data); - boolean matches = matcher.matches(); - String mdResourceKey = null; - String mdResourceName = null; - String mdResourceSubType = null; - String mdMediaType; - if (matches) { - mdResourceKey = new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_8); - mdResourceName = new String(Base64.getDecoder().decode(matcher.group(2)), StandardCharsets.UTF_8); - if (StringUtils.isNotBlank(matcher.group(3))) { - mdResourceSubType = new String(Base64.getDecoder().decode(matcher.group(3)), StandardCharsets.UTF_8); - } - mdMediaType = matcher.group(4); + if (matcher.matches()) { + String[] metadata = matcher.group(1).split(":"); + resourceKey = decode(get(metadata, 0)); + resourceName = decode(get(metadata, 1)); + resourceSubType = decode(get(metadata, 2)); + etag = get(metadata, 3); + mediaType = matcher.group(2); } else if (data.startsWith(DataConstants.TB_IMAGE_PREFIX + "data:image/") || (!strict && data.startsWith("data:image/"))) { - mdMediaType = StringUtils.substringBetween(data, "data:", ";base64"); + mediaType = StringUtils.substringBetween(data, "data:", ";base64"); } else { return UpdateResult.of(false, data); } + String base64Data = StringUtils.substringAfter(data, "base64,"); - String extension = ImageUtils.mediaTypeToFileExtension(mdMediaType); - byte[] imageData = Base64.getDecoder().decode(base64Data); - String etag = calculateEtag(imageData); + byte[] imageData = StringUtils.isNotEmpty(base64Data) ? Base64.getDecoder().decode(base64Data) : null; + if (StringUtils.isBlank(etag)) { + etag = calculateEtag(imageData); + } var imageInfo = findSystemOrTenantImageByEtag(tenantId, etag); if (imageInfo == null) { + if (imageData == null) { + return UpdateResult.of(false, data); + } TbResource image = new TbResource(); image.setTenantId(tenantId); image.setResourceType(ResourceType.IMAGE); - if (StringUtils.isBlank(mdResourceName)) { - mdResourceName = name; + if (StringUtils.isBlank(resourceName)) { + resourceName = name; } - image.setTitle(mdResourceName); + image.setTitle(resourceName); String fileName; - if (StringUtils.isBlank(mdResourceKey)) { - fileName = StringUtils.strip(mdResourceName.toLowerCase() + if (StringUtils.isBlank(resourceKey)) { + String extension = ImageUtils.mediaTypeToFileExtension(mediaType); + fileName = StringUtils.strip(resourceName.toLowerCase() .replaceAll("['\"]", "") .replaceAll("[^\\pL\\d]+", "_"), "_") // leaving only letters and numbers + "." + extension; } else { - fileName = mdResourceKey; + fileName = resourceKey; } - if (StringUtils.isBlank(mdResourceSubType)) { + if (StringUtils.isBlank(resourceSubType)) { image.setResourceSubType(ResourceSubType.IMAGE); } else { - image.setResourceSubType(ResourceSubType.valueOf(mdResourceSubType)); + image.setResourceSubType(ResourceSubType.valueOf(resourceSubType)); } image.setFileName(fileName); - image.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mdMediaType)); + image.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mediaType)); image.setData(imageData); image.setPublic(true); try { @@ -503,159 +516,127 @@ public class BaseImageService extends BaseResourceService implements ImageServic } private boolean base64ToImageUrlRecursively(TenantId tenantId, String title, JsonNode root) { - boolean updated = false; - Queue tasks = new LinkedList<>(); - tasks.add(new JsonNodeProcessingTask(title, root)); - while (!tasks.isEmpty()) { - JsonNodeProcessingTask task = tasks.poll(); - JsonNode node = task.getNode(); - if (node == null) { - continue; + AtomicBoolean updated = new AtomicBoolean(false); + JacksonUtil.replaceAll(root, title, (path, value) -> { + UpdateResult result = base64ToImageUrl(tenantId, path, value, true); + if (result.isUpdated()) { + updated.set(true); } - String currentPath = StringUtils.isBlank(task.getPath()) ? "" : (task.getPath() + " "); - if (node.isObject()) { - ObjectNode on = (ObjectNode) node; - for (Iterator it = on.fieldNames(); it.hasNext(); ) { - String childName = it.next(); - JsonNode childValue = on.get(childName); - if (childValue.isTextual()) { - UpdateResult result = base64ToImageUrl(tenantId, currentPath + childName, childValue.asText(), true); - on.put(childName, result.getValue()); - updated |= result.isUpdated(); - } else if (childValue.isObject() || childValue.isArray()) { - tasks.add(new JsonNodeProcessingTask(currentPath + childName, childValue)); - } - } - } else if (node.isArray()) { - ArrayNode childArray = (ArrayNode) node; - for (int i = 0; i < childArray.size(); i++) { - JsonNode element = childArray.get(i); - if (element.isObject()) { - tasks.add(new JsonNodeProcessingTask(currentPath + " " + i, element)); - } else if (element.isTextual()) { - UpdateResult result = base64ToImageUrl(tenantId, currentPath + "." + i, element.asText(), true); - childArray.set(i, result.getValue()); - updated |= result.isUpdated(); - } - } - } - } - return updated; + return result.getValue(); + }); + return updated.get(); } @Override public void inlineImage(HasImage entity) { log.trace("Executing inlineImage [{}] [{}] [{}]", entity.getTenantId(), entity.getClass().getSimpleName(), entity.getName()); - entity.setImage(inlineImage(entity.getTenantId(), "image", entity.getImage(), true)); + inlineImage(entity, null); } @Override - public void inlineImages(Dashboard dashboard) { + public List inlineImages(Dashboard dashboard) { log.trace("Executing inlineImage [{}] [Dashboard] [{}]", dashboard.getTenantId(), dashboard.getId()); - inlineImage(dashboard); - inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration()); + List images = new ArrayList<>(); + inlineImage(dashboard, images); + inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration(), images); + return images; } @Override - public void inlineImages(WidgetTypeDetails widgetTypeDetails) { + public List inlineImages(WidgetTypeDetails widgetTypeDetails) { log.trace("Executing inlineImage [{}] [WidgetTypeDetails] [{}]", widgetTypeDetails.getTenantId(), widgetTypeDetails.getId()); - inlineImage(widgetTypeDetails); + List images = new ArrayList<>(); + inlineImage(widgetTypeDetails, images); ObjectNode descriptor = (ObjectNode) widgetTypeDetails.getDescriptor(); - inlineIntoJson(widgetTypeDetails.getTenantId(), descriptor); + inlineIntoJson(widgetTypeDetails.getTenantId(), descriptor, images); if (descriptor.has(DEFAULT_CONFIG_TAG) && descriptor.get(DEFAULT_CONFIG_TAG).isTextual()) { try { var defaultConfig = JacksonUtil.toJsonNode(descriptor.get(DEFAULT_CONFIG_TAG).asText()); - inlineIntoJson(widgetTypeDetails.getTenantId(), defaultConfig); + inlineIntoJson(widgetTypeDetails.getTenantId(), defaultConfig, images); descriptor.put(DEFAULT_CONFIG_TAG, JacksonUtil.toString(defaultConfig)); } catch (Exception e) { log.debug("[{}][{}] Failed to process default config: ", widgetTypeDetails.getTenantId(), widgetTypeDetails.getId(), e); } } + return images; } @Override public void inlineImageForEdge(HasImage entity) { log.trace("Executing inlineImageForEdge [{}] [{}] [{}]", entity.getTenantId(), entity.getClass().getSimpleName(), entity.getName()); - entity.setImage(inlineImage(entity.getTenantId(), "image", entity.getImage(), false)); + entity.setImage(inlineImage(entity.getTenantId(), "image", entity.getImage(), false, null)); } @Override public void inlineImagesForEdge(Dashboard dashboard) { log.trace("Executing inlineImagesForEdge [{}] [Dashboard] [{}]", dashboard.getTenantId(), dashboard.getId()); inlineImageForEdge(dashboard); - inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration(), false); + inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration(), false, null); } @Override public void inlineImagesForEdge(WidgetTypeDetails widgetTypeDetails) { log.trace("Executing inlineImage [{}] [WidgetTypeDetails] [{}]", widgetTypeDetails.getTenantId(), widgetTypeDetails.getId()); inlineImageForEdge(widgetTypeDetails); - inlineIntoJson(widgetTypeDetails.getTenantId(), widgetTypeDetails.getDescriptor(), false); + inlineIntoJson(widgetTypeDetails.getTenantId(), widgetTypeDetails.getDescriptor(), false, null); } - private void inlineIntoJson(TenantId tenantId, JsonNode root) { - inlineIntoJson(tenantId, root, true); + private void inlineImage(HasImage entity, List processedImages) { + log.trace("Executing inlineImage [{}] [{}] [{}]", entity.getTenantId(), entity.getClass().getSimpleName(), entity.getName()); + entity.setImage(inlineImage(entity.getTenantId(), "image", entity.getImage(), true, processedImages)); } - private void inlineIntoJson(TenantId tenantId, JsonNode root, boolean addTbImagePrefix) { - Queue tasks = new LinkedList<>(); - tasks.add(new JsonNodeProcessingTask("", root)); - while (!tasks.isEmpty()) { - JsonNodeProcessingTask task = tasks.poll(); - JsonNode node = task.getNode(); - if (node == null) { - continue; - } - String currentPath = StringUtils.isBlank(task.getPath()) ? "" : (task.getPath() + "."); - if (node.isObject()) { - ObjectNode on = (ObjectNode) node; - for (Iterator it = on.fieldNames(); it.hasNext(); ) { - String childName = it.next(); - JsonNode childValue = on.get(childName); - if (childValue.isTextual()) { - on.put(childName, inlineImage(tenantId, currentPath + childName, childValue.asText(), addTbImagePrefix)); - } else if (childValue.isObject() || childValue.isArray()) { - tasks.add(new JsonNodeProcessingTask(currentPath + childName, childValue)); - } - } - } else if (node.isArray()) { - ArrayNode childArray = (ArrayNode) node; - for (int i = 0; i < childArray.size(); i++) { - JsonNode element = childArray.get(i); - if (element.isObject()) { - tasks.add(new JsonNodeProcessingTask(currentPath + "." + i, element)); - } else if (element.isTextual()) { - childArray.set(i, inlineImage(tenantId, currentPath + "." + i, element.asText(), addTbImagePrefix)); - } + private void inlineIntoJson(TenantId tenantId, JsonNode root, List processedImages) { + inlineIntoJson(tenantId, root, true, processedImages); + } + + private void inlineIntoJson(TenantId tenantId, JsonNode root, boolean addTbImagePrefix, List processedImages) { + JacksonUtil.replaceAll(root, "", (path, value) -> inlineImage(tenantId, path, value, addTbImagePrefix, processedImages)); + } + + private String inlineImage(TenantId tenantId, String path, String url, boolean addTbImagePrefix, List processedImages) { + return inlineImage(tenantId, path, url, (key, imageInfo) -> { + String tbImagePrefix = ""; + boolean addData = true; + if (addTbImagePrefix) { + tbImagePrefix = "tb-image:" + encode(imageInfo.getResourceKey()) + ":" + + encode(imageInfo.getName()) + ":" + + encode(imageInfo.getResourceSubType().name()) + ":" + + imageInfo.getEtag() + ";"; + + if (processedImages != null && !key.isPreview()) { + addData = false; + processedImages.add(imageInfo); } } - } + + byte[] data; + if (addData) { + data = key.isPreview() ? getImagePreview(tenantId, imageInfo.getId()) : getImageData(tenantId, imageInfo.getId()); + } else { + data = null; + } + ImageDescriptor descriptor = getImageDescriptor(imageInfo, key.isPreview()); + return tbImagePrefix + "data:" + descriptor.getMediaType() + ";base64," + encode(data); + }); } - private String inlineImage(TenantId tenantId, String path, String url, boolean addTbImagePrefix) { + private String inlineImage(TenantId tenantId, String path, String imageUrl, BiFunction inliner) { try { - ImageCacheKey key = getKeyFromUrl(tenantId, url); + ImageCacheKey key = getKeyFromUrl(tenantId, imageUrl); if (key != null) { var imageInfo = getImageInfoByTenantIdAndKey(key.getTenantId(), key.getResourceKey()); if (imageInfo != null && !(TenantId.SYS_TENANT_ID.equals(imageInfo.getTenantId()) && ResourceSubType.SCADA_SYMBOL.equals(imageInfo.getResourceSubType()))) { - byte[] data = key.isPreview() ? getImagePreview(tenantId, imageInfo.getId()) : getImageData(tenantId, imageInfo.getId()); - ImageDescriptor descriptor = getImageDescriptor(imageInfo, key.isPreview()); - String tbImagePrefix = ""; - if (addTbImagePrefix) { - tbImagePrefix = "tb-image:" + Base64.getEncoder().encodeToString(imageInfo.getResourceKey().getBytes(StandardCharsets.UTF_8)) + ":" - + Base64.getEncoder().encodeToString(imageInfo.getName().getBytes(StandardCharsets.UTF_8)) + ":" - + Base64.getEncoder().encodeToString(imageInfo.getResourceSubType().name().getBytes(StandardCharsets.UTF_8)) + ";"; - } - return tbImagePrefix + "data:" + descriptor.getMediaType() + ";base64," + Base64.getEncoder().encodeToString(data); + return inliner.apply(key, imageInfo); } } } catch (Exception e) { - log.warn("[{}][{}][{}] Failed to inline image.", tenantId, path, url, e); + log.warn("[{}][{}][{}] Failed to inline image.", tenantId, path, imageUrl, e); } - return url; + return imageUrl; } - private ImageDescriptor getImageDescriptor(TbResourceInfo imageInfo, boolean preview) throws JsonProcessingException { + private ImageDescriptor getImageDescriptor(TbResourceInfo imageInfo, boolean preview) { ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); return preview ? descriptor.getPreviewDescriptor() : descriptor; } @@ -681,6 +662,24 @@ public class BaseImageService extends BaseResourceService implements ImageServic return null; } + private String encode(String data) { + return encode(data.getBytes(StandardCharsets.UTF_8)); + } + + private String encode(byte[] data) { + if (data == null || data.length == 0) { + return ""; + } + return Base64.getEncoder().encodeToString(data); + } + + private String decode(String value) { + if (value == null) { + return null; + } + return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + } + @Data(staticConstructor = "of") private static class UpdateResult { private final boolean updated; diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 818d714a3b..85933aca43 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -55,7 +55,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; import org.thingsboard.server.common.data.EventInfo; -import org.thingsboard.server.common.data.ImageExportData; +import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.ResourceSubType; @@ -3802,14 +3802,14 @@ public class RestClient implements Closeable { return IOUtils.toByteArray(image.getInputStream()); } - public ImageExportData exportImage(String type, String key) { - return restTemplate.getForObject(baseURL + "/api/images/{type}/{key}/export", ImageExportData.class, Map.of( + public ResourceExportData exportImage(String type, String key) { + return restTemplate.getForObject(baseURL + "/api/images/{type}/{key}/export", ResourceExportData.class, Map.of( "type", type, "key", key )); } - public TbResourceInfo importImage(ImageExportData exportData) { + public TbResourceInfo importImage(ResourceExportData exportData) { return restTemplate.exchange(baseURL + "/api/image/import", HttpMethod.PUT, new HttpEntity<>(exportData), TbResourceInfo.class).getBody(); } diff --git a/ui-ngx/src/app/core/http/dashboard.service.ts b/ui-ngx/src/app/core/http/dashboard.service.ts index 3a5025a9ba..0ee4386f7e 100644 --- a/ui-ngx/src/app/core/http/dashboard.service.ts +++ b/ui-ngx/src/app/core/http/dashboard.service.ts @@ -20,7 +20,7 @@ import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; -import { Dashboard, DashboardInfo, HomeDashboard, HomeDashboardInfo } from '@shared/models/dashboard.models'; +import { Dashboard, DashboardExportData, DashboardInfo, HomeDashboard, HomeDashboardInfo } from '@shared/models/dashboard.models'; import { WINDOW } from '@core/services/window.service'; import { NavigationEnd, Router } from '@angular/router'; import { filter, map, publishReplay, refCount } from 'rxjs/operators'; @@ -71,8 +71,8 @@ export class DashboardService { return this.http.get(`/api/dashboard/${dashboardId}`, defaultHttpOptionsFromConfig(config)); } - public exportDashboard(dashboardId: string, config?: RequestConfig): Observable { - return this.http.get(`/api/dashboard/${dashboardId}?inlineImages=true`, defaultHttpOptionsFromConfig(config)); + public exportDashboard(dashboardId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/dashboard/${dashboardId}/export`, defaultHttpOptionsFromConfig(config)); } public getDashboardInfo(dashboardId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 3963a81c1e..3cc3debe93 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -144,10 +144,12 @@ export class ImportExportService { public exportDashboard(dashboardId: string) { this.dashboardService.exportDashboard(dashboardId).subscribe({ - next: (dashboard) => { + next: (exportData) => { + let dashboard = exportData.dashboard; let name = dashboard.title; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareDashboardExport(dashboard), name); + exportData.dashboard = this.prepareDashboardExport(dashboard) + this.exportToPc(exportData, name); }, error: (e) => { this.handleExportError(e, 'dashboard.export-failed-error'); @@ -155,6 +157,7 @@ export class ImportExportService { }); } + // FIXME: backward compatibility - support old export structure public importDashboard(onEditMissingAliases: editMissingAliasesFunction): Observable { return this.openImportDialog('dashboard.import', 'dashboard.dashboard-file').pipe( mergeMap((dashboard: Dashboard) => { diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index 45f4eb8cf2..ad8f56bca6 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -209,6 +209,11 @@ export interface DashboardSetup extends Dashboard { assignedCustomerIds?: Array; } +export interface DashboardExportData { + dashboard?: Dashboard; + resources: any; +} + export const isPublicDashboard = (dashboard: DashboardInfo): boolean => { if (dashboard && dashboard.assignedCustomers) { return dashboard.assignedCustomers