Browse Source

Merge pull request #9846 from thingsboard/feature/public-images

Public images
pull/9863/head
Andrew Shvayka 3 years ago
committed by GitHub
parent
commit
e05a5c3420
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 30
      application/src/main/data/upgrade/3.6.1/schema_update.sql
  2. 2
      application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
  3. 103
      application/src/main/java/org/thingsboard/server/controller/ImageController.java
  4. 2
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
  5. 15
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  6. 66
      application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java
  7. 4
      application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java
  8. 85
      application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java
  9. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java
  10. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java
  11. 15
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java
  12. 23
      common/data/src/main/java/org/thingsboard/server/common/data/util/ThrowingSupplier.java
  13. 7
      common/proto/src/main/proto/queue.proto
  14. 2
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  15. 12
      dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java
  16. 12
      dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java
  17. 26
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  18. 36
      dao/src/main/java/org/thingsboard/server/dao/resource/ImageCacheKey.java
  19. 5
      dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java
  20. 10
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceInfoDao.java
  21. 5
      dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java
  22. 39
      dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java
  23. 4
      dao/src/main/resources/sql/schema-entities-idx.sql
  24. 2
      dao/src/main/resources/sql/schema-entities.sql
  25. 7
      ui-ngx/src/app/core/http/image.service.ts
  26. 60
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html
  27. 71
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss
  28. 94
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts
  29. 19
      ui-ngx/src/app/shared/components/image/image-dialog.component.html
  30. 33
      ui-ngx/src/app/shared/components/image/image-dialog.component.ts
  31. 22
      ui-ngx/src/app/shared/components/image/image-gallery.component.html
  32. 20
      ui-ngx/src/app/shared/components/image/image-gallery.component.ts
  33. 7
      ui-ngx/src/app/shared/models/resource.models.ts
  34. 7
      ui-ngx/src/app/shared/shared.module.ts
  35. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json

30
application/src/main/data/upgrade/3.6.1/schema_update.sql

@ -16,6 +16,28 @@
-- RESOURCES UPDATE START
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;
ALTER TABLE resource ADD COLUMN IF NOT EXISTS is_public boolean default true;
ALTER TABLE resource ADD COLUMN IF NOT EXISTS public_resource_key varchar(32) unique;
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag);
CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(resource_type, public_resource_key);
CREATE OR REPLACE FUNCTION generate_resource_public_key()
RETURNS text AS $$
DECLARE
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
result text := '';
BEGIN
FOR i IN 1..32 LOOP
result := result || substr(chars, floor(random()*62)::int + 1, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
DO
$$
BEGIN
@ -24,15 +46,13 @@ $$
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;
ELSE
UPDATE resource SET public_resource_key = generate_resource_public_key() WHERE resource_type = 'IMAGE' AND public_resource_key IS NULL;
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);
DROP FUNCTION generate_resource_public_key;
-- RESOURCES UPDATE END

2
application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java

@ -75,7 +75,7 @@ public class ThingsboardSecurityConfiguration {
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";
protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**"};
protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**"};
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";

103
application/src/main/java/org/thingsboard/server/controller/ImageController.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -50,6 +49,7 @@ 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.common.data.util.ThrowingSupplier;
import org.thingsboard.server.dao.resource.ImageCacheKey;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
@ -89,6 +89,10 @@ public class ImageController extends BaseController {
private static final String SYSTEM_IMAGE = "system";
private static final String TENANT_IMAGE = "tenant";
private static final String IMAGE_TYPE_PARAM_DESCRIPTION = "Type of the image: tenant or system";
private static final String IMAGE_TYPE_PARAM_ALLOWABLE_VALUES = "tenant, system";
private static final String IMAGE_KEY_PARAM_DESCRIPTION = "Image resource key, for example thermostats_dashboard_background.jpeg";
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PostMapping("/api/image")
public TbResourceInfo uploadImage(@RequestPart MultipartFile file,
@ -110,12 +114,15 @@ public class ImageController extends BaseController {
descriptor.setMediaType(file.getContentType());
image.setDescriptorValue(descriptor);
image.setData(file.getBytes());
image.setPublic(true);
return tbImageService.save(image, user);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(IMAGE_URL)
public TbResourceInfo updateImage(@PathVariable String type,
public TbResourceInfo updateImage(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@RequestPart MultipartFile file) throws Exception {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE);
@ -133,29 +140,65 @@ public class ImageController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(IMAGE_URL + "/info")
public TbResourceInfo updateImageInfo(@PathVariable String type,
public TbResourceInfo updateImageInfo(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@RequestBody TbResourceInfo newImageInfo) throws ThingsboardException {
@RequestBody TbResourceInfo request) throws ThingsboardException {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE);
TbResourceInfo newImageInfo = new TbResourceInfo(imageInfo);
newImageInfo.setTitle(request.getTitle());
return tbImageService.save(newImageInfo, imageInfo, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(IMAGE_URL + "/public/{isPublic}")
public TbResourceInfo updateImagePublicStatus(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@PathVariable boolean isPublic) throws ThingsboardException {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE);
imageInfo.setTitle(newImageInfo.getTitle());
return tbImageService.save(imageInfo, getCurrentUser());
TbResourceInfo newImageInfo = new TbResourceInfo(imageInfo);
newImageInfo.setPublic(isPublic);
return tbImageService.save(newImageInfo, imageInfo, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = IMAGE_URL, produces = "image/*")
public ResponseEntity<ByteArrayResource> downloadImage(@PathVariable String type,
public ResponseEntity<ByteArrayResource> downloadImage(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception {
return downloadIfChanged(type, key, etag, false);
}
@GetMapping(value = "/api/images/public/{publicResourceKey}", produces = "image/*")
public ResponseEntity<ByteArrayResource> downloadPublicImage(@PathVariable String publicResourceKey,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception {
ImageCacheKey cacheKey = ImageCacheKey.forPublicImage(publicResourceKey);
return downloadIfChanged(cacheKey, etag, () -> imageService.getPublicImageInfoByKey(publicResourceKey));
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = IMAGE_URL + "/export")
public ImageExportData exportImage(@PathVariable String type, @PathVariable String key) throws Exception {
public ImageExportData exportImage(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = 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 new ImageExportData(descriptor.getMediaType(), imageInfo.getFileName(), imageInfo.getTitle(), imageInfo.getResourceKey(), Base64Utils.encodeToString(data));
return ImageExportData.builder()
.mediaType(descriptor.getMediaType())
.fileName(imageInfo.getFileName())
.title(imageInfo.getTitle())
.resourceKey(imageInfo.getResourceKey())
.isPublic(imageInfo.isPublic())
.publicResourceKey(imageInfo.getPublicResourceKey())
.data(Base64Utils.encodeToString(data))
.build();
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@ -172,19 +215,22 @@ public class ImageController extends BaseController {
} else {
image.setTitle(imageData.getFileName());
}
image.setResourceKey(imageData.getResourceKey());
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(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,
public ResponseEntity<ByteArrayResource> downloadImagePreview(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception {
return downloadIfChanged(type, key, etag, true);
@ -192,7 +238,9 @@ public class ImageController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(IMAGE_URL + "/info")
public TbResourceInfo getImageInfo(@PathVariable String type,
public TbResourceInfo getImageInfo(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key) throws ThingsboardException {
return checkImageInfo(type, key, Operation.READ);
}
@ -223,7 +271,9 @@ public class ImageController extends BaseController {
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@DeleteMapping(IMAGE_URL)
public ResponseEntity<TbImageDeleteResult> deleteImage(@PathVariable String type,
public ResponseEntity<TbImageDeleteResult> deleteImage(@ApiParam(value = IMAGE_TYPE_PARAM_DESCRIPTION, allowableValues = IMAGE_TYPE_PARAM_ALLOWABLE_VALUES, required = true)
@PathVariable String type,
@ApiParam(value = IMAGE_KEY_PARAM_DESCRIPTION, required = true)
@PathVariable String key,
@RequestParam(name = "force", required = false) boolean force) throws ThingsboardException {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.DELETE);
@ -231,32 +281,39 @@ public class ImageController extends BaseController {
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);
private ResponseEntity<ByteArrayResource> downloadIfChanged(String type, String key, String etag, boolean preview) throws Exception {
ImageCacheKey cacheKey = ImageCacheKey.forImage(getTenantId(type), key, preview);
return downloadIfChanged(cacheKey, etag, () -> checkImageInfo(type, key, Operation.READ));
}
private ResponseEntity<ByteArrayResource> downloadIfChanged(ImageCacheKey cacheKey, String etag, ThrowingSupplier<TbResourceInfo> imageInfoSupplier) throws Exception {
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);
TbResourceInfo imageInfo = checkNotNull(imageInfoSupplier.get());
String fileName = imageInfo.getFileName();
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class);
byte[] data;
if (preview) {
if (cacheKey.isPreview()) {
descriptor = descriptor.getPreviewDescriptor();
data = imageService.getImagePreview(tenantId, imageInfo.getId());
data = imageService.getImagePreview(imageInfo.getTenantId(), imageInfo.getId());
} else {
data = imageService.getImageData(tenantId, imageInfo.getId());
data = imageService.getImageData(imageInfo.getTenantId(), 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 (!cacheKey.isPublic()) {
result
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName)
.header("x-filename", fileName);
}
if (systemImagesBrowserTtlInMinutes > 0 && imageInfo.getTenantId().isSysTenantId()) {
result.cacheControl(CacheControl.maxAge(systemImagesBrowserTtlInMinutes, TimeUnit.MINUTES));
} else if (tenantImagesBrowserTtlInMinutes > 0 && !imageInfo.getTenantId().isSysTenantId()) {

2
application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java

@ -27,6 +27,7 @@ import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
import static org.thingsboard.server.common.data.CacheConstants.RESOURCE_INFO_CACHE;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
@RequiredArgsConstructor
@ -91,6 +92,7 @@ public class DefaultCacheCleanupService implements CacheCleanupService {
case "3.6.1":
log.info("Clearing cache to upgrade from version 3.6.1 to 3.6.2");
clearCacheByName(SECURITY_SETTINGS_CACHE);
clearCacheByName(RESOURCE_INFO_CACHE);
break;
default:
//Do nothing, since cache cleanup is optional.

15
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -44,8 +44,10 @@ import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequestActorMsg;
import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.resource.ImageCacheKey;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto;
@ -59,6 +61,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmDeleteProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbAlarmUpdateProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeDeleteProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbAttributeUpdateProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbEntitySubEventProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesDeleteProto;
import org.thingsboard.server.gen.transport.TransportProtos.TbTimeSeriesUpdateProto;
import org.thingsboard.server.gen.transport.TransportProtos.ToCoreMsg;
@ -66,7 +69,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToCoreNotificationMs
import org.thingsboard.server.gen.transport.TransportProtos.ToOtaPackageStateServiceMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceMsg;
import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg;
import org.thingsboard.server.gen.transport.TransportProtos.TbEntitySubEventProto;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.discovery.PartitionService;
@ -83,10 +85,8 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.queue.processing.AbstractConsumerService;
import org.thingsboard.server.service.queue.processing.IdMsgPair;
import org.thingsboard.server.dao.resource.ImageCacheKey;
import org.thingsboard.server.service.resource.TbImageService;
import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService;
import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequestActorMsg;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
import org.thingsboard.server.service.state.DeviceStateService;
import org.thingsboard.server.service.subscription.SubscriptionManagerService;
@ -560,8 +560,13 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
private void forwardToResourceService(TransportProtos.ResourceCacheInvalidateMsg msg, TbCallback callback) {
var tenantId = new TenantId(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB()));
imageService.evictETag(new ImageCacheKey(tenantId, msg.getResourceKey(), false));
imageService.evictETag(new ImageCacheKey(tenantId, msg.getResourceKey(), true));
msg.getKeysList().stream().map(cacheKeyProto -> {
if (cacheKeyProto.hasResourceKey()) {
return ImageCacheKey.forImage(tenantId, cacheKeyProto.getResourceKey());
} else {
return ImageCacheKey.forPublicImage(cacheKeyProto.getPublicResourceKey());
}
}).forEach(imageService::evictETags);
callback.onSuccess();
}

66
application/src/main/java/org/thingsboard/server/service/resource/DefaultTbImageService.java

@ -38,8 +38,11 @@ import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
@Slf4j
@ -72,8 +75,11 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb
}
@Override
public void evictETag(ImageCacheKey imageCacheKey) {
public void evictETags(ImageCacheKey imageCacheKey) {
etagCache.invalidate(imageCacheKey);
if (imageCacheKey.getPublicResourceKey() == null) {
etagCache.invalidate(imageCacheKey.withPreview(true));
}
}
@Override
@ -82,28 +88,32 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb
TenantId tenantId = image.getTenantId();
try {
var oldEtag = getEtag(image);
TbResourceInfo existingImage = null;
if (image.getId() == null && StringUtils.isNotEmpty(image.getResourceKey())) {
var existingImage = imageService.getImageInfoByTenantIdAndKey(tenantId, image.getResourceKey());
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);
List<ImageCacheKey> toEvict = new ArrayList<>();
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());
toEvict.add(ImageCacheKey.forImage(tenantId, image.getResourceKey()));
if (image.isPublic()) {
toEvict.add(ImageCacheKey.forPublicImage(savedImage.getPublicResourceKey()));
}
}
}
if (existingImage != null && image.isPublic() != existingImage.isPublic()) {
toEvict.add(ImageCacheKey.forPublicImage(image.getPublicResourceKey()));
}
if (!toEvict.isEmpty()) {
evictFromCache(tenantId, toEvict);
}
return savedImage;
} catch (Exception e) {
image.setData(null);
@ -124,12 +134,16 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb
}
@Override
public TbResourceInfo save(TbResourceInfo imageInfo, User user) {
public TbResourceInfo save(TbResourceInfo imageInfo, TbResourceInfo oldImageInfo, User user) {
TenantId tenantId = imageInfo.getTenantId();
TbResourceId imageId = imageInfo.getId();
try {
imageInfo = imageService.saveImageInfo(imageInfo);
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user);
if (imageInfo.isPublic() != oldImageInfo.isPublic()) {
evictFromCache(tenantId, List.of(ImageCacheKey.forPublicImage(imageInfo.getPublicResourceKey())));
}
return imageInfo;
} catch (Exception e) {
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user, e);
@ -145,15 +159,13 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb
TbImageDeleteResult result = imageService.deleteImage(imageInfo, force);
if (result.isSuccess()) {
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.DELETED, user, imageId.toString());
evictETag(new ImageCacheKey(tenantId, imageInfo.getResourceKey(), false));
evictETag(new ImageCacheKey(tenantId, imageInfo.getResourceKey(), true));
clusterService.broadcastToCore(TransportProtos.ToCoreNotificationMsg.newBuilder()
.setResourceCacheInvalidateMsg(TransportProtos.ResourceCacheInvalidateMsg.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.setResourceKey(imageInfo.getResourceKey())
.build())
.build());
List<ImageCacheKey> toEvict = new ArrayList<>();
toEvict.add(ImageCacheKey.forImage(tenantId, imageInfo.getResourceKey()));
if (imageInfo.isPublic()) {
toEvict.add(ImageCacheKey.forPublicImage(imageInfo.getPublicResourceKey()));
}
evictFromCache(tenantId, toEvict);
}
return result;
} catch (Exception e) {
@ -161,4 +173,16 @@ public class DefaultTbImageService extends AbstractTbEntityService implements Tb
throw e;
}
}
private void evictFromCache(TenantId tenantId, List<ImageCacheKey> toEvict) {
toEvict.forEach(this::evictETags);
clusterService.broadcastToCore(TransportProtos.ToCoreNotificationMsg.newBuilder()
.setResourceCacheInvalidateMsg(TransportProtos.ResourceCacheInvalidateMsg.newBuilder()
.setTenantIdMSB(tenantId.getId().getMostSignificantBits())
.setTenantIdLSB(tenantId.getId().getLeastSignificantBits())
.addAllKeys(toEvict.stream().map(ImageCacheKey::toProto).collect(Collectors.toList()))
.build())
.build());
}
}

4
application/src/main/java/org/thingsboard/server/service/resource/TbImageService.java

@ -25,7 +25,7 @@ public interface TbImageService {
TbResourceInfo save(TbResource image, User user) throws Exception;
TbResourceInfo save(TbResourceInfo imageInfo, User user);
TbResourceInfo save(TbResourceInfo imageInfo, TbResourceInfo oldImageInfo, User user);
TbImageDeleteResult delete(TbResourceInfo imageInfo, User user, boolean force);
@ -33,6 +33,6 @@ public interface TbImageService {
void putETag(ImageCacheKey imageCacheKey, String etag);
void evictETag(ImageCacheKey imageCacheKey);
void evictETags(ImageCacheKey imageCacheKey);
}

85
application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java

@ -22,8 +22,9 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder;
import org.springframework.mock.web.MockPart;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ImageDescriptor;
@ -64,7 +65,7 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUploadPngImage() throws Exception {
String filename = "my_png_image.png";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE);
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, false);
assertThat(imageInfo.getTitle()).isEqualTo(filename);
assertThat(imageInfo.getResourceType()).isEqualTo(ResourceType.IMAGE);
@ -83,7 +84,7 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUploadJpegImage() throws Exception {
String filename = "my_jpeg_image.jpg";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE);
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE, false);
ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class);
checkJpegImageDescriptor(imageDescriptor);
@ -95,7 +96,7 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUploadSvgImage() throws Exception {
String filename = "my_svg_image.svg";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/svg+xml", SVG_IMAGE);
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/svg+xml", SVG_IMAGE, false);
ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class);
checkSvgImageDescriptor(imageDescriptor);
@ -107,17 +108,17 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUploadImageWithSameFilename() throws Exception {
String filename = "my_jpeg_image.jpg";
TbResourceInfo imageInfo1 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE);
TbResourceInfo imageInfo1 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE, false);
assertThat(imageInfo1.getTitle()).isEqualTo(filename);
assertThat(imageInfo1.getFileName()).isEqualTo(filename);
assertThat(imageInfo1.getResourceKey()).isEqualTo(filename);
TbResourceInfo imageInfo2 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE);
TbResourceInfo imageInfo2 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE, false);
assertThat(imageInfo2.getTitle()).isEqualTo(filename);
assertThat(imageInfo2.getFileName()).isEqualTo(filename);
assertThat(imageInfo2.getResourceKey()).isEqualTo("my_jpeg_image_(1).jpg");
TbResourceInfo imageInfo3 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE);
TbResourceInfo imageInfo3 = uploadImage(HttpMethod.POST, "/api/image", filename, "image/jpeg", JPEG_IMAGE, false);
assertThat(imageInfo3.getTitle()).isEqualTo(filename);
assertThat(imageInfo3.getFileName()).isEqualTo(filename);
assertThat(imageInfo3.getResourceKey()).isEqualTo("my_jpeg_image_(2).jpg");
@ -126,11 +127,11 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUpdateImage() throws Exception {
String filename = "my_png_image.png";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE);
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, false);
checkPngImageDescriptor(imageInfo.getDescriptor(ImageDescriptor.class));
String newFilename = "my_jpeg_image.png";
imageInfo = uploadImage(HttpMethod.PUT, "/api/images/tenant/" + filename, newFilename, "image/jpeg", JPEG_IMAGE);
imageInfo = uploadImage(HttpMethod.PUT, "/api/images/tenant/" + filename, newFilename, "image/jpeg", JPEG_IMAGE, false);
assertThat(imageInfo.getTitle()).isEqualTo(filename);
assertThat(imageInfo.getResourceKey()).isEqualTo(filename);
@ -146,7 +147,7 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testUpdateImageInfo() throws Exception {
String filename = "my_png_image.png";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE);
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, false);
ImageDescriptor imageDescriptor = imageInfo.getDescriptor(ImageDescriptor.class);
assertThat(imageInfo.getTitle()).isEqualTo(filename);
@ -165,7 +166,7 @@ public class ImageControllerTest extends AbstractControllerTest {
@Test
public void testExportImportImage() throws Exception {
String filename = "my_png_image.png";
uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE);
uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, false);
ImageExportData exportData = doGet("/api/images/tenant/" + filename + "/export", ImageExportData.class);
assertThat(exportData.getMediaType()).isEqualTo("image/png");
@ -188,11 +189,11 @@ public class ImageControllerTest extends AbstractControllerTest {
public void testGetImages() throws Exception {
loginSysAdmin();
String systemImageName = "my_system_png_image.png";
TbResourceInfo systemImage = uploadImage(HttpMethod.POST, "/api/image", systemImageName, "image/png", PNG_IMAGE);
TbResourceInfo systemImage = uploadImage(HttpMethod.POST, "/api/image", systemImageName, "image/png", PNG_IMAGE, false);
loginTenantAdmin();
String tenantImageName = "my_jpeg_image.jpg";
TbResourceInfo tenantImage = uploadImage(HttpMethod.POST, "/api/image", tenantImageName, "image/jpeg", JPEG_IMAGE);
TbResourceInfo tenantImage = uploadImage(HttpMethod.POST, "/api/image", tenantImageName, "image/jpeg", JPEG_IMAGE, false);
List<TbResourceInfo> tenantImages = getImages(null, false, 10);
assertThat(tenantImages).containsOnly(tenantImage);
@ -206,6 +207,43 @@ public class ImageControllerTest extends AbstractControllerTest {
.containsOnly(tenantImage);
}
@Test
public void testUploadPublicImage() throws Exception {
String filename = "my_public_image.png";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, true);
assertThat(imageInfo.isPublic()).isTrue();
assertThat(imageInfo.getPublicResourceKey()).hasSize(32);
assertThat(imageInfo.getPublicLink()).isEqualTo("/api/images/public/" + imageInfo.getPublicResourceKey());
assertThat(downloadImage("tenant", filename)).containsExactly(PNG_IMAGE);
resetTokens();
assertThat(downloadPublicImage(imageInfo.getPublicResourceKey())).containsExactly(PNG_IMAGE);
}
@Test
public void testMakeImagePublic() throws Exception {
String filename = "my_public_image.png";
TbResourceInfo imageInfo = uploadImage(HttpMethod.POST, "/api/image", filename, "image/png", PNG_IMAGE, false);
String publicKey = imageInfo.getPublicResourceKey();
assertThat(publicKey).hasSize(32);
updateImagePublicStatus(filename, false);
doGet("/api/images/public/" + publicKey).andExpect(status().isNotFound());
updateImagePublicStatus(filename, true);
resetTokens();
assertThat(downloadPublicImage(publicKey)).containsExactly(PNG_IMAGE);
loginTenantAdmin();
updateImagePublicStatus(filename, false);
doGet("/api/images/public/" + publicKey).andExpect(status().isNotFound());
}
private TbResourceInfo updateImagePublicStatus(String filename, boolean isPublic) throws Exception {
return doPut("/api/images/tenant/" + filename + "/public/" + isPublic, "", TbResourceInfo.class);
}
private void checkPngImageDescriptor(ImageDescriptor imageDescriptor) {
assertThat(imageDescriptor.getMediaType()).isEqualTo("image/png");
assertThat(imageDescriptor.getWidth()).isEqualTo(200);
@ -242,13 +280,6 @@ public class ImageControllerTest extends AbstractControllerTest {
assertThat(imageDescriptor.getHeight()).isEqualTo(150);
assertThat(imageDescriptor.getSize()).isEqualTo(SVG_IMAGE.length);
assertThat(imageDescriptor.getEtag()).isEqualTo(Hashing.sha256().hashBytes(SVG_IMAGE).toString());
ImageDescriptor previewDescriptor = imageDescriptor.getPreviewDescriptor();
assertThat(previewDescriptor.getMediaType()).isEqualTo("image/png");
assertThat(previewDescriptor.getWidth()).isEqualTo(250);
assertThat(previewDescriptor.getHeight()).isEqualTo(250);
assertThat(previewDescriptor.getSize()).isEqualTo(13247);
assertThat(previewDescriptor.getEtag()).isEqualTo("f02db84f2c6ed7ea0606ae1145986a16a534cb0fd70436b3b5ca585569d48e46");
}
private List<TbResourceInfo> getImages(String searchText, boolean includeSystemImages, int limit) throws Exception {
@ -266,10 +297,18 @@ public class ImageControllerTest extends AbstractControllerTest {
.andReturn().getResponse().getContentAsByteArray();
}
private <R> TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content) throws Exception {
private byte[] downloadPublicImage(String publicKey) throws Exception {
return doGet("/api/images/public/" + publicKey).andExpect(status().isOk())
.andReturn().getResponse().getContentAsByteArray();
}
private <R> TbResourceInfo uploadImage(HttpMethod httpMethod, String url, String filename, String mediaType, byte[] content, boolean isPublic) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", filename, mediaType, content);
MockMultipartHttpServletRequestBuilder request = MockMvcRequestBuilders.multipart(httpMethod, url);
request.file(file);
MockPart publicPart = new MockPart("isPublic", String.valueOf(isPublic).getBytes());
publicPart.getHeaders().setContentType(MediaType.APPLICATION_JSON);
var request = MockMvcRequestBuilders.multipart(httpMethod, url)
.file(file)
.part(publicPart);
setJwtToken(request);
return readResponse(mockMvc.perform(request).andExpect(status().isOk()), TbResourceInfo.class);
}

2
common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java

@ -34,6 +34,8 @@ public interface ImageService {
TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key);
TbResourceInfo getPublicImageInfoByKey(String publicResourceKey);
PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, PageLink pageLink);
PageData<TbResourceInfo> getAllImagesByTenantId(TenantId tenantId, PageLink pageLink);

4
common/data/src/main/java/org/thingsboard/server/common/data/ImageExportData.java

@ -16,18 +16,22 @@
package org.thingsboard.server.common.data;
import io.swagger.annotations.ApiModel;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@ApiModel
@Slf4j
@Data
@Builder
public class ImageExportData {
private final String mediaType;
private final String fileName;
private final String title;
private final String resourceKey;
private final boolean isPublic;
private final String publicResourceKey;
private final String data;
}

15
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java

@ -51,6 +51,8 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
@Length(fieldName = "resourceKey")
@ApiModelProperty(position = 6, value = "Resource key.", example = "19_1.0", accessMode = ApiModelProperty.AccessMode.READ_ONLY)
private String resourceKey;
private boolean isPublic;
private String publicResourceKey;
@ApiModelProperty(position = 7, value = "Resource search text.", example = "19_1.0:binaryappdatacontainer", accessMode = ApiModelProperty.AccessMode.READ_ONLY)
private String searchText;
@ -79,6 +81,8 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
this.resourceType = resourceInfo.resourceType;
this.resourceKey = resourceInfo.resourceKey;
this.searchText = resourceInfo.searchText;
this.isPublic = resourceInfo.isPublic;
this.publicResourceKey = resourceInfo.publicResourceKey;
this.etag = resourceInfo.etag;
this.fileName = resourceInfo.fileName;
this.descriptor = resourceInfo.descriptor != null ? resourceInfo.descriptor.deepCopy() : null;
@ -109,7 +113,16 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public String getLink() {
if (resourceType == ResourceType.IMAGE) {
return "/api/images/" + ((tenantId == null || !tenantId.isSysTenantId()) ? "tenant" : "system") + "/" + resourceKey; // tenantId is null in case of export to git
String type = (tenantId != null && tenantId.isSysTenantId()) ? "system" : "tenant"; // tenantId is null in case of export to git
return "/api/images/" + type + "/" + resourceKey;
}
return null;
}
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public String getPublicLink() {
if (resourceType == ResourceType.IMAGE && isPublic) {
return "/api/images/public/" + getPublicResourceKey();
}
return null;
}

23
common/data/src/main/java/org/thingsboard/server/common/data/util/ThrowingSupplier.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.util;
@FunctionalInterface
public interface ThrowingSupplier<T> {
T get() throws Exception;
}

7
common/proto/src/main/proto/queue.proto

@ -309,7 +309,12 @@ message CoreStartupMsg {
message ResourceCacheInvalidateMsg {
int64 tenantIdMSB = 1;
int64 tenantIdLSB = 2;
string resourceKey = 3;
repeated ImageCacheKeyProto keys = 3;
}
message ImageCacheKeyProto {
optional string resourceKey = 1;
optional string publicResourceKey = 2;
}
message LwM2MRegistrationRequestMsg {

2
dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java

@ -501,6 +501,8 @@ public class ModelConstants {
public static final String RESOURCE_ETAG_COLUMN = "etag";
public static final String RESOURCE_DESCRIPTOR_COLUMN = "descriptor";
public static final String RESOURCE_PREVIEW_COLUMN = "preview";
public static final String RESOURCE_IS_PUBLIC_COLUMN = "is_public";
public static final String PUBLIC_RESOURCE_KEY_COLUMN = "public_resource_key";
/**
* Ota Package constants.

12
dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceEntity.java

@ -37,8 +37,10 @@ import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DATA_COLU
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_PREVIEW_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN;
@ -83,6 +85,12 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> {
@Column(name = RESOURCE_PREVIEW_COLUMN)
private byte[] preview;
@Column(name = RESOURCE_IS_PUBLIC_COLUMN)
private Boolean isPublic;
@Column(name = PUBLIC_RESOURCE_KEY_COLUMN, unique = true, updatable = false)
private String publicResourceKey;
@Column(name = EXTERNAL_ID_PROPERTY)
private UUID externalId;
@ -106,6 +114,8 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> {
this.etag = resource.getEtag();
this.descriptor = resource.getDescriptor();
this.preview = resource.getPreview();
this.isPublic = resource.isPublic();
this.publicResourceKey = resource.getPublicResourceKey();
this.externalId = getUuid(resource.getExternalId());
}
@ -123,6 +133,8 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> {
resource.setEtag(etag);
resource.setDescriptor(descriptor);
resource.setPreview(preview);
resource.setPublic(isPublic == null || isPublic);
resource.setPublicResourceKey(publicResourceKey);
resource.setExternalId(getEntityId(externalId, TbResourceId::new));
return resource;
}

12
dao/src/main/java/org/thingsboard/server/dao/model/sql/TbResourceInfoEntity.java

@ -37,7 +37,9 @@ import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPER
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_IS_PUBLIC_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.PUBLIC_RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN;
@ -76,6 +78,12 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
@Column(name = RESOURCE_DESCRIPTOR_COLUMN)
private JsonNode descriptor;
@Column(name = RESOURCE_IS_PUBLIC_COLUMN)
private Boolean isPublic;
@Column(name = PUBLIC_RESOURCE_KEY_COLUMN, unique = true, updatable = false)
private String publicResourceKey;
@Column(name = EXTERNAL_ID_PROPERTY)
private UUID externalId;
@ -95,6 +103,8 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
this.etag = resource.getEtag();
this.fileName = resource.getFileName();
this.descriptor = resource.getDescriptor();
this.isPublic = resource.isPublic();
this.publicResourceKey = resource.getPublicResourceKey();
this.externalId = getUuid(resource.getExternalId());
}
@ -110,6 +120,8 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
resource.setEtag(etag);
resource.setFileName(fileName);
resource.setDescriptor(descriptor);
resource.setPublic(isPublic == null || isPublic);
resource.setPublicResourceKey(publicResourceKey);
resource.setExternalId(getEntityId(externalId, TbResourceId::new));
return resource;
}

26
dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java

@ -23,6 +23,7 @@ import com.google.common.base.Strings;
import lombok.Data;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.lang3.tuple.Pair;
@ -149,6 +150,15 @@ public class BaseImageService extends BaseResourceService implements ImageServic
image.setDescriptorValue(descriptor);
image.setPreview(result.getRight());
if (image.getId() == null) {
if (StringUtils.isEmpty(image.getPublicResourceKey())) {
image.setPublicResourceKey(generatePublicResourceKey());
} else {
if (resourceInfoDao.existsByPublicResourceKey(ResourceType.IMAGE, image.getPublicResourceKey())) {
image.setPublicResourceKey(generatePublicResourceKey());
}
}
}
log.debug("[{}] Creating image {} ('{}')", image.getTenantId(), image.getResourceKey(), image.getName());
return new TbResourceInfo(doSaveResource(image));
}
@ -194,6 +204,10 @@ public class BaseImageService extends BaseResourceService implements ImageServic
return resourceKey;
}
private String generatePublicResourceKey() {
return RandomStringUtils.randomAlphanumeric(32);
}
@Override
public TbResourceInfo saveImageInfo(TbResourceInfo imageInfo) {
log.trace("Executing saveImageInfo [{}] [{}]", imageInfo.getTenantId(), imageInfo.getId());
@ -206,6 +220,11 @@ public class BaseImageService extends BaseResourceService implements ImageServic
return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key);
}
@Override
public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) {
return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey);
}
@Override
public PageData<TbResourceInfo> getImagesByTenantId(TenantId tenantId, PageLink pageLink) {
log.trace("Executing getImagesByTenantId [{}]", tenantId);
@ -452,6 +471,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic
image.setFileName(fileName);
image.setDescriptor(JacksonUtil.newObjectNode().put("mediaType", mdMediaType));
image.setData(imageData);
image.setPublic(true);
try {
imageInfo = saveImage(image);
} catch (Exception e) {
@ -602,7 +622,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic
try {
ImageCacheKey key = getKeyFromUrl(tenantId, url);
if (key != null) {
var imageInfo = getImageInfoByTenantIdAndKey(key.getTenantId(), key.getKey());
var imageInfo = getImageInfoByTenantIdAndKey(key.getTenantId(), key.getResourceKey());
if (imageInfo != null) {
byte[] data = key.isPreview() ? getImagePreview(tenantId, imageInfo.getId()) : getImageData(tenantId, imageInfo.getId());
ImageDescriptor descriptor = getImageDescriptor(imageInfo, key.isPreview());
@ -638,9 +658,9 @@ public class BaseImageService extends BaseResourceService implements ImageServic
if (imageTenantId != null) {
var parts = url.split("/");
if (parts.length == 5) {
return new ImageCacheKey(imageTenantId, parts[4], false);
return ImageCacheKey.forImage(imageTenantId, parts[4], false);
} else if (parts.length == 6 && "preview".equals(parts[5])) {
return new ImageCacheKey(imageTenantId, parts[4], true);
return ImageCacheKey.forImage(imageTenantId, parts[4], true);
}
}
return null;

36
dao/src/main/java/org/thingsboard/server/dao/resource/ImageCacheKey.java

@ -15,14 +15,48 @@
*/
package org.thingsboard.server.dao.resource;
import lombok.AccessLevel;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.With;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.transport.TransportProtos.ImageCacheKeyProto;
@Data
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class ImageCacheKey {
private final TenantId tenantId;
private final String key;
private final String resourceKey;
@With
private final boolean preview;
private final String publicResourceKey;
public static ImageCacheKey forImage(TenantId tenantId, String key, boolean preview) {
return new ImageCacheKey(tenantId, key, preview, null);
}
public static ImageCacheKey forImage(TenantId tenantId, String key) {
return forImage(tenantId, key, false);
}
public static ImageCacheKey forPublicImage(String publicKey) {
return new ImageCacheKey(null, null, false, publicKey);
}
public ImageCacheKeyProto toProto() {
var msg = ImageCacheKeyProto.newBuilder();
if (resourceKey != null) {
msg.setResourceKey(resourceKey);
} else {
msg.setPublicResourceKey(publicResourceKey);
}
return msg.build();
}
public boolean isPublic() {
return this.publicResourceKey != null;
}
}

5
dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java

@ -41,4 +41,9 @@ public interface TbResourceInfoDao extends Dao<TbResourceInfo> {
List<TbResourceInfo> findByTenantIdAndEtagAndKeyStartingWith(TenantId tenantId, String etag, String query);
TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, ResourceType resourceType, String etag);
boolean existsByPublicResourceKey(ResourceType resourceType, String publicResourceKey);
TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey);
}

10
dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceInfoDao.java

@ -109,4 +109,14 @@ public class JpaTbResourceInfoDao extends JpaAbstractDao<TbResourceInfoEntity, T
public TbResourceInfo findSystemOrTenantImageByEtag(TenantId tenantId, ResourceType resourceType, String etag) {
return DaoUtil.getData(resourceInfoRepository.findSystemOrTenantImageByEtag(tenantId.getId(), resourceType.name(), etag));
}
@Override
public boolean existsByPublicResourceKey(ResourceType resourceType, String publicResourceKey) {
return resourceInfoRepository.existsByResourceTypeAndPublicResourceKey(resourceType.name(), publicResourceKey);
}
@Override
public TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey) {
return DaoUtil.getData(resourceInfoRepository.findByResourceTypeAndPublicResourceKeyAndIsPublicTrue(resourceType.name(), publicResourceKey));
}
}

5
dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java

@ -70,4 +70,9 @@ public interface TbResourceInfoRepository extends JpaRepository<TbResourceInfoEn
TbResourceInfoEntity findSystemOrTenantImageByEtag(@Param("tenantId") UUID tenantId,
@Param("resourceType") String resourceType,
@Param("etag") String etag);
boolean existsByResourceTypeAndPublicResourceKey(String resourceType, String publicResourceKey);
TbResourceInfoEntity findByResourceTypeAndPublicResourceKeyAndIsPublicTrue(String resourceType, String publicResourceKey);
}

39
dao/src/main/java/org/thingsboard/server/dao/util/ImageUtils.java

@ -20,8 +20,10 @@ import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.With;
import lombok.extern.slf4j.Slf4j;
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
import org.apache.batik.bridge.BridgeContext;
@ -121,7 +123,7 @@ public class ImageUtils {
image.setHeight(bufferedImage.getHeight());
ProcessedImage preview = new ProcessedImage();
int[] thumbnailDimensions = getThumbnailDimensions(image.getWidth(), image.getHeight(), thumbnailMaxDimension);
int[] thumbnailDimensions = getThumbnailDimensions(image.getWidth(), image.getHeight(), thumbnailMaxDimension, true);
preview.setWidth(thumbnailDimensions[0]);
preview.setHeight(thumbnailDimensions[1]);
@ -191,8 +193,18 @@ public class ImageUtils {
image.setSize(data.length);
PNGTranscoder transcoder = new PNGTranscoder();
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_WIDTH, (float) thumbnailMaxDimension);
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_HEIGHT, (float) thumbnailMaxDimension);
if (image.getSize() < 10240) { // if SVG is smaller than 10kB (average 250x250 PNG preview size)
return withPreviewAsOriginalImage(image);
} else if (image.getSize() > 102400 && image.getWidth() != 0) { // considering SVG image detailed after 100kB
// increasing preview dimensions
thumbnailMaxDimension = 512;
int[] thumbnailDimensions = getThumbnailDimensions(image.getWidth(), image.getHeight(), thumbnailMaxDimension, false);
transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, (float) thumbnailDimensions[0]);
transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, (float) thumbnailDimensions[1]);
} else {
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_WIDTH, (float) thumbnailMaxDimension);
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_HEIGHT, (float) thumbnailMaxDimension);
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
transcoder.transcode(new TranscoderInput(new ByteArrayInputStream(data)), new TranscoderOutput(out));
byte[] pngThumbnail = out.toByteArray();
@ -214,18 +226,16 @@ public class ImageUtils {
image.setSize(data.length);
image.setWidth(0);
image.setHeight(0);
ProcessedImage preview = new ProcessedImage();
preview.setMediaType(mediaType);
preview.setData(null);
preview.setSize(data.length);
preview.setWidth(0);
preview.setHeight(0);
image.setPreview(preview);
return image;
return withPreviewAsOriginalImage(image);
}
public static ProcessedImage withPreviewAsOriginalImage(ProcessedImage originalImage) {
originalImage.setPreview(originalImage.withData(null));
return originalImage;
}
private static int[] getThumbnailDimensions(int originalWidth, int originalHeight, int maxDimension) {
if (originalWidth <= maxDimension && originalHeight <= maxDimension) {
private static int[] getThumbnailDimensions(int originalWidth, int originalHeight, int maxDimension, boolean originalIfSmaller) {
if (originalWidth <= maxDimension && originalHeight <= maxDimension && originalIfSmaller) {
return new int[]{originalWidth, originalHeight};
}
int thumbnailWidth;
@ -242,10 +252,13 @@ public class ImageUtils {
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ProcessedImage {
private String mediaType;
private int width;
private int height;
@With
private byte[] data;
private long size;
private ProcessedImage preview;

4
dao/src/main/resources/sql/schema-entities-idx.sql

@ -125,3 +125,7 @@ CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_created_time ON notific
CREATE INDEX IF NOT EXISTS idx_notification_recipient_id_unread ON notification(recipient_id) WHERE status <> 'READ';
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag);
CREATE INDEX IF NOT EXISTS idx_resource_etag ON resource(tenant_id, etag);
CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(resource_type, public_resource_key);

2
dao/src/main/resources/sql/schema-entities.sql

@ -718,6 +718,8 @@ CREATE TABLE IF NOT EXISTS resource (
etag varchar,
descriptor varchar,
preview bytea,
is_public boolean default true,
public_resource_key varchar(32) unique,
external_id uuid,
CONSTRAINT resource_unq_key UNIQUE (tenant_id, resource_type, resource_key)
);

7
ui-ngx/src/app/core/http/image.service.ts

@ -69,6 +69,13 @@ export class ImageService {
imageInfo, defaultHttpOptionsFromConfig(config));
}
public updateImagePublicStatus(imageInfo: ImageResourceInfo, isPublic: boolean, config?: RequestConfig): Observable<ImageResourceInfo> {
const type = imageResourceType(imageInfo);
const key = encodeURIComponent(imageInfo.resourceKey);
return this.http.put<ImageResourceInfo>(`${IMAGES_URL_PREFIX}/${type}/${key}/public/${isPublic}`,
imageInfo, defaultHttpOptionsFromConfig(config));
}
public getImages(pageLink: PageLink, includeSystemImages = false, config?: RequestConfig): Observable<PageData<ImageResourceInfo>> {
return this.http.get<PageData<ImageResourceInfo>>(
`${IMAGES_URL_PREFIX}${pageLink.toQuery()}&includeSystemImages=${includeSystemImages}`,

60
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html

@ -0,0 +1,60 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2>{{ 'image.embed-image' | translate }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<div class="tb-form-panel stroked tb-slide-toggle" *ngIf="!readonly || image.public">
<mat-expansion-panel class="tb-settings" [expanded]="image.public" disabled>
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<div *ngIf="!readonly" tb-hint-tooltip-icon="{{ 'image.embed-to-html-hint' | translate }}"
class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" [formControl]="publicStatusControl" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
<div class="tb-form-panel-title">{{ 'image.embed-to-html' | translate }}</div>
</mat-slide-toggle>
</div>
<div *ngIf="readonly" class="tb-form-panel-title" translate>image.embed-to-html</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-embed-image-text" [innerHTML]="'image.embed-to-html-text' | translate"></div>
<tb-markdown usePlainMarkdown
containerClass="tb-embed-image-code"
[data]="embedToHtmlCode()"></tb-markdown>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>image.embed-to-angular-template</div>
<div class="tb-embed-image-text" [innerHTML]="'image.embed-to-angular-template-text' | translate"></div>
<tb-markdown usePlainMarkdown
containerClass="tb-embed-image-code"
[data]="embedToAngularTemplateCode()"></tb-markdown>
</div>
</div>

71
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss

@ -0,0 +1,71 @@
/**
* 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.
*/
:host {
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
.tb-embed-image-text {
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: rgba(0,0,0,0.54);
letter-spacing: 0.2px;
}
.tb-form-panel-title {
color: rgba(0, 0, 0, 0.87);
}
}
}
:host ::ng-deep {
.tb-markdown-view {
max-width: 700px;
.tb-embed-image-code {
.code-wrapper {
padding: 0;
pre[class*=language-] {
margin: 0;
padding: 9px 38px 9px 16px;
}
code[class*="language-"], pre[class*="language-"] {
font-size: 12px;
overflow: hidden;
white-space: normal;
word-break: break-word;
}
button.clipboard-btn {
right: 0;
height: 36px;
p, div {
background: transparent;
}
p {
margin: 0;
padding: 6px;
font-size: 14px;
}
div {
top: 0;
padding: 8px;
height: 38px;
width: 38px;
}
}
}
}
}
}

94
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts

@ -0,0 +1,94 @@
///
/// 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.
///
import { ImageResourceInfo } from '@shared/models/resource.models';
import { Component, Inject, OnInit } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { ImageService } from '@core/http/image.service';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormControl, UntypedFormBuilder } from '@angular/forms';
export interface EmbedImageDialogData {
readonly: boolean;
image: ImageResourceInfo;
}
@Component({
selector: 'tb-embed-image-dialog',
templateUrl: './embed-image-dialog.component.html',
styleUrls: ['./embed-image-dialog.component.scss']
})
export class EmbedImageDialogComponent extends
DialogComponent<EmbedImageDialogComponent, ImageResourceInfo> implements OnInit {
image = this.data.image;
readonly = this.data.readonly;
imageChanged = false;
publicStatusControl = new FormControl(this.image.public);
constructor(protected store: Store<AppState>,
protected router: Router,
private imageService: ImageService,
@Inject(MAT_DIALOG_DATA) private data: EmbedImageDialogData,
public dialogRef: MatDialogRef<EmbedImageDialogComponent, ImageResourceInfo>,
public fb: UntypedFormBuilder) {
super(store, router, dialogRef);
}
ngOnInit(): void {
if (!this.readonly) {
this.publicStatusControl.valueChanges.subscribe(
(isPublic) => {
this.updateImagePublicStatus(isPublic);
}
);
}
}
cancel(): void {
this.dialogRef.close(this.imageChanged ? this.image : null);
}
embedToHtmlCode(): string {
return '```html\n' +
'<img src="'+this.image.publicLink+'" alt="'+this.image.title.replace(/"/g, '&quot;')+'" />' +
'{:copy-code}\n' +
'```';
}
embedToAngularTemplateCode(): string {
return '```html\n' +
'<img [src]="\''+this.image.link+'\' | image | async" />' +
'{:copy-code}\n' +
'```';
}
private updateImagePublicStatus(isPublic: boolean): void {
this.imageService.updateImagePublicStatus(this.image, isPublic).subscribe(
(image) => {
this.image = image;
this.imageChanged = true;
}
);
}
}

19
ui-ngx/src/app/shared/components/image/image-dialog.component.html

@ -56,18 +56,6 @@
<mat-icon>check</mat-icon>
</button>
</mat-form-field>
<mat-form-field>
<mat-label translate>image.link</mat-label>
<input matInput formControlName="link">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="imageFormGroup.get('link').value"
tooltipText="{{ 'image.copy-image-link' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</mat-form-field>
<div class="tb-image-container tb-primary-fill">
<div class="tb-image-content">
<div class="tb-image-actions">
@ -86,6 +74,13 @@
(click)="exportImage($event)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button type="button"
mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event)">
<mat-icon>code</mat-icon>
</button>
</div>
<button *ngIf="!readonly"
type="button"

33
ui-ngx/src/app/shared/components/image/image-dialog.component.ts

@ -30,6 +30,7 @@ import {
} from '@shared/components/image/upload-image-dialog.component';
import { UrlHolder } from '@shared/pipe/image.pipe';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component';
export interface ImageDialogData {
readonly: boolean;
@ -66,19 +67,16 @@ export class ImageDialogComponent extends
this.image = data.image;
this.readonly = data.readonly;
this.imagePreviewData = {
url: this.image.link
url: this.image.public ? this.image.publicLink : this.image.link
};
}
ngOnInit(): void {
this.imageFormGroup = this.fb.group({
title: [this.image.title, [Validators.required]],
link: [this.image.link, []],
title: [this.image.title, [Validators.required]]
});
if (this.data.readonly) {
this.imageFormGroup.disable();
} else {
this.imageFormGroup.get('link').disable();
}
}
@ -117,6 +115,29 @@ export class ImageDialogComponent extends
this.importExportService.exportImage(imageResourceType(this.image), this.image.resourceKey);
}
embedImage($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<EmbedImageDialogComponent, EmbedImageDialogData,
ImageResourceInfo>(EmbedImageDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
image: this.image,
readonly: this.readonly
}
}).afterClosed().subscribe((result) => {
if (result) {
this.imageChanged = true;
this.image = result;
this.imagePreviewData = {
url: this.image.public ? this.image.publicLink : this.image.link
};
}
});
}
updateImage($event): void {
if ($event) {
$event.stopPropagation();
@ -133,7 +154,7 @@ export class ImageDialogComponent extends
this.imageChanged = true;
this.image = result;
this.imagePreviewData = {
url: this.image.link
url: this.image.public ? this.image.publicLink : this.image.link
};
}
});

22
ui-ngx/src/app/shared/components/image/image-gallery.component.html

@ -187,9 +187,9 @@
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: '192px', maxWidth: '192px', width: '192px' }">
<mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: '240px', maxWidth: '240px', width: '240px' }">
</mat-header-cell>
<mat-cell *matCellDef="let image" [ngStyle.gt-md]="{ minWidth: '192px', maxWidth: '192px', width: '192px' }">
<mat-cell *matCellDef="let image" [ngStyle.gt-md]="{ minWidth: '240px', maxWidth: '240px', width: '240px' }">
<div fxHide fxShow.gt-md fxFlex fxLayout="row" fxLayoutAlign="end">
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.download-image' | translate }}"
@ -203,6 +203,12 @@
(click)="exportImage($event, image)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event, image)">
<mat-icon>code</mat-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ (readonly(image) ? 'image.image-details' : 'image.edit-image') | translate }}"
matTooltipPosition="above"
@ -235,6 +241,12 @@
<tb-icon matMenuItemIcon>mdi:file-export</tb-icon>
<span translate>image.export-image</span>
</button>
<button mat-menu-item
[disabled]="isLoading$ | async"
(click)="embedImage($event, image)">
<mat-icon>code</mat-icon>
<span>{{ 'image.embed-image' | translate }}</span>
</button>
<button mat-menu-item
[disabled]="isLoading$ | async"
(click)="editImage($event, image)">
@ -326,6 +338,12 @@
(click)="exportImage($event, item)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event, item, itemIndex)">
<mat-icon>code</mat-icon>
</button>
<button *ngIf="deleteEnabled(item)"
mat-icon-button [disabled]="(isLoading$ | async)"
matTooltip="{{ 'image.delete-image' | translate }}"

20
ui-ngx/src/app/shared/components/image/image-gallery.component.ts

@ -68,6 +68,7 @@ import {
ImagesInUseDialogData
} from '@shared/components/image/images-in-use-dialog.component';
import { ImagesDatasource } from '@shared/components/image/images-datasource';
import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component';
interface GridImagesFilter {
search: string;
@ -661,6 +662,25 @@ export class ImageGalleryComponent extends PageComponent implements OnInit, OnDe
});
}
embedImage($event: Event, image: ImageResourceInfo, itemIndex = -1) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<EmbedImageDialogComponent, EmbedImageDialogData,
ImageResourceInfo>(EmbedImageDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
image,
readonly: this.readonly(image)
}
}).afterClosed().subscribe((result) => {
if (result) {
this.imageUpdated(result, itemIndex);
}
});
}
protected updatedRouterParamsAndData(queryParams: object, queryParamsHandling: QueryParamsHandling = 'merge') {
if (this.pageMode) {
this.router.navigate([], {

7
ui-ngx/src/app/shared/models/resource.models.ts

@ -60,6 +60,8 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
title?: string;
resourceType: ResourceType;
fileName: string;
public: boolean;
publicResourceKey?: string;
descriptor?: D;
}
@ -81,6 +83,7 @@ export interface ImageDescriptor {
export interface ImageResourceInfo extends TbResourceInfo<ImageDescriptor> {
link?: string;
publicLink?: string;
}
export interface ImageExportData {
@ -88,6 +91,8 @@ export interface ImageExportData {
fileName: string;
title: string;
resourceKey: string;
public: boolean;
publicResourceKey: string;
data: string;
}
@ -129,6 +134,8 @@ export const TB_IMAGE_PREFIX = 'tb-image;';
export const IMAGES_URL_REGEXP = /\/api\/images\/(tenant|system)\/(.*)/;
export const IMAGES_URL_PREFIX = '/api/images';
export const PUBLIC_IMAGES_URL_PREFIX = '/api/images/public';
export const IMAGE_BASE64_URL_PREFIX = 'data:image/';
export const removeTbImagePrefix = (url: string): string => url ? url.replace(TB_IMAGE_PREFIX, '') : url;

7
ui-ngx/src/app/shared/shared.module.ts

@ -214,6 +214,7 @@ import { ImageReferencesComponent } from '@shared/components/image/image-referen
import { ImagesInUseDialogComponent } from '@shared/components/image/images-in-use-dialog.component';
import { GalleryImageInputComponent } from '@shared/components/image/gallery-image-input.component';
import { MultipleGalleryImageInputComponent } from '@shared/components/image/multiple-gallery-image-input.component';
import { EmbedImageDialogComponent } from '@shared/components/image/embed-image-dialog.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -408,7 +409,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ImageReferencesComponent,
ImagesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent
],
imports: [
CommonModule,
@ -657,7 +659,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ImageReferencesComponent,
ImagesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent
]
})
export class SharedModule { }

12
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2968,8 +2968,8 @@
"size": "Size",
"system": "System",
"download-image": "Download image",
"export-image": "Export image",
"import-image": "Import image",
"export-image": "Export image to JSON",
"import-image": "Import image from JSON",
"upload-image": "Upload image",
"edit-image": "Edit image",
"image-details": "Image details",
@ -3002,7 +3002,13 @@
"set-link": "Set link",
"image-link": "Image link",
"link": "Link",
"copy-image-link": "Copy image link"
"copy-image-link": "Copy image link",
"embed-image": "Embed image",
"embed-to-html": "Embed to HTML",
"embed-to-html-hint": "This feature will make link available to any unauthorized user.",
"embed-to-html-text": "Using the following code snippet, you may embed an image into the components based on the plain HTML.<br/>Such components include HTML card widgets, cell content functions, etc.",
"embed-to-angular-template": "Embed to Angular HTML template",
"embed-to-angular-template-text": "Using the following code snippet, you may embed an image into the Angular HTML template.<br/>Such components include the Markdown widget, HTML section in the widget editor, custom actions, etc."
},
"image-input": {
"drop-images-or": "Drag and drop an images or",

Loading…
Cancel
Save