Image descriptor, image preview, refactoring

This commit is contained in:
ViacheslavKlimov 2023-11-14 16:06:59 +02:00
parent d70ce27253
commit 19968b5c10
29 changed files with 444 additions and 289 deletions

View File

@ -27,6 +27,8 @@ $$
END IF;
END;
$$;
ALTER TABLE resource ADD COLUMN IF NOT EXISTS media_type varchar(255);
ALTER TABLE resource
ADD COLUMN IF NOT EXISTS descriptor varchar,
ADD COLUMN IF NOT EXISTS preview bytea;
-- RESOURCES UPDATE END

View File

@ -32,8 +32,13 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ImageDescriptor;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
@ -43,7 +48,9 @@ import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.resource.TbImageService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.function.Supplier;
@ -68,52 +75,75 @@ public class ImageController extends BaseController {
private static final String SYSTEM_IMAGE = "system";
private static final String TENANT_IMAGE = "tenant";
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PostMapping("/api/image")
public TbResourceInfo uploadImage(MultipartFile file) {
// imageService.saveImage()
return null;
public TbResourceInfo uploadImage(@RequestPart MultipartFile file) throws Exception {
SecurityUser user = getCurrentUser();
TbResource image = new TbResource();
image.setTenantId(user.getTenantId());
accessControlService.checkPermission(user, Resource.TB_RESOURCE, Operation.CREATE, null, image);
image.setFileName(file.getOriginalFilename());
image.setTitle(file.getOriginalFilename());
image.setResourceType(ResourceType.IMAGE);
ImageDescriptor descriptor = new ImageDescriptor();
descriptor.setMediaType(file.getContentType());
image.setDescriptor(JacksonUtil.valueToTree(descriptor));
image.setData(file.getBytes());
return tbImageService.save(image, user);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(IMAGE_URL)
public TbResourceInfo updateImage(MultipartFile file) {
return null;
public TbResourceInfo updateImage(@PathVariable String type,
@PathVariable String key,
@RequestPart MultipartFile file) throws Exception {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE);
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class);
TbResource image = new TbResource(imageInfo);
image.setData(file.getBytes());
descriptor.setMediaType(file.getContentType());
return tbImageService.save(image, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@PutMapping(IMAGE_URL + "/info")
public TbResourceInfo updateImageInfo(@RequestBody TbResourceInfo imageInfo) {
return null;
public TbResourceInfo updateImageInfo(@PathVariable String type,
@PathVariable String key,
@RequestBody TbResourceInfo newImageInfo) throws ThingsboardException {
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.WRITE);
imageInfo.setTitle(newImageInfo.getTitle());
return tbImageService.save(imageInfo, getCurrentUser());
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = IMAGE_URL, produces = "image/*")
public ResponseEntity<ByteArrayResource> downloadImage(@PathVariable String type,
@PathVariable String key,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException {
TenantId tenantId = getTenantId(type);
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
return downloadIfChanged(etag, imageInfo, () -> imageService.getImageData(tenantId, imageInfo.getId()), imageInfo.getMediaType());
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception {
TenantId tenantId = getTenantId();
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ);
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class);
return downloadIfChanged(etag, imageInfo, descriptor, () -> imageService.getImageData(tenantId, imageInfo.getId()));
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = IMAGE_URL + "/preview", produces = "image/png")
public ResponseEntity<ByteArrayResource> downloadImagePreview(@PathVariable String type,
@PathVariable String key,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException {
TenantId tenantId = getTenantId(type);
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
return downloadIfChanged(etag, imageInfo, () -> imageService.getImagePreview(tenantId, imageInfo.getId()), imageInfo.getMediaType());
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws Exception {
TenantId tenantId = getTenantId();
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.READ);
ImageDescriptor descriptor = imageInfo.getDescriptor(ImageDescriptor.class);
return downloadIfChanged(etag, imageInfo, descriptor.getPreviewDescriptor(), () -> imageService.getImagePreview(tenantId, imageInfo.getId()));
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(IMAGE_URL + "/info")
public TbResourceInfo getImageInfo(@PathVariable String type,
@PathVariable String key) throws ThingsboardException {
TenantId tenantId = getTenantId(type);
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
return checkEntity(getCurrentUser(), imageInfo, Operation.READ);
return checkImageInfo(type, key, Operation.READ);
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@ -128,6 +158,7 @@ public class ImageController extends BaseController {
@RequestParam(required = false) String sortProperty,
@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES)
@RequestParam(required = false) String sortOrder) throws ThingsboardException {
// PE: generic permission
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
TenantId tenantId = getTenantId();
if (getCurrentUser().getAuthority() == Authority.SYS_ADMIN) {
@ -141,17 +172,14 @@ public class ImageController extends BaseController {
@DeleteMapping(IMAGE_URL)
public void deleteImage(@PathVariable String type,
@PathVariable String key) throws ThingsboardException {
TenantId tenantId = getTenantId(type);
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
checkEntity(getCurrentUser(), imageInfo, Operation.DELETE);
TbResourceInfo imageInfo = checkImageInfo(type, key, Operation.DELETE);
tbImageService.delete(imageInfo, getCurrentUser());
}
private ResponseEntity<ByteArrayResource> downloadIfChanged(String etag, TbResourceInfo resourceInfo,
Supplier<byte[]> dataSupplier, String mediaType) throws ThingsboardException {
checkEntity(getCurrentUser(), resourceInfo, Operation.READ);
private ResponseEntity<ByteArrayResource> downloadIfChanged(String etag, TbResourceInfo imageInfo, ImageDescriptor imageDescriptor,
Supplier<byte[]> dataSupplier) {
if (etag != null) {
if (etag.equals(resourceInfo.getEtag())) {
if (etag.equals(imageInfo.getEtag())) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(etag)
.build();
@ -160,15 +188,22 @@ public class ImageController extends BaseController {
byte[] data = dataSupplier.get();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + resourceInfo.getFileName())
.header("x-filename", resourceInfo.getFileName())
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + imageInfo.getFileName())
.header("x-filename", imageInfo.getFileName())
.contentLength(data.length)
.header("Content-Type", mediaType)
.header("Content-Type", imageDescriptor.getMediaType())
.cacheControl(CacheControl.noCache())
.eTag(resourceInfo.getEtag())
.eTag(imageInfo.getEtag())
.body(new ByteArrayResource(data));
}
private TbResourceInfo checkImageInfo(String imageType, String key, Operation operation) throws ThingsboardException {
TenantId tenantId = getTenantId(imageType);
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
checkEntity(getCurrentUser(), checkNotNull(imageInfo), operation);
return imageInfo;
}
private TenantId getTenantId(String imageType) throws ThingsboardException {
TenantId tenantId;
if (imageType.equals(TENANT_IMAGE)) {

View File

@ -54,11 +54,10 @@ import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.Base64;
import java.util.Collections;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER;
import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION;
@ -110,17 +109,6 @@ public class TbResourceController extends BaseController {
.body(resource);
}
@ApiOperation(value = "Download Image (downloadImageIfChanged)", notes = DOWNLOAD_RESOURCE_IF_NOT_CHANGED + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/images/{resourceKey}", produces = "image/*")
public ResponseEntity<ByteArrayResource> downloadImageIfChanged(@PathVariable("resourceKey") String resourceKey,
@RequestHeader(name = HttpHeaders.IF_NONE_MATCH, required = false) String etag) throws ThingsboardException {
TenantId tenantId = getTenantId();
return downloadResourceIfChanged(ResourceType.IMAGE, etag,
() -> resourceService.findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, resourceKey),
() -> resourceService.findResourceByTenantIdAndKey(tenantId, ResourceType.IMAGE, resourceKey));
}
@ApiOperation(value = "Download LWM2M Resource (downloadLwm2mResourceIfChanged)", notes = DOWNLOAD_RESOURCE_IF_NOT_CHANGED + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@GetMapping(value = "/resource/lwm2m/{resourceId}/download", produces = "application/xml")
@ -291,19 +279,9 @@ public class TbResourceController extends BaseController {
private ResponseEntity<ByteArrayResource> downloadResourceIfChanged(ResourceType resourceType, String strResourceId, String etag) throws ThingsboardException {
checkParameter(RESOURCE_ID, strResourceId);
TenantId tenantId = getTenantId();
TbResourceId resourceId = new TbResourceId(toUUID(strResourceId));
return downloadResourceIfChanged(resourceType, etag,
() -> resourceService.findResourceInfoById(tenantId, resourceId),
() -> resourceService.findResourceById(tenantId, resourceId));
}
private ResponseEntity<ByteArrayResource> downloadResourceIfChanged(ResourceType resourceType, String etag,
Supplier<TbResourceInfo> resourceInfoSupplier,
Supplier<TbResource> resourceSupplier) throws ThingsboardException {
if (etag != null) {
TbResourceInfo tbResourceInfo = resourceInfoSupplier.get();
checkEntity(getCurrentUser(), tbResourceInfo, Operation.READ);
TbResourceInfo tbResourceInfo = checkResourceInfoId(resourceId, Operation.READ);
if (etag.equals(tbResourceInfo.getEtag())) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(tbResourceInfo.getEtag())
@ -311,16 +289,14 @@ public class TbResourceController extends BaseController {
}
}
// TODO: rate limits
TbResource tbResource = resourceSupplier.get();
checkEntity(getCurrentUser(), tbResource, Operation.READ);
TbResource tbResource = checkResourceId(resourceId, Operation.READ);
ByteArrayResource resource = new ByteArrayResource(tbResource.getData());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + tbResource.getFileName())
.header("x-filename", tbResource.getFileName())
.contentLength(resource.contentLength())
.header("Content-Type", tbResource.getMediaType() != null ? tbResource.getMediaType() : resourceType.getDefaultMediaType())
.header("Content-Type", resourceType.getMediaType())
.cacheControl(CacheControl.noCache())
.eTag(tbResource.getEtag())
.body(resource);

View File

@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.util.ImageUtils;
import org.thingsboard.server.dao.util.ImageUtils;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.resource.ImageService;
@ -38,7 +38,6 @@ import org.thingsboard.server.dao.resource.ImageService;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -199,41 +198,44 @@ public class ImagesUpdater {
return new ImageSaveResult(imageLink, !imageLink.equals(data));
}
@SneakyThrows
private String saveImage(TenantId tenantId, String name, String key, byte[] imageData, String mediaType,
String existingImageQuery) {
TbResourceInfo resourceInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
if (resourceInfo == null && !tenantId.isSysTenantId() && existingImageQuery != null) {
TbResourceInfo imageInfo = imageService.getImageInfoByTenantIdAndKey(tenantId, key);
if (imageInfo == null && !tenantId.isSysTenantId() && existingImageQuery != null) {
// TODO: need to search among tenant images too (custom widgets)
// List<TbResourceInfo> existingSystemImages = imageService.findByTenantIdAndDataAndKeyStartingWith(TenantId.SYS_TENANT_ID, imageData, existingImageQuery);
// if (!existingSystemImages.isEmpty()) {
// resourceInfo = existingSystemImages.get(0);
// imageInfo = existingSystemImages.get(0);
// if (existingSystemImages.size() > 1) {
// log.warn("Found more than one system image resources for key {}", existingImageQuery);
// }
// String link = imageService.getImageLink(resourceInfo);
// String link = imageService.getImageLink(imageInfo);
// log.info("Using system image {} for {}", link, key);
// return link;
// }
}
TbResource resource;
if (resourceInfo == null) {
resource = new TbResource();
resource.setTenantId(tenantId);
resource.setResourceType(ResourceType.IMAGE);
resource.setResourceKey(key);
TbResource image;
if (imageInfo == null) {
image = new TbResource();
image.setTenantId(tenantId);
image.setResourceType(ResourceType.IMAGE);
image.setResourceKey(key);
} else if (tenantId.isSysTenantId()) {
resource = new TbResource(resourceInfo);
image = new TbResource(imageInfo);
} else {
return imageService.getImageLink(resourceInfo);
return imageService.getImageLink(imageInfo);
}
resource.setTitle(name);
resource.setFileName(key);
resource.setMediaType(mediaType);
resource.setData(imageData);
resource = imageService.saveImage(resource);
log.info("[{}] {} image '{}' ({})", tenantId, resourceInfo == null ? "Created" : "Updated",
resource.getTitle(), resource.getResourceKey());
return imageService.getImageLink(resourceInfo);
image.setTitle(name);
image.setFileName(key);
image.setDescriptor(JacksonUtil.newObjectNode()
.put("mediaType", mediaType));
image.setData(imageData);
TbResourceInfo savedImage = imageService.saveImage(image);
log.info("[{}] {} image '{}' ({})", tenantId, imageInfo == null ? "Created" : "Updated",
image.getTitle(), image.getResourceKey());
return imageService.getImageLink(savedImage);
}
private String getText(JsonNode jsonNode, String field) {

View File

@ -24,7 +24,7 @@ import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.util.MediaTypeUtils;
import org.thingsboard.server.dao.util.ImageUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

View File

@ -17,29 +17,65 @@ package org.thingsboard.server.service.resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
@Service
@TbCoreComponent
@RequiredArgsConstructor
public class DefaultTbImageService implements TbImageService {
public class DefaultTbImageService extends AbstractTbEntityService implements TbImageService {
private final ImageService imageService;
@Override
public TbResource save(TbResourceInfo imageInfo, MultipartFile imageFile, User user) {
return null;
public TbResourceInfo save(TbResource image, User user) throws Exception {
ActionType actionType = image.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = image.getTenantId();
image.setResourceKey(image.getFileName()); // TODO: generate unique resource key file_name+idx
try {
TbResourceInfo savedImage = imageService.saveImage(image);
notificationEntityService.logEntityAction(tenantId, savedImage.getId(), savedImage, actionType, user);
return savedImage;
} catch (Exception e) {
image.setData(null);
notificationEntityService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), image, actionType, user, e);
throw e;
}
}
@Override
public TbResourceInfo save(TbResourceInfo imageInfo, User user) {
TenantId tenantId = imageInfo.getTenantId();
TbResourceId imageId = imageInfo.getId();
try {
imageInfo = imageService.saveImageInfo(imageInfo);
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user);
return imageInfo;
} catch (Exception e) {
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.UPDATED, user, e);
throw e;
}
}
@Override
public void delete(TbResourceInfo imageInfo, User user) {
TenantId tenantId = imageInfo.getTenantId();
TbResourceId imageId = imageInfo.getId();
try {
imageService.deleteImage(tenantId, imageId);
notificationEntityService.logEntityAction(tenantId, imageId, imageInfo, ActionType.DELETED, user, imageId.toString());
} catch (Exception e) {
notificationEntityService.logEntityAction(tenantId, imageId, ActionType.DELETED, user, e, imageId.toString());
throw e;
}
}
}

View File

@ -15,14 +15,15 @@
*/
package org.thingsboard.server.service.resource;
import org.springframework.web.multipart.MultipartFile;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User;
public interface TbImageService {
TbResource save(TbResourceInfo imageInfo, MultipartFile imageFile, User user);
TbResourceInfo save(TbResource image, User user) throws Exception;
TbResourceInfo save(TbResourceInfo imageInfo, User user);
void delete(TbResourceInfo imageInfo, User user);

View File

@ -620,10 +620,9 @@ public class TbResourceControllerTest extends AbstractControllerTest {
@Test
public void testUpdateResourceData_nonUpdatableResourceType() throws Exception {
TbResource resource = new TbResource();
resource.setResourceType(ResourceType.IMAGE);
resource.setResourceType(ResourceType.PKCS_12);
resource.setTitle("My resource");
resource.setFileName("image.png");
resource.setMediaType("image/png");
resource.setFileName("3.pks");
resource.setBase64Data(TEST_DATA);
TbResource savedResource = save(resource);
resource.setEtag(savedResource.getEtag());
@ -638,7 +637,6 @@ public class TbResourceControllerTest extends AbstractControllerTest {
savedResource = doPost("/api/resource", savedResource, TbResource.class);
assertThat(savedResource.getTitle()).isEqualTo("Updated resource");
assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName());
assertThat(savedResource.getMediaType()).isEqualTo(resource.getMediaType());
assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag());
assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA);
}
@ -665,54 +663,6 @@ public class TbResourceControllerTest extends AbstractControllerTest {
assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(newData);
}
@Test
public void testSaveImage_systemLevel() throws Exception {
loginSysAdmin();
TbResource resource = new TbResource();
resource.setResourceType(ResourceType.IMAGE);
resource.setTitle("System image");
resource.setFileName("image.png");
resource.setMediaType("image/png");
resource.setBase64Data(TEST_DATA);
TbResource savedResource = save(resource);
loginTenantAdmin();
MockHttpServletResponse imageResponse = doGet(getImageLink(savedResource)).andExpect(status().isOk())
.andReturn().getResponse();
assertThat(imageResponse.getContentAsByteArray())
.isEqualTo(download(savedResource.getId()))
.isEqualTo(Base64.getDecoder().decode(TEST_DATA));
assertThat(imageResponse.getContentType()).isEqualTo("image/png");
loginSysAdmin();
doDelete("/api/resource/" + savedResource.getId()).andExpect(status().isOk());
}
@Test
public void testSaveImage_tenantLevel() throws Exception {
loginTenantAdmin();
TbResource resource = new TbResource();
resource.setResourceType(ResourceType.IMAGE);
resource.setTitle("My image");
resource.setFileName("image.jpg");
resource.setMediaType("image/jpeg");
resource.setBase64Data(TEST_DATA);
TbResource savedResource = save(resource);
String imageLink = getImageLink(savedResource);
MockHttpServletResponse imageResponse = doGet(imageLink).andExpect(status().isOk())
.andReturn().getResponse();
assertThat(imageResponse.getContentAsByteArray())
.isEqualTo(download(savedResource.getId()))
.isEqualTo(Base64.getDecoder().decode(TEST_DATA));
assertThat(imageResponse.getContentType()).isEqualTo("image/jpeg");
loginDifferentTenant();
doGet(imageLink).andExpect(status().isNotFound());
}
private TbResource save(TbResource tbResource) throws Exception {
return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() {
});

View File

@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.page.PageLink;
public interface ImageService {
TbResource saveImage(TbResource image);
TbResourceInfo saveImage(TbResource image) throws Exception;
TbResourceInfo saveImageInfo(TbResourceInfo imageInfo);
@ -38,6 +38,8 @@ public interface ImageService {
byte[] getImagePreview(TenantId tenantId, TbResourceId imageId);
void deleteImage(TenantId tenantId, TbResourceId imageId);
String getImageLink(TbResourceInfo imageInfo);
}

View File

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ImageDescriptor {
private String mediaType;
private int width;
private int height;
private long size;
private ImageDescriptor previewDescriptor;
}

View File

@ -27,7 +27,7 @@ public enum ResourceType {
IMAGE(null, true, false);
@Getter
private final String defaultMediaType;
private final String mediaType;
@Getter
private final boolean customerAccess;
@Getter

View File

@ -37,6 +37,9 @@ public class TbResource extends TbResourceInfo {
@JsonIgnore
private byte[] data;
@JsonIgnore
private byte[] preview;
public TbResource() {
super();
}
@ -53,6 +56,7 @@ public class TbResource extends TbResourceInfo {
super(resource);
this.base64Data = resource.base64Data;
this.data = resource.data;
this.preview = resource.preview;
}
@JsonIgnore

View File

@ -16,6 +16,9 @@
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
@ -55,9 +58,8 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
@Length(fieldName = "file name")
@ApiModelProperty(position = 9, value = "Resource file name.", example = "19.xml", accessMode = ApiModelProperty.AccessMode.READ_ONLY)
private String fileName;
@ApiModelProperty(position = 10, value = "Resource media type.", example = "image/png", accessMode = ApiModelProperty.AccessMode.READ_ONLY)
private String mediaType;
private ObjectNode descriptor;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private JsonNode descriptor;
public TbResourceInfo() {
super();
@ -76,7 +78,6 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
this.searchText = resourceInfo.searchText;
this.etag = resourceInfo.etag;
this.fileName = resourceInfo.fileName;
this.mediaType = resourceInfo.mediaType;
this.descriptor = resourceInfo.descriptor;
}
@ -106,4 +107,9 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
return searchText != null ? searchText : title;
}
@JsonIgnore
public <T> T getDescriptor(Class<T> type) throws JsonProcessingException {
return descriptor != null ? mapper.treeToValue(descriptor, type) : null;
}
}

View File

@ -1,96 +0,0 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.util;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Map;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ImageUtils {
private static final Map<String, String> mediaTypeMappings = Map.of(
"jpeg", "jpg",
"svg+xml", "svg"
);
public static String mediaTypeToFileExtension(String mimeType) {
String subtype = MimeTypeUtils.parseMimeType(mimeType).getSubtype();
return mediaTypeMappings.getOrDefault(subtype, subtype);
}
public static String fileExtensionToMediaType(String type, String extension) {
String subtype = mediaTypeMappings.entrySet().stream()
.filter(mapping -> mapping.getValue().equals(extension))
.map(Map.Entry::getKey).findFirst().orElse(extension);
return new MimeType(type, subtype).toString();
}
public static ImageInfo processImage(byte[] imageData) throws IOException {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageData));
ImageThumbnail thumbnail = getImageThumbnail(image, 250);
return new ImageInfo(image.getWidth(), image.getHeight(), thumbnail);
}
private static ImageThumbnail getImageThumbnail(BufferedImage originalImage, int maxDimension) throws IOException {
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
int thumbnailWidth;
int thumbnailHeight;
if (originalWidth <= maxDimension && originalHeight <= maxDimension) {
thumbnailWidth = originalWidth;
thumbnailHeight = originalHeight;
} else {
double aspectRatio = (double) originalWidth / originalHeight;
if (originalWidth > originalHeight) {
thumbnailWidth = maxDimension;
thumbnailHeight = (int) (maxDimension / aspectRatio);
} else {
thumbnailWidth = (int) (maxDimension * aspectRatio);
thumbnailHeight = maxDimension;
}
}
BufferedImage thumbnail = new BufferedImage(thumbnailWidth, thumbnailHeight, BufferedImage.TYPE_INT_RGB);
thumbnail.getGraphics().drawImage(originalImage, 0, 0, thumbnailWidth, thumbnailHeight, null);
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(thumbnail, "ignored", os);
return new ImageThumbnail(thumbnail.getWidth(), thumbnail.getHeight(), os.toByteArray());
}
@Data
public static class ImageInfo {
private final int width;
private final int height;
private final ImageThumbnail thumbnail;
}
@Data
public static class ImageThumbnail {
private final int width;
private final int height;
private final byte[] data;
}
}

View File

@ -234,6 +234,14 @@
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-55</artifactId>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View File

@ -499,7 +499,8 @@ public class ModelConstants {
public static final String RESOURCE_FILE_NAME_COLUMN = "file_name";
public static final String RESOURCE_DATA_COLUMN = "data";
public static final String RESOURCE_ETAG_COLUMN = "etag";
public static final String RESOURCE_MEDIA_TYPE_COLUMN = "media_type";
public static final String RESOURCE_DESCRIPTOR_COLUMN = "descriptor";
public static final String RESOURCE_PREVIEW_COLUMN = "preview";
/**
* Ota Package constants.

View File

@ -15,14 +15,17 @@
*/
package org.thingsboard.server.dao.model.sql;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.TypeDef;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.model.BaseEntity;
import org.thingsboard.server.dao.model.BaseSqlEntity;
import org.thingsboard.server.dao.util.mapping.JsonStringType;
import javax.persistence.Column;
import javax.persistence.Entity;
@ -30,10 +33,11 @@ import javax.persistence.Table;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DATA_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_MEDIA_TYPE_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_PREVIEW_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN;
@ -43,8 +47,9 @@ import static org.thingsboard.server.dao.model.ModelConstants.SEARCH_TEXT_PROPER
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@TypeDef(name = "json", typeClass = JsonStringType.class)
@Table(name = RESOURCE_TABLE_NAME)
public class TbResourceEntity extends BaseSqlEntity<TbResource> implements BaseEntity<TbResource> {
public class TbResourceEntity extends BaseSqlEntity<TbResource> {
@Column(name = RESOURCE_TENANT_ID_COLUMN, columnDefinition = "uuid")
private UUID tenantId;
@ -70,8 +75,12 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> implements BaseE
@Column(name = RESOURCE_ETAG_COLUMN)
private String etag;
@Column(name = RESOURCE_MEDIA_TYPE_COLUMN)
private String mediaType;
@Type(type = "json")
@Column(name = RESOURCE_DESCRIPTOR_COLUMN)
private JsonNode descriptor;
@Column(name = RESOURCE_PREVIEW_COLUMN)
private byte[] preview;
public TbResourceEntity() {
}
@ -91,7 +100,8 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> implements BaseE
this.fileName = resource.getFileName();
this.data = resource.getData();
this.etag = resource.getEtag();
this.mediaType = resource.getMediaType();
this.descriptor = resource.getDescriptor();
this.preview = resource.getPreview();
}
@Override
@ -106,7 +116,8 @@ public class TbResourceEntity extends BaseSqlEntity<TbResource> implements BaseE
resource.setFileName(fileName);
resource.setData(data);
resource.setEtag(etag);
resource.setMediaType(mediaType);
resource.setDescriptor(descriptor);
resource.setPreview(preview);
return resource;
}

View File

@ -15,24 +15,28 @@
*/
package org.thingsboard.server.dao.model.sql;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.TypeDef;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.model.BaseEntity;
import org.thingsboard.server.dao.model.BaseSqlEntity;
import org.thingsboard.server.dao.util.mapping.JsonStringType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_DESCRIPTOR_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_ETAG_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_FILE_NAME_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_KEY_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_MEDIA_TYPE_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TENANT_ID_COLUMN;
import static org.thingsboard.server.dao.model.ModelConstants.RESOURCE_TITLE_COLUMN;
@ -42,6 +46,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.SEARCH_TEXT_PROPER
@Data
@EqualsAndHashCode(callSuper = true)
@Entity
@TypeDef(name = "json", typeClass = JsonStringType.class)
@Table(name = RESOURCE_TABLE_NAME)
public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implements BaseEntity<TbResourceInfo> {
@ -66,8 +71,9 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
@Column(name = RESOURCE_FILE_NAME_COLUMN)
private String fileName;
@Column(name = RESOURCE_MEDIA_TYPE_COLUMN)
private String mediaType;
@Type(type = "json")
@Column(name = RESOURCE_DESCRIPTOR_COLUMN)
private JsonNode descriptor;
public TbResourceInfoEntity() {
}
@ -84,7 +90,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
this.searchText = resource.getSearchText();
this.hashCode = resource.getEtag();
this.fileName = resource.getFileName();
this.mediaType = resource.getMediaType();
this.descriptor = resource.getDescriptor();
}
@Override
@ -98,7 +104,7 @@ public class TbResourceInfoEntity extends BaseSqlEntity<TbResourceInfo> implemen
resource.setSearchText(searchText);
resource.setEtag(hashCode);
resource.setFileName(fileName);
resource.setMediaType(mediaType);
resource.setDescriptor(descriptor);
return resource;
}
}

View File

@ -15,7 +15,11 @@
*/
package org.thingsboard.server.dao.resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ImageDescriptor;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
@ -25,10 +29,13 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
import org.thingsboard.server.dao.util.ImageUtils;
import org.thingsboard.server.dao.util.ImageUtils.ProcessedImage;
import java.util.Set;
@Service
@Slf4j
public class BaseImageService extends BaseResourceService implements ImageService {
public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator) {
@ -36,18 +43,39 @@ public class BaseImageService extends BaseResourceService implements ImageServic
}
@Override
public TbResource saveImage(TbResource image) {
public TbResourceInfo saveImage(TbResource image) throws Exception {
resourceValidator.validate(image, TbResourceInfo::getTenantId);
if (image.getData() != null) {
}
// generate preview, etc.
return saveResource(image, false);
ImageDescriptor descriptor = image.getDescriptor(ImageDescriptor.class);
Pair<ImageDescriptor, byte[]> result = processImage(image.getData(), descriptor);
image.setDescriptor(JacksonUtil.valueToTree(result.getLeft()));
image.setPreview(result.getRight());
image = saveResource(image, false);
return new TbResourceInfo(image);
}
private Pair<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());
ImageDescriptor previewDescriptor = new ImageDescriptor();
previewDescriptor.setWidth(preview.getWidth());
previewDescriptor.setHeight(preview.getHeight());
previewDescriptor.setMediaType(preview.getMediaType());
previewDescriptor.setSize(preview.getSize());
descriptor.setPreviewDescriptor(previewDescriptor);
return Pair.of(descriptor, preview.getData());
}
@Override
public TbResourceInfo saveImageInfo(TbResourceInfo imageInfo) {
return null;
return saveResource(new TbResource(imageInfo));
}
@Override
@ -80,7 +108,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic
@Override
public byte[] getImagePreview(TenantId tenantId, TbResourceId imageId) {
return new byte[0];
return resourceDao.getResourcePreview(tenantId, imageId);
}
@Override
public void deleteImage(TenantId tenantId, TbResourceId imageId) {
deleteResource(tenantId, imageId);
}
@Override

View File

@ -15,12 +15,12 @@
*/
package org.thingsboard.server.dao.resource;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.common.hash.Hashing;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey;
@ -46,7 +46,6 @@ import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID;
import static org.thingsboard.server.dao.service.Validator.validateId;
@ -54,6 +53,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId;
@Service("TbResourceDaoService")
@Slf4j
@AllArgsConstructor
@Primary
public class BaseResourceService extends AbstractCachedEntityService<ResourceInfoCacheKey, TbResourceInfo, ResourceInfoEvictEvent> implements ResourceService {
public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId ";

View File

@ -43,4 +43,6 @@ public interface TbResourceDao extends Dao<TbResource>, TenantEntityWithDataDao
byte[] getResourceData(TenantId tenantId, TbResourceId resourceId);
byte[] getResourcePreview(TenantId tenantId, TbResourceId resourceId);
}

View File

@ -17,8 +17,6 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.TbResource;
@ -94,19 +92,6 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
long maxSumResourcesDataInBytes = profileConfiguration.getMaxResourcesInBytes();
validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, resource.getData().length, TB_RESOURCE);
}
if (resource.getResourceType().getDefaultMediaType() != null) {
resource.setMediaType(resource.getResourceType().getDefaultMediaType());
} else {
if (resource.getMediaType() == null) {
throw new DataValidationException("Media type is required");
} else {
try {
MediaType.parseMediaType(resource.getMediaType());
} catch (InvalidMediaTypeException e) {
throw new DataValidationException("Invalid media type", e);
}
}
}
if (StringUtils.isEmpty(resource.getFileName())) {
throw new DataValidationException("Resource file name should be specified!");
}

View File

@ -99,6 +99,11 @@ public class JpaTbResourceDao extends JpaAbstractDao<TbResourceEntity, TbResourc
return resourceRepository.getDataById(resourceId.getId());
}
@Override
public byte[] getResourcePreview(TenantId tenantId, TbResourceId resourceId) {
return resourceRepository.getPreviewById(resourceId.getId());
}
@Override
public Long sumDataSizeByTenantId(TenantId tenantId) {
return resourceRepository.sumDataSizeByTenantId(tenantId.getId());

View File

@ -37,19 +37,19 @@ public interface TbResourceInfoRepository extends JpaRepository<TbResourceInfoEn
"WHERE sr.tenantId = :tenantId " +
"AND tr.resourceType = sr.resourceType " +
"AND tr.resourceKey = sr.resourceKey)))" +
"AND (:resourceTypes IS NULL OR tr.resourceType IN :resourceTypes)")
"AND tr.resourceType IN :resourceTypes")
Page<TbResourceInfoEntity> findAllTenantResourcesByTenantId(@Param("tenantId") UUID tenantId,
@Param("systemAdminId") UUID sysadminId,
@Param("resourceTypes") Collection<String> resourceTypes,
@Param("resourceTypes") List<String> resourceTypes,
@Param("searchText") String searchText,
Pageable pageable);
@Query("SELECT ri FROM TbResourceInfoEntity ri WHERE " +
"ri.tenantId = :tenantId " +
"AND (:resourceTypes IS NULL OR ri.resourceType IN :resourceTypes)" +
"AND ri.resourceType IN :resourceTypes " +
"AND (:searchText IS NULL OR ilike(ri.title, CONCAT('%', :searchText, '%')) = true)")
Page<TbResourceInfoEntity> findTenantResourcesByTenantId(@Param("tenantId") UUID tenantId,
@Param("resourceTypes") Collection<String> resourceTypes,
@Param("resourceTypes") List<String> resourceTypes,
@Param("searchText") String searchText,
Pageable pageable);

View File

@ -84,4 +84,7 @@ public interface TbResourceRepository extends JpaRepository<TbResourceEntity, UU
@Query("SELECT r.data FROM TbResourceEntity r WHERE r.id = :id")
byte[] getDataById(@Param("id") UUID id);
@Query(value = "SELECT COALESCE(preview, data) FROM resource WHERE id = :id", nativeQuery = true)
byte[] getPreviewById(@Param("id") UUID id);
}

View File

@ -0,0 +1,144 @@
/**
* 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.util;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.PNGTranscoder;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Map;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ImageUtils {
private static final Map<String, String> mediaTypeMappings = Map.of(
"jpeg", "jpg",
"svg+xml", "svg"
);
public static String mediaTypeToFileExtension(String mimeType) {
String subtype = MimeTypeUtils.parseMimeType(mimeType).getSubtype();
return mediaTypeMappings.getOrDefault(subtype, subtype);
}
public static String fileExtensionToMediaType(String type, String extension) {
String subtype = mediaTypeMappings.entrySet().stream()
.filter(mapping -> mapping.getValue().equals(extension))
.map(Map.Entry::getKey).findFirst().orElse(extension);
return new MimeType(type, subtype).toString();
}
public static ProcessedImage processImage(byte[] data, String mediaType, int thumbnailMaxDimension) throws Exception {
if (mediaTypeToFileExtension(mediaType).equals("svg")) {
return processSvgImage(data, thumbnailMaxDimension);
}
BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(data));
ProcessedImage image = new ProcessedImage();
image.setMediaType(mediaType);
image.setWidth(bufferedImage.getWidth());
image.setHeight(bufferedImage.getHeight());
image.setData(data);
image.setSize(data.length);
ProcessedImage preview = new ProcessedImage();
int[] thumbnailDimensions = getThumbnailDimensions(image.getWidth(), image.getHeight(), thumbnailMaxDimension);
preview.setWidth(thumbnailDimensions[0]);
preview.setHeight(thumbnailDimensions[1]);
if (preview.getWidth() == image.getWidth() && preview.getHeight() == image.getHeight()) {
if (mediaType.equals("image/png")) {
preview.setMediaType(mediaType);
preview.setData(null);
preview.setSize(data.length);
image.setPreview(preview);
return image;
}
}
BufferedImage thumbnail = new BufferedImage(preview.getWidth(), preview.getHeight(), BufferedImage.TYPE_INT_RGB);
thumbnail.getGraphics().drawImage(bufferedImage, 0, 0, preview.getWidth(), preview.getHeight(), null);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(thumbnail, "png", out);
preview.setMediaType("image/png");
preview.setData(out.toByteArray());
preview.setSize(preview.getData().length);
image.setPreview(preview);
return image;
}
public static ProcessedImage processSvgImage(byte[] data, int thumbnailMaxDimension) throws Exception {
ProcessedImage image = new ProcessedImage();
image.setWidth(0);
image.setHeight(0);
image.setData(data);
image.setSize(data.length);
PNGTranscoder transcoder = new PNGTranscoder();
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_WIDTH, (float) thumbnailMaxDimension);
transcoder.addTranscodingHint(PNGTranscoder.KEY_MAX_HEIGHT, (float) thumbnailMaxDimension);
ByteArrayOutputStream out = new ByteArrayOutputStream();
transcoder.transcode(new TranscoderInput(new ByteArrayInputStream(data)), new TranscoderOutput(out));
byte[] pngThumbnail = out.toByteArray();
ProcessedImage preview = new ProcessedImage();
preview.setWidth(thumbnailMaxDimension);
preview.setHeight(thumbnailMaxDimension);
preview.setMediaType("image/png");
preview.setData(pngThumbnail);
preview.setSize(pngThumbnail.length);
image.setPreview(preview);
return image;
}
private static int[] getThumbnailDimensions(int originalWidth, int originalHeight, int maxDimension) {
if (originalWidth <= maxDimension && originalHeight <= maxDimension) {
return new int[]{originalWidth, originalHeight};
}
int thumbnailWidth;
int thumbnailHeight;
double aspectRatio = (double) originalWidth / originalHeight;
if (originalWidth > originalHeight) {
thumbnailWidth = maxDimension;
thumbnailHeight = (int) (maxDimension / aspectRatio);
} else {
thumbnailWidth = (int) (maxDimension * aspectRatio);
thumbnailHeight = maxDimension;
}
return new int[]{thumbnailWidth, thumbnailHeight};
}
@Data
public static class ProcessedImage {
private String mediaType;
private int width;
private int height;
private byte[] data;
private long size;
private ProcessedImage preview;
}
}

View File

@ -716,7 +716,8 @@ CREATE TABLE IF NOT EXISTS resource (
file_name varchar(255) NOT NULL,
data bytea,
etag varchar,
media_type varchar(255),
descriptor varchar,
preview bytea,
CONSTRAINT resource_unq_key UNIQUE (tenant_id, resource_type, resource_key)
);

11
pom.xml
View File

@ -153,6 +153,7 @@
<slack-api.version>1.12.1</slack-api.version>
<oshi.version>6.4.2</oshi.version>
<google-oauth-client.version>1.34.1</google-oauth-client.version>
<apache-xmlgraphics.version>1.14</apache-xmlgraphics.version>
</properties>
<modules>
@ -2029,6 +2030,16 @@
<artifactId>google-oauth-client</artifactId>
<version>${google-oauth-client.version}</version>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>${apache-xmlgraphics.version}</version>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
<version>${apache-xmlgraphics.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@ -61,8 +61,6 @@ export interface ResourceInfo extends Omit<BaseData<TbResourceId>, 'name' | 'lab
title?: string;
resourceType: ResourceType;
fileName: string;
mediaType: string;
link: string;
}
export interface Resource extends ResourceInfo {