committed by
GitHub
279 changed files with 8434 additions and 953 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,37 @@ |
|||
-- |
|||
-- Copyright © 2016-2023 The Thingsboard Authors |
|||
-- |
|||
-- Licensed under the Apache License, Version 2.0 (the "License"); |
|||
-- you may not use this file except in compliance with the License. |
|||
-- You may obtain a copy of the License at |
|||
-- |
|||
-- http://www.apache.org/licenses/LICENSE-2.0 |
|||
-- |
|||
-- Unless required by applicable law or agreed to in writing, software |
|||
-- distributed under the License is distributed on an "AS IS" BASIS, |
|||
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
-- See the License for the specific language governing permissions and |
|||
-- limitations under the License. |
|||
-- |
|||
|
|||
-- RESOURCES UPDATE START |
|||
|
|||
DO |
|||
$$ |
|||
BEGIN |
|||
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = 'resource' AND column_name = 'data' AND data_type = 'bytea') THEN |
|||
ALTER TABLE resource RENAME COLUMN data TO base64_data; |
|||
ALTER TABLE resource ADD COLUMN data bytea; |
|||
UPDATE resource SET data = decode(base64_data, 'base64') WHERE base64_data IS NOT NULL; |
|||
ALTER TABLE resource DROP COLUMN base64_data; |
|||
END IF; |
|||
END; |
|||
$$; |
|||
|
|||
ALTER TABLE resource ADD COLUMN IF NOT EXISTS descriptor varchar; |
|||
ALTER TABLE resource ADD COLUMN IF NOT EXISTS preview bytea; |
|||
ALTER TABLE resource ADD COLUMN IF NOT EXISTS external_id uuid; |
|||
|
|||
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag); |
|||
|
|||
-- RESOURCES UPDATE END |
|||
@ -0,0 +1,283 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.controller; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import io.swagger.annotations.ApiParam; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.apache.commons.lang3.StringUtils; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.core.io.ByteArrayResource; |
|||
import org.springframework.http.CacheControl; |
|||
import org.springframework.http.HttpHeaders; |
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.util.Base64Utils; |
|||
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.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestHeader; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RequestPart; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import org.thingsboard.server.common.data.ImageDescriptor; |
|||
import org.thingsboard.server.common.data.ImageExportData; |
|||
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; |
|||
import org.thingsboard.server.common.data.exception.ThingsboardException; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.page.PageLink; |
|||
import org.thingsboard.server.common.data.security.Authority; |
|||
import org.thingsboard.server.dao.resource.ImageService; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
import org.thingsboard.server.dao.resource.ImageCacheKey; |
|||
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.concurrent.TimeUnit; |
|||
|
|||
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES; |
|||
import static org.thingsboard.server.controller.ControllerConstants.RESOURCE_TEXT_SEARCH_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_ALLOWABLE_VALUES; |
|||
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; |
|||
|
|||
@Slf4j |
|||
@RestController |
|||
@TbCoreComponent |
|||
@RequiredArgsConstructor |
|||
public class ImageController extends BaseController { |
|||
|
|||
private final ImageService imageService; |
|||
private final TbImageService tbImageService; |
|||
@Value("${cache.image.systemImagesBrowserTtlInMinutes:0}") |
|||
private int systemImagesBrowserTtlInMinutes; |
|||
@Value("${cache.image.tenantImagesBrowserTtlInMinutes:0}") |
|||
private int tenantImagesBrowserTtlInMinutes; |
|||
|
|||
private static final String IMAGE_URL = "/api/images/{type}/{key}"; |
|||
private static final String SYSTEM_IMAGE = "system"; |
|||
private static final String TENANT_IMAGE = "tenant"; |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@PostMapping("/api/image") |
|||
public TbResourceInfo uploadImage(@RequestPart MultipartFile file, |
|||
@RequestPart(required = false) String title) throws Exception { |
|||
SecurityUser user = getCurrentUser(); |
|||
TbResource image = new TbResource(); |
|||
image.setTenantId(user.getTenantId()); |
|||
accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.CREATE, null, image); |
|||
|
|||
image.setFileName(file.getOriginalFilename()); |
|||
if (StringUtils.isNotEmpty(title)) { |
|||
image.setTitle(title); |
|||
} else { |
|||
image.setTitle(file.getOriginalFilename()); |
|||
} |
|||
image.setResourceType(ResourceType.IMAGE); |
|||
ImageDescriptor descriptor = new ImageDescriptor(); |
|||
descriptor.setMediaType(file.getContentType()); |
|||
image.setDescriptorValue(descriptor); |
|||
image.setData(file.getBytes()); |
|||
return tbImageService.save(image, user); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@PutMapping(IMAGE_URL) |
|||
public TbResourceInfo updateImage(@PathVariable String type, |
|||
@PathVariable String key, |
|||
@RequestPart MultipartFile file) throws Exception { |
|||
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE); |
|||
TbResource image = new TbResource(imageInfo); |
|||
image.setData(file.getBytes()); |
|||
image.setFileName(file.getOriginalFilename()); |
|||
image.updateDescriptor(ImageDescriptor.class, descriptor -> { |
|||
descriptor.setMediaType(file.getContentType()); |
|||
return descriptor; |
|||
}); |
|||
return tbImageService.save(image, getCurrentUser()); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@PutMapping(IMAGE_URL + "/info") |
|||
public TbResourceInfo updateImageInfo(@PathVariable String type, |
|||
@PathVariable String key, |
|||
@RequestBody TbResourceInfo newImageInfo) throws ThingsboardException { |
|||
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE); |
|||
imageInfo.setTitle(newImageInfo.getTitle()); |
|||
return tbImageService.save(imageInfo, getCurrentUser()); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
|||
@GetMapping(value = IMAGE_URL, produces = "image/*") |
|||
public ResponseEntity<ByteArrayResource> downloadImage(@PathVariable String type, |
|||
@PathVariable String key, |
|||
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception { |
|||
return downloadIfChanged(type, key, etag, false); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@GetMapping(value = IMAGE_URL + "/export") |
|||
public ImageExportData exportImage(@PathVariable String type, @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 new ImageExportData(descriptor.getMediaType(), imageInfo.getFileName(), imageInfo.getTitle(), imageInfo.getResourceKey(), Base64Utils.encodeToString(data)); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@PutMapping("/api/image/import") |
|||
public TbResourceInfo importImage(@RequestBody ImageExportData 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()); |
|||
} |
|||
image.setResourceKey(imageData.getResourceKey()); |
|||
image.setResourceType(ResourceType.IMAGE); |
|||
ImageDescriptor descriptor = new ImageDescriptor(); |
|||
descriptor.setMediaType(imageData.getMediaType()); |
|||
image.setDescriptorValue(descriptor); |
|||
image.setData(Base64Utils.decodeFromString(imageData.getData())); |
|||
return tbImageService.save(image, user); |
|||
|
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") |
|||
@GetMapping(value = IMAGE_URL + "/preview", produces = "image/png") |
|||
public ResponseEntity<ByteArrayResource> downloadImagePreview(@PathVariable String type, |
|||
@PathVariable String key, |
|||
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception { |
|||
return downloadIfChanged(type, key, etag, true); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@GetMapping(IMAGE_URL + "/info") |
|||
public TbResourceInfo getImageInfo(@PathVariable String type, |
|||
@PathVariable String key) throws ThingsboardException { |
|||
return checkImageInfo(type, key, Operation.READ); |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@GetMapping("/api/images") |
|||
public PageData<TbResourceInfo> getImages(@ApiParam(value = PAGE_SIZE_DESCRIPTION, required = true) |
|||
@RequestParam int pageSize, |
|||
@ApiParam(value = PAGE_NUMBER_DESCRIPTION, required = true) |
|||
@RequestParam int page, |
|||
@ApiParam(value = RESOURCE_INCLUDE_SYSTEM_IMAGES_DESCRIPTION) |
|||
@RequestParam(required = false) boolean includeSystemImages, |
|||
@ApiParam(value = RESOURCE_TEXT_SEARCH_DESCRIPTION) |
|||
@RequestParam(required = false) String textSearch, |
|||
@ApiParam(value = SORT_PROPERTY_DESCRIPTION, allowableValues = RESOURCE_SORT_PROPERTY_ALLOWABLE_VALUES) |
|||
@RequestParam(required = false) String sortProperty, |
|||
@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) |
|||
@RequestParam(required = false) String sortOrder) throws ThingsboardException { |
|||
// PE: generic permission
|
|||
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); |
|||
TenantId tenantId = getTenantId(); |
|||
if (getCurrentUser().getAuthority() == Authority.SYS_ADMIN || !includeSystemImages) { |
|||
return checkNotNull(imageService.getImagesByTenantId(tenantId, pageLink)); |
|||
} else { |
|||
return checkNotNull(imageService.getAllImagesByTenantId(tenantId, pageLink)); |
|||
} |
|||
} |
|||
|
|||
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") |
|||
@DeleteMapping(IMAGE_URL) |
|||
public ResponseEntity<TbImageDeleteResult> deleteImage(@PathVariable String type, |
|||
@PathVariable String key, |
|||
@RequestParam(name = "force", required = false) boolean force) throws ThingsboardException { |
|||
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.DELETE); |
|||
TbImageDeleteResult result = tbImageService.delete(imageInfo, getCurrentUser(), force); |
|||
return (result.isSuccess() ? ResponseEntity.ok() : ResponseEntity.badRequest()).body(result); |
|||
} |
|||
|
|||
private ResponseEntity<ByteArrayResource> downloadIfChanged(String type, String key, String etag, boolean preview) throws ThingsboardException, JsonProcessingException { |
|||
ImageCacheKey cacheKey = new ImageCacheKey(getTenantId(type), key, preview); |
|||
if (StringUtils.isNotEmpty(etag)) { |
|||
etag = StringUtils.remove(etag, '\"'); // etag is wrapped in double quotes due to HTTP specification
|
|||
if (etag.equals(tbImageService.getETag(cacheKey))) { |
|||
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); |
|||
} |
|||
} |
|||
TenantId tenantId = getTenantId(); |
|||
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ); |
|||
String fileName = imageInfo.getFileName(); |
|||
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); |
|||
byte[] data; |
|||
if (preview) { |
|||
descriptor = descriptor.getPreviewDescriptor(); |
|||
data = imageService.getImagePreview(tenantId, imageInfo.getId()); |
|||
} else { |
|||
data = imageService.getImageData(tenantId, imageInfo.getId()); |
|||
} |
|||
tbImageService.putETag(cacheKey, descriptor.getEtag()); |
|||
var result = ResponseEntity.ok() |
|||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName) |
|||
.header("x-filename", fileName) |
|||
.header("Content-Type", descriptor.getMediaType()) |
|||
.contentLength(data.length) |
|||
.eTag(descriptor.getEtag()); |
|||
if (systemImagesBrowserTtlInMinutes > 0 && imageInfo.getTenantId().isSysTenantId()) { |
|||
result.cacheControl(CacheControl.maxAge(systemImagesBrowserTtlInMinutes, TimeUnit.MINUTES)); |
|||
} else if (tenantImagesBrowserTtlInMinutes > 0 && !imageInfo.getTenantId().isSysTenantId()) { |
|||
result.cacheControl(CacheControl.maxAge(tenantImagesBrowserTtlInMinutes, TimeUnit.MINUTES)); |
|||
} else { |
|||
result.cacheControl(CacheControl.noCache()); |
|||
} |
|||
return result.body(new ByteArrayResource(data)); |
|||
} |
|||
|
|||
private TbResourceInfo checkImageInfo(String imageType, String key, Operation operation) throws ThingsboardException { |
|||
TenantId tenantId = getTenantId(imageType); |
|||
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key); |
|||
checkEntity(getCurrentUser(), checkNotNull(imageInfo), operation); |
|||
return imageInfo; |
|||
} |
|||
|
|||
private TenantId getTenantId(String imageType) throws ThingsboardException { |
|||
TenantId tenantId; |
|||
if (imageType.equals(TENANT_IMAGE)) { |
|||
tenantId = getTenantId(); |
|||
} else if (imageType.equals(SYSTEM_IMAGE)) { |
|||
tenantId = TenantId.SYS_TENANT_ID; |
|||
} else { |
|||
throw new IllegalArgumentException("Invalid image URL"); |
|||
} |
|||
return tenantId; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.install.update; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Component; |
|||
import org.thingsboard.server.common.data.Dashboard; |
|||
import org.thingsboard.server.common.data.HasImage; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageDataIterable; |
|||
import org.thingsboard.server.dao.Dao; |
|||
import org.thingsboard.server.dao.asset.AssetProfileDao; |
|||
import org.thingsboard.server.dao.dashboard.DashboardDao; |
|||
import org.thingsboard.server.dao.device.DeviceProfileDao; |
|||
import org.thingsboard.server.dao.resource.ImageService; |
|||
import org.thingsboard.server.dao.widget.WidgetTypeDao; |
|||
import org.thingsboard.server.dao.widget.WidgetsBundleDao; |
|||
|
|||
import java.util.function.BiFunction; |
|||
import java.util.function.Function; |
|||
|
|||
@Component |
|||
@RequiredArgsConstructor |
|||
@Slf4j |
|||
public class ImagesUpdater { |
|||
private final ImageService imageService; |
|||
private final WidgetsBundleDao widgetsBundleDao; |
|||
private final WidgetTypeDao widgetTypeDao; |
|||
private final DashboardDao dashboardDao; |
|||
private final DeviceProfileDao deviceProfileDao; |
|||
private final AssetProfileDao assetProfileDao; |
|||
|
|||
public void updateWidgetsBundlesImages() { |
|||
log.info("Updating widgets bundles images..."); |
|||
var widgetsBundles = new PageDataIterable<>(widgetsBundleDao::findAllWidgetsBundles, 128); |
|||
updateImages(widgetsBundles, "bundle", imageService::replaceBase64WithImageUrl, widgetsBundleDao); |
|||
} |
|||
|
|||
public void updateWidgetTypesImages() { |
|||
log.info("Updating widget types images..."); |
|||
var widgetTypesIds = new PageDataIterable<>(widgetTypeDao::findAllWidgetTypesIds, 1024); |
|||
updateImages(widgetTypesIds, "widget type", imageService::replaceBase64WithImageUrl, widgetTypeDao); |
|||
} |
|||
|
|||
public void updateDashboardsImages() { |
|||
log.info("Updating dashboards images..."); |
|||
var dashboardsIds = new PageDataIterable<>(dashboardDao::findAllIds, 1024); |
|||
updateImages(dashboardsIds, "dashboard", imageService::replaceBase64WithImageUrl, dashboardDao); |
|||
} |
|||
|
|||
public void createSystemImages(Dashboard defaultDashboard) { |
|||
defaultDashboard.setTenantId(TenantId.SYS_TENANT_ID); |
|||
boolean created = imageService.replaceBase64WithImageUrl(defaultDashboard); |
|||
if (created) { |
|||
log.debug("Created system images for default dashboard '{}'", defaultDashboard.getTitle()); |
|||
} |
|||
} |
|||
|
|||
public void updateDeviceProfilesImages() { |
|||
log.info("Updating device profiles images..."); |
|||
var deviceProfiles = new PageDataIterable<>(deviceProfileDao::findAllWithImages, 256); |
|||
updateImages(deviceProfiles, "device profile", imageService::replaceBase64WithImageUrl, deviceProfileDao); |
|||
} |
|||
|
|||
public void updateAssetProfilesImages() { |
|||
log.info("Updating asset profiles images..."); |
|||
var assetProfiles = new PageDataIterable<>(assetProfileDao::findAllWithImages, 256); |
|||
updateImages(assetProfiles, "asset profile", imageService::replaceBase64WithImageUrl, assetProfileDao); |
|||
} |
|||
|
|||
private <E extends HasImage> void updateImages(Iterable<E> entities, String type, |
|||
BiFunction<E, String, Boolean> updater, Dao<E> dao) { |
|||
int updatedCount = 0; |
|||
int totalCount = 0; |
|||
for (E entity : entities) { |
|||
totalCount++; |
|||
try { |
|||
boolean updated = updater.apply(entity, type); |
|||
if (updated) { |
|||
dao.save(entity.getTenantId(), entity); |
|||
log.debug("[{}][{}] Updated {} images", entity.getTenantId(), entity.getName(), type); |
|||
updatedCount++; |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("[{}][{}] Failed to update {} images", entity.getTenantId(), entity.getName(), type, e); |
|||
} |
|||
if (totalCount % 100 == 0) { |
|||
log.info("Processed {} {}s so far", totalCount, type); |
|||
} |
|||
} |
|||
log.info("Updated {} {}s out of {}", updatedCount, type, totalCount); |
|||
} |
|||
|
|||
private <E extends HasImage> void updateImages(Iterable<? extends EntityId> entitiesIds, String type, |
|||
Function<E, Boolean> updater, Dao<E> dao) { |
|||
int updatedCount = 0; |
|||
int totalCount = 0; |
|||
for (EntityId id : entitiesIds) { |
|||
totalCount++; |
|||
E entity = dao.findById(TenantId.SYS_TENANT_ID, id.getId()); |
|||
try { |
|||
boolean updated = updater.apply(entity); |
|||
if (updated) { |
|||
dao.save(entity.getTenantId(), entity); |
|||
log.debug("[{}][{}] Updated {} images", entity.getTenantId(), entity.getName(), type); |
|||
updatedCount++; |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("[{}][{}] Failed to update {} images", entity.getTenantId(), entity.getName(), type, e); |
|||
} |
|||
if (totalCount % 100 == 0) { |
|||
log.info("Processed {} {}s so far", totalCount, type); |
|||
} |
|||
} |
|||
log.info("Updated {} {}s out of {}", updatedCount, type, totalCount); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.resource; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.github.benmanes.caffeine.cache.Cache; |
|||
import com.github.benmanes.caffeine.cache.Caffeine; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
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.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.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.dao.resource.ImageCacheKey; |
|||
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 java.util.Optional; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
@Service |
|||
@Slf4j |
|||
@TbCoreComponent |
|||
public class DefaultTbImageService extends AbstractTbEntityService implements TbImageService { |
|||
|
|||
private final TbClusterService clusterService; |
|||
private final ImageService imageService; |
|||
private final Cache<ImageCacheKey, String> etagCache; |
|||
|
|||
public DefaultTbImageService(TbClusterService clusterService, ImageService imageService, |
|||
@Value("${cache.image.etag.timeToLiveInMinutes:44640}") int cacheTtl, |
|||
@Value("${cache.image.etag.maxSize:10000}") int cacheMaxSize) { |
|||
this.clusterService = clusterService; |
|||
this.imageService = imageService; |
|||
this.etagCache = Caffeine.newBuilder() |
|||
.expireAfterAccess(cacheTtl, TimeUnit.MINUTES) |
|||
.maximumSize(cacheMaxSize) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public String getETag(ImageCacheKey imageCacheKey) { |
|||
return etagCache.getIfPresent(imageCacheKey); |
|||
} |
|||
|
|||
@Override |
|||
public void putETag(ImageCacheKey imageCacheKey, String etag) { |
|||
etagCache.put(imageCacheKey, etag); |
|||
} |
|||
|
|||
@Override |
|||
public void evictETag(ImageCacheKey imageCacheKey) { |
|||
etagCache.invalidate(imageCacheKey); |
|||
} |
|||
|
|||
@Override |
|||
public TbResourceInfo save(TbResource image, User user) throws Exception { |
|||
ActionType actionType = image.getId() == null ? ActionType.ADDED : ActionType.UPDATED; |
|||
TenantId tenantId = image.getTenantId(); |
|||
try { |
|||
var oldEtag = getEtag(image); |
|||
if (image.getId() == null && StringUtils.isNotEmpty(image.getResourceKey())) { |
|||
var existingImage = imageService.getImageInfoByTenantIdAndKey(tenantId, image.getResourceKey()); |
|||
if (existingImage != null) { |
|||
image.setId(existingImage.getId()); |
|||
} |
|||
} |
|||
TbResourceInfo savedImage = imageService.saveImage(image); |
|||
notificationEntityService.logEntityAction(tenantId, savedImage.getId(), savedImage, actionType, user); |
|||
if (oldEtag.isPresent()) { |
|||
var newEtag = getEtag(savedImage); |
|||
if (newEtag.isPresent() && !oldEtag.get().equals(newEtag.get())) { |
|||
evictETag(new ImageCacheKey(image.getTenantId(), image.getResourceKey(), false)); |
|||
evictETag(new ImageCacheKey(image.getTenantId(), image.getResourceKey(), true)); |
|||
clusterService.broadcastToCore(TransportProtos.ToCoreNotificationMsg.newBuilder() |
|||
.setResourceCacheInvalidateMsg(TransportProtos.ResourceCacheInvalidateMsg.newBuilder() |
|||
.setTenantIdMSB(tenantId.getId().getMostSignificantBits()) |
|||
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) |
|||
.setResourceKey(image.getResourceKey()) |
|||
.build()) |
|||
.build()); |
|||
} |
|||
} |
|||
return savedImage; |
|||
} catch (Exception e) { |
|||
image.setData(null); |
|||
notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), image, actionType, user, e); |
|||
throw e; |
|||
} |
|||
} |
|||
|
|||
private Optional<String> getEtag(TbResourceInfo image) throws JsonProcessingException { |
|||
var descriptor = image.getDescriptor(ImageDescriptor.class); |
|||
return Optional.ofNullable(descriptor != null ? descriptor.getEtag() : null); |
|||
} |
|||
|
|||
private Optional<String> getPreviewEtag(TbResourceInfo image) throws JsonProcessingException { |
|||
var descriptor = image.getDescriptor(ImageDescriptor.class); |
|||
descriptor = descriptor != null ? descriptor.getPreviewDescriptor() : null; |
|||
return Optional.ofNullable(descriptor != null ? descriptor.getEtag() : null); |
|||
} |
|||
|
|||
@Override |
|||
public TbResourceInfo save(TbResourceInfo imageInfo, User user) { |
|||
TenantId tenantId = imageInfo.getTenantId(); |
|||
TbResourceId imageId = imageInfo.getId(); |
|||
try { |
|||
imageInfo = imageService.saveImageInfo(imageInfo); |
|||
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user); |
|||
return imageInfo; |
|||
} catch (Exception e) { |
|||
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user, e); |
|||
throw e; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public TbImageDeleteResult delete(TbResourceInfo imageInfo, User user, boolean force) { |
|||
TenantId tenantId = imageInfo.getTenantId(); |
|||
TbResourceId imageId = imageInfo.getId(); |
|||
try { |
|||
TbImageDeleteResult result = imageService.deleteImage(imageInfo, force); |
|||
if (result.isSuccess()) { |
|||
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.DELETED, user, imageId.toString()); |
|||
} |
|||
return result; |
|||
} catch (Exception e) { |
|||
notificationEntityService.logEntityAction(tenantId, imageId, ActionType.DELETED, user, e, imageId.toString()); |
|||
throw e; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.resource; |
|||
|
|||
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; |
|||
|
|||
public interface TbImageService { |
|||
|
|||
TbResourceInfo save(TbResource image, User user) throws Exception; |
|||
|
|||
TbResourceInfo save(TbResourceInfo imageInfo, User user); |
|||
|
|||
TbImageDeleteResult delete(TbResourceInfo imageInfo, User user, boolean force); |
|||
|
|||
String getETag(ImageCacheKey imageCacheKey); |
|||
|
|||
void putETag(ImageCacheKey imageCacheKey, String etag); |
|||
|
|||
void evictETag(ImageCacheKey imageCacheKey); |
|||
|
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.sync.ie.exporting.impl; |
|||
|
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.TbResource; |
|||
import org.thingsboard.server.common.data.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.sync.ie.EntityExportData; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
|
|||
import java.util.Set; |
|||
|
|||
@Service |
|||
@TbCoreComponent |
|||
public class ResourceExportService extends BaseEntityExportService<TbResourceId, TbResource, EntityExportData<TbResource>> { |
|||
|
|||
@Override |
|||
public Set<EntityType> getSupportedEntityTypes() { |
|||
return Set.of(EntityType.TB_RESOURCE); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.sync.ie.importing.impl; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.TbResource; |
|||
import org.thingsboard.server.common.data.User; |
|||
import org.thingsboard.server.common.data.exception.ThingsboardException; |
|||
import org.thingsboard.server.common.data.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.sync.ie.EntityExportData; |
|||
import org.thingsboard.server.dao.resource.ResourceService; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; |
|||
|
|||
@Service |
|||
@TbCoreComponent |
|||
@RequiredArgsConstructor |
|||
public class ResourceImportService extends BaseEntityImportService<TbResourceId, TbResource, EntityExportData<TbResource>> { |
|||
|
|||
private final ResourceService resourceService; |
|||
|
|||
@Override |
|||
protected void setOwner(TenantId tenantId, TbResource resource, IdProvider idProvider) { |
|||
resource.setTenantId(tenantId); |
|||
} |
|||
|
|||
@Override |
|||
protected TbResource prepare(EntitiesImportCtx ctx, TbResource resource, TbResource oldResource, EntityExportData<TbResource> exportData, IdProvider idProvider) { |
|||
return resource; |
|||
} |
|||
|
|||
@Override |
|||
protected TbResource findExistingEntity(EntitiesImportCtx ctx, TbResource resource, IdProvider idProvider) { |
|||
TbResource existingResource = super.findExistingEntity(ctx, resource, idProvider); |
|||
if (existingResource == null && ctx.isFindExistingByName()) { |
|||
existingResource = resourceService.findResourceByTenantIdAndKey(ctx.getTenantId(), resource.getResourceType(), resource.getResourceKey()); |
|||
} |
|||
return existingResource; |
|||
} |
|||
|
|||
@Override |
|||
protected boolean compare(EntitiesImportCtx ctx, EntityExportData<TbResource> exportData, TbResource prepared, TbResource existing) { |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
protected TbResource deepCopy(TbResource resource) { |
|||
return new TbResource(resource); |
|||
} |
|||
|
|||
@Override |
|||
protected TbResource saveOrUpdate(EntitiesImportCtx ctx, TbResource resource, EntityExportData<TbResource> exportData, IdProvider idProvider) { |
|||
return resourceService.saveResource(resource); |
|||
} |
|||
|
|||
@Override |
|||
protected void onEntitySaved(User user, TbResource savedResource, TbResource oldResource) throws ThingsboardException { |
|||
super.onEntitySaved(user, savedResource, oldResource); |
|||
clusterService.onResourceChange(savedResource, null); |
|||
} |
|||
|
|||
@Override |
|||
public EntityType getEntityType() { |
|||
return EntityType.TB_RESOURCE; |
|||
} |
|||
|
|||
} |
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,58 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.resource; |
|||
|
|||
import org.thingsboard.server.common.data.Dashboard; |
|||
import org.thingsboard.server.common.data.HasImage; |
|||
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.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.page.PageLink; |
|||
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; |
|||
|
|||
public interface ImageService { |
|||
|
|||
TbResourceInfo saveImage(TbResource image); |
|||
|
|||
TbResourceInfo saveImageInfo(TbResourceInfo imageInfo); |
|||
|
|||
TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); |
|||
|
|||
PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, PageLink pageLink); |
|||
|
|||
PageData<TbResourceInfo> getAllImagesByTenantId(TenantId tenantId, PageLink pageLink); |
|||
|
|||
byte[] getImageData(TenantId tenantId, TbResourceId imageId); |
|||
|
|||
byte[] getImagePreview(TenantId tenantId, TbResourceId imageId); |
|||
|
|||
TbImageDeleteResult deleteImage(TbResourceInfo imageInfo, boolean force); |
|||
|
|||
TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, String etag); |
|||
|
|||
boolean replaceBase64WithImageUrl(HasImage entity, String type); |
|||
boolean replaceBase64WithImageUrl(Dashboard dashboard); |
|||
boolean replaceBase64WithImageUrl(WidgetTypeDetails widgetType); |
|||
|
|||
void inlineImage(HasImage entity); |
|||
|
|||
void inlineImages(Dashboard dashboard); |
|||
|
|||
void inlineImages(WidgetTypeDetails widgetTypeDetails); |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.data; |
|||
|
|||
public interface HasImage extends HasTenantId, HasName { |
|||
|
|||
String getImage(); |
|||
|
|||
void setImage(String image); |
|||
|
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.data; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public class ImageDescriptor { |
|||
private String mediaType; |
|||
private int width; |
|||
private int height; |
|||
private long size; |
|||
private String etag; |
|||
private ImageDescriptor previewDescriptor; |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.data; |
|||
|
|||
import io.swagger.annotations.ApiModel; |
|||
import lombok.Data; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
@ApiModel |
|||
@Slf4j |
|||
@Data |
|||
public class ImageExportData { |
|||
|
|||
private final String mediaType; |
|||
private final String fileName; |
|||
private final String title; |
|||
private final String resourceKey; |
|||
private final String data; |
|||
|
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.data; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.HasId; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
@Data |
|||
@Builder |
|||
public class TbImageDeleteResult { |
|||
|
|||
private boolean success; |
|||
private Map<String, List<? extends HasId<?>>> references; |
|||
|
|||
} |
|||
File diff suppressed because one or more lines are too long
@ -0,0 +1,29 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao; |
|||
|
|||
import org.thingsboard.server.common.data.id.HasId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface ImageContainerDao<T extends HasId<?>> { |
|||
|
|||
List<T> findByTenantAndImageLink(TenantId tenantId, String imageUrl, int limit); |
|||
|
|||
List<T> findByImageLink(String imageUrl, int limit); |
|||
|
|||
} |
|||
@ -0,0 +1,615 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.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; |
|||
import com.google.common.base.Strings; |
|||
import lombok.Data; |
|||
import lombok.SneakyThrows; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.apache.commons.lang3.StringUtils; |
|||
import org.apache.commons.lang3.exception.ExceptionUtils; |
|||
import org.apache.commons.lang3.tuple.Pair; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.util.Base64Utils; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.Dashboard; |
|||
import org.thingsboard.server.common.data.DataConstants; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.HasImage; |
|||
import org.thingsboard.server.common.data.ImageDescriptor; |
|||
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; |
|||
import org.thingsboard.server.common.data.TbResourceInfoFilter; |
|||
import org.thingsboard.server.common.data.id.HasId; |
|||
import org.thingsboard.server.common.data.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.page.PageLink; |
|||
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; |
|||
import org.thingsboard.server.dao.ImageContainerDao; |
|||
import org.thingsboard.server.dao.asset.AssetProfileDao; |
|||
import org.thingsboard.server.dao.dashboard.DashboardInfoDao; |
|||
import org.thingsboard.server.dao.device.DeviceProfileDao; |
|||
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 javax.annotation.PostConstruct; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.Base64; |
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.Iterator; |
|||
import java.util.LinkedList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
import java.util.Queue; |
|||
import java.util.Set; |
|||
import java.util.regex.Pattern; |
|||
|
|||
@Service |
|||
@Slf4j |
|||
public class BaseImageService extends BaseResourceService implements ImageService { |
|||
|
|||
private static final int MAX_ENTITIES_TO_FIND = 10; |
|||
|
|||
public static Map<String, String> DASHBOARD_BASE64_MAPPING = new HashMap<>(); |
|||
public static Map<String, String> WIDGET_TYPE_BASE64_MAPPING = new HashMap<>(); |
|||
|
|||
static { |
|||
DASHBOARD_BASE64_MAPPING.put("settings.dashboardLogoUrl", "$prefix logo"); |
|||
DASHBOARD_BASE64_MAPPING.put("states.default.layouts.main.gridSettings.backgroundImageUrl", "$prefix background"); |
|||
DASHBOARD_BASE64_MAPPING.put("states.default.layouts.right.gridSettings.backgroundImageUrl", "$prefix right background"); |
|||
DASHBOARD_BASE64_MAPPING.put("states.$stateId.layouts.main.gridSettings.backgroundImageUrl", "$prefix $stateId background"); |
|||
DASHBOARD_BASE64_MAPPING.put("states.$stateId.layouts.right.gridSettings.backgroundImageUrl", "$prefix $stateId right background"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.backgroundImageUrl", "$prefix widget \"$title\" background"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.mapImageUrl", "$prefix widget \"$title\" map image"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.markerImage", "$prefix widget \"$title\" marker image"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.markerImages", "$prefix widget \"$title\" marker image $index"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.background.imageUrl", "$prefix widget \"$title\" background"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].settings.background.imageBase64", "$prefix widget \"$title\" background"); |
|||
DASHBOARD_BASE64_MAPPING.put("widgets.*.config[$title].datasources.*.dataKeys.*.settings.customIcon", "$prefix widget \"$title\" custom icon"); |
|||
|
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.backgroundImageUrl", "$prefix background"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.mapImageUrl", "$prefix map image"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.markerImage", "Map marker image"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.markerImages", "Map marker image $index"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.background.imageUrl", "$prefix background"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("settings.background.imageBase64", "$prefix background"); |
|||
WIDGET_TYPE_BASE64_MAPPING.put("datasources.*.dataKeys.*.settings.customIcon", "$prefix custom icon"); |
|||
} |
|||
|
|||
private final AssetProfileDao assetProfileDao; |
|||
private final DeviceProfileDao deviceProfileDao; |
|||
private final WidgetsBundleDao widgetsBundleDao; |
|||
private final WidgetTypeDao widgetTypeDao; |
|||
private final DashboardInfoDao dashboardInfoDao; |
|||
private final Map<EntityType, ImageContainerDao<?>> imageContainerDaoMap = new HashMap<>(); |
|||
|
|||
public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator, |
|||
AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao, |
|||
WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) { |
|||
super(resourceDao, resourceInfoDao, resourceValidator); |
|||
this.assetProfileDao = assetProfileDao; |
|||
this.deviceProfileDao = deviceProfileDao; |
|||
this.widgetsBundleDao = widgetsBundleDao; |
|||
this.widgetTypeDao = widgetTypeDao; |
|||
this.dashboardInfoDao = dashboardInfoDao; |
|||
} |
|||
|
|||
@PostConstruct |
|||
public void init() { |
|||
imageContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); |
|||
imageContainerDaoMap.put(EntityType.WIDGETS_BUNDLE, widgetsBundleDao); |
|||
imageContainerDaoMap.put(EntityType.DEVICE_PROFILE, deviceProfileDao); |
|||
imageContainerDaoMap.put(EntityType.ASSET_PROFILE, assetProfileDao); |
|||
imageContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); |
|||
} |
|||
|
|||
|
|||
@Override |
|||
@SneakyThrows |
|||
public TbResourceInfo saveImage(TbResource image) { |
|||
if (image.getId() == null && StringUtils.isEmpty(image.getResourceKey())) { |
|||
image.setResourceKey(getUniqueKey(image.getTenantId(), image.getFileName())); |
|||
} |
|||
resourceValidator.validate(image, TbResourceInfo::getTenantId); |
|||
|
|||
ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class); |
|||
Pair<ImageDescriptor, byte[]> result = processImage(image.getData(), descriptor); |
|||
descriptor = result.getLeft(); |
|||
image.setEtag(descriptor.getEtag()); |
|||
image.setDescriptorValue(descriptor); |
|||
image.setPreview(result.getRight()); |
|||
|
|||
log.debug("[{}] Creating image {} ('{}')", image.getTenantId(), image.getResourceKey(), image.getName()); |
|||
return new TbResourceInfo(doSaveResource(image)); |
|||
} |
|||
|
|||
private Pair<ImageDescriptor, byte[]> processImage(byte[] data, ImageDescriptor descriptor) throws Exception { |
|||
ProcessedImage image = ImageUtils.processImage(data, descriptor.getMediaType(), 250); |
|||
ProcessedImage preview = image.getPreview(); |
|||
|
|||
descriptor.setWidth(image.getWidth()); |
|||
descriptor.setHeight(image.getHeight()); |
|||
descriptor.setSize(image.getSize()); |
|||
descriptor.setEtag(calculateEtag(data)); |
|||
|
|||
ImageDescriptor previewDescriptor = new ImageDescriptor(); |
|||
previewDescriptor.setWidth(preview.getWidth()); |
|||
previewDescriptor.setHeight(preview.getHeight()); |
|||
previewDescriptor.setMediaType(preview.getMediaType()); |
|||
previewDescriptor.setSize(preview.getSize()); |
|||
previewDescriptor.setEtag(preview.getData() != null ? calculateEtag(preview.getData()) : descriptor.getEtag()); |
|||
descriptor.setPreviewDescriptor(previewDescriptor); |
|||
|
|||
return Pair.of(descriptor, preview.getData()); |
|||
} |
|||
|
|||
private String getUniqueKey(TenantId tenantId, String filename) { |
|||
if (!resourceInfoDao.existsByTenantIdAndResourceTypeAndResourceKey(tenantId, ResourceType.IMAGE, filename)) { |
|||
return filename; |
|||
} |
|||
|
|||
String basename = StringUtils.substringBeforeLast(filename, "."); |
|||
String extension = StringUtils.substringAfterLast(filename, "."); |
|||
|
|||
Set<String> existing = resourceInfoDao.findKeysByTenantIdAndResourceTypeAndResourceKeyPrefix( |
|||
tenantId, ResourceType.IMAGE, basename |
|||
); |
|||
String resourceKey = filename; |
|||
int idx = 1; |
|||
while (existing.contains(resourceKey)) { |
|||
resourceKey = basename + "_(" + idx + ")." + extension; |
|||
idx++; |
|||
} |
|||
log.debug("[{}] Generated unique key {} for image {}", tenantId, resourceKey, filename); |
|||
return resourceKey; |
|||
} |
|||
|
|||
@Override |
|||
public TbResourceInfo saveImageInfo(TbResourceInfo imageInfo) { |
|||
log.trace("Executing saveImageInfo [{}] [{}]", imageInfo.getTenantId(), imageInfo.getId()); |
|||
return saveResource(new TbResource(imageInfo)); |
|||
} |
|||
|
|||
@Override |
|||
public TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key) { |
|||
log.trace("Executing getImageInfoByTenantIdAndKey [{}] [{}]", tenantId, key); |
|||
return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key); |
|||
} |
|||
|
|||
@Override |
|||
public PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, PageLink pageLink) { |
|||
log.trace("Executing getImagesByTenantId [{}]", tenantId); |
|||
TbResourceInfoFilter filter = TbResourceInfoFilter.builder() |
|||
.tenantId(tenantId) |
|||
.resourceTypes(Set.of(ResourceType.IMAGE)) |
|||
.build(); |
|||
return findTenantResourcesByTenantId(filter, pageLink); |
|||
} |
|||
|
|||
@Override |
|||
public PageData<TbResourceInfo> getAllImagesByTenantId(TenantId tenantId, PageLink pageLink) { |
|||
log.trace("Executing getAllImagesByTenantId [{}]", tenantId); |
|||
TbResourceInfoFilter filter = TbResourceInfoFilter.builder() |
|||
.tenantId(tenantId) |
|||
.resourceTypes(Set.of(ResourceType.IMAGE)) |
|||
.build(); |
|||
return findAllTenantResourcesByTenantId(filter, pageLink); |
|||
} |
|||
|
|||
@Override |
|||
public byte[] getImageData(TenantId tenantId, TbResourceId imageId) { |
|||
log.trace("Executing getImageData [{}] [{}]", tenantId, imageId); |
|||
return resourceDao.getResourceData(tenantId, imageId); |
|||
} |
|||
|
|||
@Override |
|||
public byte[] getImagePreview(TenantId tenantId, TbResourceId imageId) { |
|||
log.trace("Executing getImagePreview [{}] [{}]", tenantId, imageId); |
|||
return resourceDao.getResourcePreview(tenantId, imageId); |
|||
} |
|||
|
|||
@Override |
|||
public TbImageDeleteResult deleteImage(TbResourceInfo imageInfo, boolean force) { |
|||
var tenantId = imageInfo.getTenantId(); |
|||
var imageId = imageInfo.getId(); |
|||
log.trace("Executing deleteImage [{}] [{}]", tenantId, imageId); |
|||
Validator.validateId(imageId, INCORRECT_RESOURCE_ID + imageId); |
|||
TbImageDeleteResult.TbImageDeleteResultBuilder result = TbImageDeleteResult.builder(); |
|||
boolean success = true; |
|||
if (!force) { |
|||
var link = DataConstants.TB_IMAGE_PREFIX + imageInfo.getLink(); |
|||
Map<String, List<? extends HasId<?>>> affectedEntities = new HashMap<>(); |
|||
imageContainerDaoMap.forEach((entityType, imageContainerDao) -> { |
|||
var entities = tenantId.isSysTenantId() ? imageContainerDao.findByImageLink(link, MAX_ENTITIES_TO_FIND) : |
|||
imageContainerDao.findByTenantAndImageLink(tenantId, link, MAX_ENTITIES_TO_FIND); |
|||
if (!entities.isEmpty()) { |
|||
affectedEntities.put(entityType.name(), entities); |
|||
} |
|||
}); |
|||
if (!affectedEntities.isEmpty()) { |
|||
success = false; |
|||
result.references(affectedEntities); |
|||
} |
|||
} |
|||
if (success) { |
|||
deleteResource(tenantId, imageId, force); |
|||
} |
|||
return result.success(success).build(); |
|||
} |
|||
|
|||
@Override |
|||
public TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, String etag) { |
|||
log.trace("Executing findSystemOrTenantImageByEtag [{}] [{}]", tenantId, etag); |
|||
return resourceInfoDao.findSystemOrTenantImageByEtag(tenantId, ResourceType.IMAGE, etag); |
|||
} |
|||
|
|||
@Transactional(propagation = Propagation.NOT_SUPPORTED)// we don't want transaction to rollback in case of an image processing failure
|
|||
@Override |
|||
public boolean replaceBase64WithImageUrl(HasImage entity, String type) { |
|||
log.trace("Executing replaceBase64WithImageUrl [{}] [{}] [{}]", entity.getTenantId(), type, entity.getName()); |
|||
String imageName = "\"" + entity.getName() + "\" "; |
|||
if (entity.getTenantId() == null || entity.getTenantId().isSysTenantId()) { |
|||
imageName += "system "; |
|||
} |
|||
imageName = imageName + type + " image"; |
|||
|
|||
UpdateResult result = base64ToImageUrl(entity.getTenantId(), imageName, entity.getImage()); |
|||
entity.setImage(result.getValue()); |
|||
return result.isUpdated(); |
|||
} |
|||
|
|||
@Transactional(propagation = Propagation.NOT_SUPPORTED)// we don't want transaction to rollback in case of an image processing failure
|
|||
@Override |
|||
public boolean replaceBase64WithImageUrl(WidgetTypeDetails entity) { |
|||
log.trace("Executing replaceBase64WithImageUrl [{}] [WidgetTypeDetails] [{}]", entity.getTenantId(), entity.getId()); |
|||
String prefix = "\"" + entity.getName() + "\" "; |
|||
if (entity.getTenantId() == null || entity.getTenantId().isSysTenantId()) { |
|||
prefix += "system "; |
|||
} |
|||
prefix += "widget"; |
|||
UpdateResult result = base64ToImageUrl(entity.getTenantId(), prefix + " image", entity.getImage()); |
|||
entity.setImage(result.getValue()); |
|||
boolean updated = result.isUpdated(); |
|||
if (entity.getDescriptor().isObject()) { |
|||
ObjectNode descriptor = (ObjectNode) entity.getDescriptor(); |
|||
JsonNode defaultConfig = Optional.ofNullable(descriptor.get("defaultConfig")) |
|||
.filter(JsonNode::isTextual).map(JsonNode::asText) |
|||
.map(JacksonUtil::toJsonNode).orElse(null); |
|||
if (defaultConfig != null) { |
|||
updated |= base64ToImageUrlUsingMapping(entity.getTenantId(), WIDGET_TYPE_BASE64_MAPPING, Collections.singletonMap("prefix", prefix), defaultConfig); |
|||
descriptor.put("defaultConfig", defaultConfig.toString()); |
|||
} |
|||
} |
|||
updated |= base64ToImageUrlRecursively(entity.getTenantId(), prefix, entity.getDescriptor()); |
|||
return updated; |
|||
} |
|||
|
|||
@Transactional(propagation = Propagation.NOT_SUPPORTED)// we don't want transaction to rollback in case of an image processing failure
|
|||
@Override |
|||
public boolean replaceBase64WithImageUrl(Dashboard entity) { |
|||
log.trace("Executing replaceBase64WithImageUrl [{}] [Dashboard] [{}]", entity.getTenantId(), entity.getId()); |
|||
String prefix = "\"" + entity.getTitle() + "\" dashboard"; |
|||
var result = base64ToImageUrl(entity.getTenantId(), prefix + " image", entity.getImage()); |
|||
boolean updated = result.isUpdated(); |
|||
entity.setImage(result.getValue()); |
|||
updated |= base64ToImageUrlUsingMapping(entity.getTenantId(), DASHBOARD_BASE64_MAPPING, Collections.singletonMap("prefix", prefix), entity.getConfiguration()); |
|||
updated |= base64ToImageUrlRecursively(entity.getTenantId(), prefix, entity.getConfiguration()); |
|||
return updated; |
|||
} |
|||
|
|||
private boolean base64ToImageUrlUsingMapping(TenantId tenantId, Map<String, String> mapping, Map<String, String> templateParams, JsonNode configuration) { |
|||
boolean updated = false; |
|||
for (var entry : mapping.entrySet()) { |
|||
String expression = entry.getValue(); |
|||
Queue<JsonPathProcessingTask> tasks = new LinkedList<>(); |
|||
tasks.add(new JsonPathProcessingTask(entry.getKey().split("\\."), templateParams, configuration)); |
|||
while (!tasks.isEmpty()) { |
|||
JsonPathProcessingTask task = tasks.poll(); |
|||
String token = task.currentToken(); |
|||
JsonNode node = task.getNode(); |
|||
if (node == null) { |
|||
continue; |
|||
} |
|||
if (token.equals("*") || token.startsWith("$")) { |
|||
String variableName = token.startsWith("$") ? token.substring(1) : null; |
|||
if (node.isArray()) { |
|||
ArrayNode childArray = (ArrayNode) node; |
|||
for (JsonNode element : childArray) { |
|||
tasks.add(task.next(element)); |
|||
} |
|||
} else if (node.isObject()) { |
|||
ObjectNode on = (ObjectNode) node; |
|||
for (Iterator<Map.Entry<String, JsonNode>> it = on.fields(); it.hasNext(); ) { |
|||
var kv = it.next(); |
|||
if (variableName != null) { |
|||
tasks.add(task.next(kv.getValue(), variableName, kv.getKey())); |
|||
} else { |
|||
tasks.add(task.next(kv.getValue())); |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
String variableName = null; |
|||
String variableValue = null; |
|||
if (token.contains("[$")) { |
|||
variableName = StringUtils.substringBetween(token, "[$", "]"); |
|||
token = StringUtils.substringBefore(token, "[$"); |
|||
} |
|||
if (node.has(token)) { |
|||
JsonNode value = node.get(token); |
|||
if (variableName != null && value.has(variableName) && value.get(variableName).isTextual()) { |
|||
variableValue = value.get(variableName).asText(); |
|||
} |
|||
if (task.isLast()) { |
|||
String name = expression; |
|||
for (var replacement : task.getVariables().entrySet()) { |
|||
name = name.replace("$" + replacement.getKey(), Strings.nullToEmpty(replacement.getValue())); |
|||
} |
|||
if (node.isObject() && value.isTextual()) { |
|||
var result = base64ToImageUrl(tenantId, name, value.asText()); |
|||
((ObjectNode) node).put(token, result.getValue()); |
|||
updated |= result.isUpdated(); |
|||
} else if (value.isArray()) { |
|||
ArrayNode array = (ArrayNode) value; |
|||
for (int i = 0; i < array.size(); i++) { |
|||
String arrayElementName = name.replace("$index", Integer.toString(i)); |
|||
UpdateResult result = base64ToImageUrl(tenantId, arrayElementName, array.get(i).asText()); |
|||
array.set(i, result.getValue()); |
|||
updated |= result.isUpdated(); |
|||
} |
|||
} |
|||
} else { |
|||
if (StringUtils.isNotEmpty(variableName)) { |
|||
tasks.add(task.next(value, variableName, variableValue)); |
|||
} else { |
|||
tasks.add(task.next(value)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return updated; |
|||
} |
|||
|
|||
private UpdateResult base64ToImageUrl(TenantId tenantId, String name, String data) { |
|||
return base64ToImageUrl(tenantId, name, data, false); |
|||
} |
|||
|
|||
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); |
|||
} |
|||
var matcher = TB_IMAGE_METADATA_PATTERN.matcher(data); |
|||
boolean matches = matcher.matches(); |
|||
String mdResourceKey = null; |
|||
String mdResourceName = null; |
|||
String mdMediaType; |
|||
if (matches) { |
|||
mdResourceKey = new String(Base64Utils.decodeFromString(matcher.group(1)), StandardCharsets.UTF_8); |
|||
mdResourceName = new String(Base64Utils.decodeFromString(matcher.group(2)), StandardCharsets.UTF_8); |
|||
mdMediaType = matcher.group(3); |
|||
} else if (data.startsWith(DataConstants.TB_IMAGE_PREFIX + "data:image/") || (!strict && data.startsWith("data:image/"))) { |
|||
mdMediaType = 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); |
|||
var imageInfo = findSystemOrTenantImageByEtag(tenantId, etag); |
|||
if (imageInfo == null) { |
|||
TbResource image = new TbResource(); |
|||
image.setTenantId(tenantId); |
|||
image.setResourceType(ResourceType.IMAGE); |
|||
if (StringUtils.isBlank(mdResourceName)) { |
|||
mdResourceName = name; |
|||
} |
|||
image.setTitle(mdResourceName); |
|||
|
|||
String fileName; |
|||
if (StringUtils.isBlank(mdResourceKey)) { |
|||
fileName = StringUtils.strip(mdResourceName.toLowerCase() |
|||
.replaceAll("['\"]", "") |
|||
.replaceAll("[^\\pL\\d]+", "_"), "_") // leaving only letters and numbers
|
|||
+ "." + extension; |
|||
} else { |
|||
fileName = mdResourceKey; |
|||
} |
|||
image.setFileName(fileName); |
|||
image.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mdMediaType)); |
|||
image.setData(imageData); |
|||
try { |
|||
imageInfo = saveImage(image); |
|||
} catch (Exception e) { |
|||
if (log.isDebugEnabled()) { // printing stacktrace
|
|||
log.warn("[{}][{}] Failed to replace Base64 with image url for {}", tenantId, name, StringUtils.abbreviate(data, 50), e); |
|||
} else { |
|||
log.warn("[{}][{}] Failed to replace Base64 with image url for {}: {}", tenantId, name, StringUtils.abbreviate(data, 50), ExceptionUtils.getMessage(e)); |
|||
} |
|||
return UpdateResult.of(false, data); |
|||
} |
|||
} |
|||
return UpdateResult.of(true, DataConstants.TB_IMAGE_PREFIX + imageInfo.getLink()); |
|||
} |
|||
|
|||
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; |
|||
} |
|||
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; |
|||
} |
|||
|
|||
@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())); |
|||
} |
|||
|
|||
@Override |
|||
public void inlineImages(Dashboard dashboard) { |
|||
log.trace("Executing inlineImage [{}] [Dashboard] [{}]", dashboard.getTenantId(), dashboard.getId()); |
|||
inlineImage(dashboard); |
|||
inlineIntoJson(dashboard.getTenantId(), dashboard.getConfiguration()); |
|||
} |
|||
|
|||
@Override |
|||
public void inlineImages(WidgetTypeDetails widgetTypeDetails) { |
|||
log.trace("Executing inlineImage [{}] [WidgetTypeDetails] [{}]", widgetTypeDetails.getTenantId(), widgetTypeDetails.getId()); |
|||
inlineImage(widgetTypeDetails); |
|||
inlineIntoJson(widgetTypeDetails.getTenantId(), widgetTypeDetails.getDescriptor()); |
|||
} |
|||
|
|||
private void inlineIntoJson(TenantId tenantId, JsonNode root) { |
|||
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())); |
|||
} 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())); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private String inlineImage(TenantId tenantId, String path, String url) { |
|||
try { |
|||
ImageCacheKey key = getKeyFromUrl(tenantId, url); |
|||
if (key != null) { |
|||
var imageInfo = getImageInfoByTenantIdAndKey(key.getTenantId(), key.getKey()); |
|||
if (imageInfo != null) { |
|||
byte[] data = key.isPreview() ? getImagePreview(tenantId, imageInfo.getId()) : getImageData(tenantId, imageInfo.getId()); |
|||
ImageDescriptor descriptor = getImageDescriptor(imageInfo, key.isPreview()); |
|||
String tbImagePrefix = "tb-image:" + Base64Utils.encodeToString(imageInfo.getResourceKey().getBytes(StandardCharsets.UTF_8)) + ":" |
|||
+ Base64Utils.encodeToString(imageInfo.getName().getBytes(StandardCharsets.UTF_8)) + ";"; |
|||
return tbImagePrefix + "data:" + descriptor.getMediaType() + ";base64," + Base64Utils.encodeToString(data); |
|||
} |
|||
} |
|||
} catch (Exception e) { |
|||
log.warn("[{}][{}][{}] Failed to inline image.", tenantId, path, url, e); |
|||
} |
|||
return url; |
|||
} |
|||
|
|||
private ImageDescriptor getImageDescriptor(TbResourceInfo imageInfo, boolean preview) throws JsonProcessingException { |
|||
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class); |
|||
return preview ? descriptor.getPreviewDescriptor() : descriptor; |
|||
} |
|||
|
|||
private ImageCacheKey getKeyFromUrl(TenantId tenantId, String url) { |
|||
if (StringUtils.isBlank(url)) { |
|||
return null; |
|||
} |
|||
TenantId imageTenantId = null; |
|||
if (url.startsWith(DataConstants.TB_IMAGE_PREFIX + "/api/images/tenant/")) { |
|||
imageTenantId = tenantId; |
|||
} else if (url.startsWith(DataConstants.TB_IMAGE_PREFIX + "/api/images/system/")) { |
|||
imageTenantId = TenantId.SYS_TENANT_ID; |
|||
} |
|||
if (imageTenantId != null) { |
|||
var parts = url.split("/"); |
|||
if (parts.length == 5) { |
|||
return new ImageCacheKey(imageTenantId, parts[4], false); |
|||
} else if (parts.length == 6 && "preview".equals(parts[5])) { |
|||
return new ImageCacheKey(imageTenantId, parts[4], true); |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
@Data(staticConstructor = "of") |
|||
private static class UpdateResult { |
|||
private final boolean updated; |
|||
private final String value; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
/** |
|||
* Copyright © 2016-2023 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.resource; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
|
|||
@Data |
|||
public class ImageCacheKey { |
|||
|
|||
private final TenantId tenantId; |
|||
private final String key; |
|||
private final boolean preview; |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue