Change dashboard export structure; images export-import improvements

This commit is contained in:
ViacheslavKlimov 2024-10-15 17:08:15 +03:00
parent 2a71abbd52
commit cb097bb637
24 changed files with 554 additions and 242 deletions

View File

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

View File

@ -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')")

View File

@ -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')")

View File

@ -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");

View File

@ -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<TbResourceInfo> 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);
}
}

View File

@ -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> {
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;
}

View File

@ -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<TbResourceInfo> 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);
}
}

View File

@ -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> {
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;
}

View File

@ -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<ImageCacheKey> toEvict) {
toEvict.forEach(this::evictETags);
clusterService.broadcastToCore(TransportProtos.ToCoreNotificationMsg.newBuilder()

View File

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

View File

@ -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 <R> TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception {
return this.uploadImage(httpMethod, url, null, filename, mediaType, content);
}
protected <R> 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);
}
}

View File

@ -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<ResourceExportData> 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) {

File diff suppressed because one or more lines are too long

View File

@ -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<TbResourceInfo> inlineImages(Dashboard dashboard);
void inlineImages(WidgetTypeDetails widgetTypeDetails);
List<TbResourceInfo> inlineImages(WidgetTypeDetails widgetTypeDetails);
void inlineImageForEdge(HasImage entity);
void inlineImagesForEdge(Dashboard dashboard);
void inlineImagesForEdge(WidgetTypeDetails widgetTypeDetails);
}

View File

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

View File

@ -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<TbResourceId> implements HasName, H
return title;
}
public <T> T getDescriptor(Class<T> type) throws JsonProcessingException {
@SneakyThrows
public <T> T getDescriptor(Class<T> type) {
return descriptor != null ? mapper.treeToValue(descriptor, type) : null;
}
public <T> void updateDescriptor(Class<T> type, UnaryOperator<T> updater) throws JsonProcessingException {
public <T> void updateDescriptor(Class<T> type, UnaryOperator<T> updater) {
T descriptor = getDescriptor(type);
descriptor = updater.apply(descriptor);
setDescriptorValue(descriptor);

View File

@ -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<ResourceExportData> resources;
}

View File

@ -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<ResourceExportData> resources;
}

View File

@ -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<String, String, String> processor) {
Queue<JsonNodeProcessingTask> 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<String> 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;
}
}
}

View File

@ -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<JsonNodeProcessingTask> 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<String> 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<TbResourceInfo> inlineImages(Dashboard dashboard) {
log.trace("Executing inlineImage [{}] [Dashboard] [{}]", dashboard.getTenantId(), dashboard.getId());
inlineImage(dashboard);
inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration());
List<TbResourceInfo> images = new ArrayList<>();
inlineImage(dashboard, images);
inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration(), images);
return images;
}
@Override
public void inlineImages(WidgetTypeDetails widgetTypeDetails) {
public List<TbResourceInfo> inlineImages(WidgetTypeDetails widgetTypeDetails) {
log.trace("Executing inlineImage [{}] [WidgetTypeDetails] [{}]", widgetTypeDetails.getTenantId(), widgetTypeDetails.getId());
inlineImage(widgetTypeDetails);
List<TbResourceInfo> 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<TbResourceInfo> 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<JsonNodeProcessingTask> 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<String> 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<TbResourceInfo> processedImages) {
inlineIntoJson(tenantId, root, true, processedImages);
}
private void inlineIntoJson(TenantId tenantId, JsonNode root, boolean addTbImagePrefix, List<TbResourceInfo> processedImages) {
JacksonUtil.replaceAll(root, "", (path, value) -> inlineImage(tenantId, path, value, addTbImagePrefix, processedImages));
}
private String inlineImage(TenantId tenantId, String path, String url, boolean addTbImagePrefix, List<TbResourceInfo> 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<ImageCacheKey, TbResourceInfo, String> 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;

View File

@ -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();
}

View File

@ -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<Dashboard>(`/api/dashboard/${dashboardId}`, defaultHttpOptionsFromConfig(config));
}
public exportDashboard(dashboardId: string, config?: RequestConfig): Observable<Dashboard> {
return this.http.get<Dashboard>(`/api/dashboard/${dashboardId}?inlineImages=true`, defaultHttpOptionsFromConfig(config));
public exportDashboard(dashboardId: string, config?: RequestConfig): Observable<DashboardExportData> {
return this.http.get<DashboardExportData>(`/api/dashboard/${dashboardId}/export`, defaultHttpOptionsFromConfig(config));
}
public getDashboardInfo(dashboardId: string, config?: RequestConfig): Observable<DashboardInfo> {

View File

@ -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<Dashboard> {
return this.openImportDialog('dashboard.import', 'dashboard.dashboard-file').pipe(
mergeMap((dashboard: Dashboard) => {

View File

@ -209,6 +209,11 @@ export interface DashboardSetup extends Dashboard {
assignedCustomerIds?: Array<string>;
}
export interface DashboardExportData {
dashboard?: Dashboard;
resources: any;
}
export const isPublicDashboard = (dashboard: DashboardInfo): boolean => {
if (dashboard && dashboard.assignedCustomers) {
return dashboard.assignedCustomers