From b941047c99044af9d4ac91cab0a511c4003b0bb6 Mon Sep 17 00:00:00 2001 From: John Niang Date: Tue, 30 Sep 2025 19:09:15 +0800 Subject: [PATCH] Implement thumbnail cleanup and add width parameter support in reconciler --- .../core/attachment/ThumbnailProvider.java | 9 + .../app/core/attachment/ThumbnailSize.java | 8 + .../attachment/LocalThumbnailProvider.java | 43 --- .../attachment/LocalThumbnailService.java | 81 ----- .../core/attachment/ThumbnailGenerator.java | 237 -------------- .../impl/LocalThumbnailServiceImpl.java | 294 ------------------ .../reconciler/LocalThumbnailsReconciler.java | 188 +++-------- .../reconciler/ThumbnailReconciler.java | 42 +++ .../endpoint/theme/ThumbnailEndpoint.java | 13 +- .../attachment/ThumbnailGeneratorTest.java | 96 ------ .../impl/LocalThumbnailServiceImplTest.java | 141 --------- 11 files changed, 107 insertions(+), 1045 deletions(-) delete mode 100644 application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java delete mode 100644 application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java delete mode 100644 application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java delete mode 100644 application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java create mode 100644 application/src/main/java/run/halo/app/core/attachment/reconciler/ThumbnailReconciler.java delete mode 100644 application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java delete mode 100644 application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java diff --git a/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java b/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java index d28a20c66..622ec76fd 100644 --- a/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java +++ b/api/src/main/java/run/halo/app/core/attachment/ThumbnailProvider.java @@ -6,7 +6,16 @@ import lombok.Builder; import lombok.Data; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; +/** + * Thumbnail provider extension. + * + * @since 2.22.0 + * @deprecated Use {@link AttachmentHandler} instead. We are planing to remove this extension + * point in future release. + */ +@Deprecated(forRemoval = true, since = "2.22.0") public interface ThumbnailProvider extends ExtensionPoint { /** diff --git a/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java b/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java index 4f48a39c2..06bb87b14 100644 --- a/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java +++ b/api/src/main/java/run/halo/app/core/attachment/ThumbnailSize.java @@ -1,5 +1,6 @@ package run.halo.app.core.attachment; +import java.util.Arrays; import java.util.Optional; import lombok.Getter; @@ -50,4 +51,11 @@ public enum ThumbnailSize { } return Optional.empty(); } + + public static Integer[] allowedWidths() { + return Arrays.stream(ThumbnailSize.values()) + .map(ThumbnailSize::getWidth) + .toArray(Integer[]::new); + } + } diff --git a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java deleted file mode 100644 index 5cd976a97..000000000 --- a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailProvider.java +++ /dev/null @@ -1,43 +0,0 @@ -package run.halo.app.core.attachment; - -import java.net.URI; -import java.net.URL; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import reactor.core.publisher.Mono; -import run.halo.app.infra.ExternalUrlSupplier; - -@Component -@RequiredArgsConstructor -public class LocalThumbnailProvider implements ThumbnailProvider { - private final ExternalUrlSupplier externalUrlSupplier; - private final LocalThumbnailService localThumbnailService; - - @Override - public Mono generate(ThumbnailContext context) { - return localThumbnailService.create(context.getImageUrl(), context.getSize()) - .map(localThumbnail -> localThumbnail.getSpec().getThumbnailUri()) - .map(URI::create); - } - - @Override - public Mono delete(URL imageUrl) { - Assert.notNull(imageUrl, "Image URL must not be null"); - return localThumbnailService.delete(URI.create(imageUrl.toString())); - } - - @Override - public Mono supports(ThumbnailContext context) { - var imageUrl = context.getImageUrl(); - var externalUrl = externalUrlSupplier.getRaw(); - return Mono.fromSupplier(() -> externalUrl != null - && isSameOrigin(imageUrl, externalUrl)); - } - - private boolean isSameOrigin(URL imageUrl, URL externalUrl) { - return StringUtils.equals(imageUrl.getHost(), externalUrl.getHost()) - && imageUrl.getPort() == externalUrl.getPort(); - } -} diff --git a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java deleted file mode 100644 index a372446e8..000000000 --- a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java +++ /dev/null @@ -1,81 +0,0 @@ -package run.halo.app.core.attachment; - -import java.net.URI; -import java.net.URL; -import java.nio.file.Path; -import org.springframework.core.io.Resource; -import org.springframework.lang.NonNull; -import reactor.core.publisher.Mono; -import run.halo.app.core.attachment.extension.LocalThumbnail; -import run.halo.app.infra.ExternalLinkProcessor; -import run.halo.app.infra.exception.NotFoundException; - -public interface LocalThumbnailService { - - /** - * Gets original image URI for the given thumbnail URI. - * - * @param thumbnailUri The thumbnail URI string - * @return The original image URI, {@link NotFoundException} will be thrown if the thumbnail - * record does not exist by the given thumbnail URI - */ - Mono getOriginalImageUri(URI thumbnailUri); - - /** - *

Gets thumbnail file resource for the given year, size and filename.

- * {@link Mono#empty()} will be returned if the thumbnail file does not generate yet or the - * thumbnail record does not exist. - * - * @param thumbnailUri The thumbnail URI string - * @return The thumbnail file resource - */ - Mono getThumbnail(URI thumbnailUri); - - /** - *

Gets thumbnail file resource for the given URI and size.

- * {@link Mono#empty()} will be returned if the thumbnail file does not generate yet. - * - * @param originalImageUri original image URI to get thumbnail - * @param size thumbnail size - */ - Mono getThumbnail(URI originalImageUri, ThumbnailSize size); - - /** - * Generate thumbnail file for the given thumbnail. - * Do nothing if the thumbnail file already exists. - * - * @param thumbnail The thumbnail to generate. - * @return The generated thumbnail file resource. - */ - Mono generate(LocalThumbnail thumbnail); - - /** - * Creates a {@link LocalThumbnail} record for the given image URL and size. - * The thumbnail file will be generated asynchronously according to the thumbnail record. - * - * @param imageUrl original image URL - * @param size thumbnail size to generate - * @return The created thumbnail record. - */ - Mono create(URL imageUrl, ThumbnailSize size); - - /** - * Deletes the all size thumbnail files for the given image URI. - * If the image URI is not absolute, it will be processed by {@link ExternalLinkProcessor}. - * - * @param imageUri original image URI to delete thumbnails - * @return A {@link Mono} indicates the completion of the deletion. - */ - Mono delete(URI imageUri); - - /** - * Ensures the image URI is an url path if it's an in-site image. - * If it's not an in-site image, it will return directly. - */ - @NonNull - URI ensureInSiteUriIsRelative(URI imageUri); - - Path toFilePath(String thumbRelativeUnixPath); - - URI buildThumbnailUri(String year, ThumbnailSize size, String filename); -} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java deleted file mode 100644 index 30b2d7455..000000000 --- a/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java +++ /dev/null @@ -1,237 +0,0 @@ -package run.halo.app.core.attachment; - -import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; - -import com.drew.imaging.ImageMetadataReader; -import com.drew.metadata.Directory; -import com.drew.metadata.Metadata; -import com.drew.metadata.exif.ExifIFD0Directory; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Iterator; -import java.util.Optional; -import java.util.Set; -import javax.imageio.ImageIO; -import javax.imageio.ImageReader; -import javax.imageio.stream.ImageInputStream; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.imgscalr.Scalr; -import org.springframework.lang.NonNull; - -@Slf4j -@AllArgsConstructor -public class ThumbnailGenerator { - /** - * Max file size in bytes for downloading. - * 30MB - */ - static final int MAX_FILE_SIZE = 30 * 1024 * 1024; - - private static final Set UNSUPPORTED_FORMATS = Set.of("gif", "svg", "webp"); - - private final ImageDownloader imageDownloader = new ImageDownloader(); - private final ThumbnailSize size; - private final Path storePath; - - /** - * Generate thumbnail and save it to store path. - */ - public void generate(URL imageUrl) { - Path tempImagePath = null; - try { - tempImagePath = imageDownloader.downloadFile(imageUrl); - generateThumbnail(tempImagePath); - } catch (IOException e) { - throw new IllegalStateException(e); - } finally { - if (tempImagePath != null) { - try { - Files.deleteIfExists(tempImagePath); - } catch (IOException e) { - // Ignore - } - } - } - } - - /** - * Generate thumbnail by image file path. - */ - public void generate(Path imageFilePath) { - try { - generateThumbnail(imageFilePath); - } catch (IOException e) { - throw new IllegalStateException(e); - } - } - - private void generateThumbnail(Path tempImagePath) throws IOException { - var file = tempImagePath.toFile(); - if (file.length() > MAX_FILE_SIZE) { - throw new IOException("File size exceeds the limit: " + MAX_FILE_SIZE); - } - String formatName = getFormatName(file) - .orElseThrow(() -> new UnsupportedOperationException("Unknown format")); - if (isUnsupportedFormat(formatName)) { - throw new UnsupportedOperationException("Unsupported image format for: " + formatName); - } - - var img = ImageIO.read(file); - if (img == null) { - throw new UnsupportedOperationException("Cannot read image file: " + file); - } - var thumbnailFile = getThumbnailFile(formatName); - if (img.getWidth() <= size.getWidth()) { - Files.copy(tempImagePath, thumbnailFile.toPath(), REPLACE_EXISTING); - return; - } - var thumbnail = Scalr.resize(img, Scalr.Method.AUTOMATIC, Scalr.Mode.FIT_TO_WIDTH, - size.getWidth()); - // Rotate image if needed - var orientation = readExifOrientation(file); - if (orientation != null) { - thumbnail = Scalr.rotate(thumbnail, orientation); - } - ImageIO.write(thumbnail, formatName, thumbnailFile); - } - - private static Scalr.Rotation readExifOrientation(File inputFile) { - try { - Metadata metadata = ImageMetadataReader.readMetadata(inputFile); - Directory directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class); - if (directory != null && directory.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { - return getScalrRotationFromExifOrientation( - directory.getInt(ExifIFD0Directory.TAG_ORIENTATION)); - } - } catch (Exception e) { - log.debug("Failed to read EXIF orientation from file: {}", inputFile, e); - } - return null; - } - - private static Scalr.Rotation getScalrRotationFromExifOrientation(int orientation) { - // https://www.media.mit.edu/pia/Research/deepview/exif.html#:~:text=0x0112-,Orientation,-unsigned%20short - return switch (orientation) { - case 3 -> Scalr.Rotation.CW_180; - case 6 -> Scalr.Rotation.CW_90; - case 8 -> Scalr.Rotation.CW_270; - default -> null; - }; - } - - private static boolean isUnsupportedFormat(@NonNull String formatName) { - return UNSUPPORTED_FORMATS.contains(formatName.toLowerCase()); - } - - private File getThumbnailFile(String formatName) { - return Optional.of(storePath) - .map(path -> { - if (storePath.endsWith(formatName)) { - return storePath.resolve("." + formatName); - } - return storePath; - }) - .map(path -> { - if (!Files.exists(path.getParent())) { - try { - Files.createDirectories(path.getParent()); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - return path; - }) - .map(Path::toFile) - .orElseThrow(); - } - - /** - * Sanitize file name. - * - * @param fileName file name to sanitize - */ - public static String sanitizeFileName(String fileName) { - String sanitizedFileName = fileName.replaceAll("[^a-zA-Z0-9\\.\\-]", ""); - - if (sanitizedFileName.length() > 255) { - sanitizedFileName = sanitizedFileName.substring(0, 255); - } - - return sanitizedFileName; - } - - private static Optional getFormatName(File file) { - try { - return Optional.of(doGetFormatName(file)); - } catch (IOException e) { - // Ignore - } - return Optional.empty(); - } - - private static String doGetFormatName(File file) throws IOException { - try (ImageInputStream imageStream = ImageIO.createImageInputStream(file)) { - Iterator readers = ImageIO.getImageReaders(imageStream); - if (!readers.hasNext()) { - throw new IOException("No ImageReader found for the image."); - } - ImageReader reader = readers.next(); - return reader.getFormatName().toLowerCase(); - } - } - - static class ImageDownloader { - public Path downloadFile(URL url) throws IOException { - return downloadFileInternal(encodedUrl(url)); - } - - private static URL encodedUrl(URL url) { - try { - return new URL(url.toURI().toASCIIString()); - } catch (MalformedURLException | URISyntaxException e) { - throw new IllegalArgumentException(e); - } - } - - Path downloadFileInternal(URL url) throws IOException { - File tempFile = File.createTempFile("halo-image-thumb-", ".tmp"); - long totalBytesDownloaded = 0; - var tempFilePath = tempFile.toPath(); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestProperty("User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - + " Chrome/92.0.4515.131 Safari/537.36"); - try (InputStream inputStream = connection.getInputStream(); - FileOutputStream outputStream = new FileOutputStream(tempFile)) { - - byte[] buffer = new byte[4096]; - int bytesRead; - - while ((bytesRead = inputStream.read(buffer)) != -1) { - totalBytesDownloaded += bytesRead; - - if (totalBytesDownloaded > MAX_FILE_SIZE) { - outputStream.close(); - Files.deleteIfExists(tempFilePath); - throw new IOException("File size exceeds the limit: " + MAX_FILE_SIZE); - } - - outputStream.write(buffer, 0, bytesRead); - } - } catch (IOException e) { - Files.deleteIfExists(tempFilePath); - throw e; - } - return tempFile.toPath(); - } - } -} diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java deleted file mode 100644 index 3ed0ddf2b..000000000 --- a/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java +++ /dev/null @@ -1,294 +0,0 @@ -package run.halo.app.core.attachment.impl; - -import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; -import static org.apache.commons.lang3.StringUtils.defaultIfBlank; -import static org.apache.commons.lang3.StringUtils.removeStart; -import static org.apache.commons.lang3.StringUtils.substringAfterLast; -import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; -import static run.halo.app.extension.index.query.QueryFactory.and; -import static run.halo.app.extension.index.query.QueryFactory.equal; -import static run.halo.app.extension.index.query.QueryFactory.isNull; - -import java.net.URI; -import java.net.URL; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.concurrent.Callable; -import java.util.function.Consumer; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.data.domain.Sort; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import reactor.core.Exceptions; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; -import reactor.util.retry.Retry; -import run.halo.app.core.attachment.AttachmentRootGetter; -import run.halo.app.core.attachment.LocalThumbnailService; -import run.halo.app.core.attachment.ThumbnailGenerator; -import run.halo.app.core.attachment.ThumbnailSigner; -import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.attachment.extension.LocalThumbnail; -import run.halo.app.extension.ListOptions; -import run.halo.app.extension.ListResult; -import run.halo.app.extension.Metadata; -import run.halo.app.extension.PageRequestImpl; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.infra.exception.NotFoundException; - -@Slf4j -@Component -@RequiredArgsConstructor -public class LocalThumbnailServiceImpl implements LocalThumbnailService { - private final AttachmentRootGetter attachmentDirGetter; - private final ReactiveExtensionClient client; - private final ExternalUrlSupplier externalUrlSupplier; - - private static Path buildThumbnailStorePath(Path rootPath, String fileName, String year, - ThumbnailSize size) { - return rootPath - .resolve("thumbnails") - .resolve(year) - .resolve("w" + size.getWidth()) - .resolve(fileName); - } - - static String geImageFileName(URI imageUri) { - var fileName = substringAfterLast(imageUri.getPath(), "/"); - fileName = defaultIfBlank(fileName, randomAlphanumeric(10)); - return ThumbnailGenerator.sanitizeFileName(fileName); - } - - static String getYear() { - return String.valueOf(LocalDateTime.now().getYear()); - } - - @Override - public Mono getOriginalImageUri(URI thumbnailUri) { - return fetchThumbnail(thumbnailUri) - .map(local -> URI.create(local.getSpec().getImageUri())) - .switchIfEmpty(Mono.error(() -> new NotFoundException("Resource not found."))); - } - - @Override - public Mono getThumbnail(URI thumbnailUri) { - Assert.notNull(thumbnailUri, "Thumbnail URI must not be null."); - return fetchThumbnail(thumbnailUri) - .flatMap(thumbnail -> { - var filePath = toFilePath(thumbnail.getSpec().getFilePath()); - if (Files.exists(filePath)) { - return getResourceMono(() -> new FileSystemResource(filePath)); - } - return generate(thumbnail) - .then(Mono.empty()); - }); - } - - @Override - public Mono getThumbnail(URI originalImageUri, ThumbnailSize size) { - var imageHash = signatureForImageUri(originalImageUri); - return fetchByImageHashAndSize(imageHash, size) - .flatMap(this::generate); - } - - private Mono fetchThumbnail(URI thumbnailUri) { - Assert.notNull(thumbnailUri, "Thumbnail URI must not be null."); - var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri); - return client.listBy(LocalThumbnail.class, ListOptions.builder() - .fieldQuery(equal("spec.thumbSignature", thumbSignature)) - .build(), PageRequestImpl.ofSize(1)) - .flatMap(result -> Mono.justOrEmpty(ListResult.first(result))); - } - - private Mono fetchByImageHashAndSize(String imageSignature, - ThumbnailSize size) { - var indexValue = LocalThumbnail.uniqueImageAndSize(imageSignature, size); - return client.listBy(LocalThumbnail.class, ListOptions.builder() - .fieldQuery(equal(LocalThumbnail.UNIQUE_IMAGE_AND_SIZE_INDEX, indexValue)) - .build(), PageRequestImpl.ofSize(ThumbnailSize.values().length) - ) - .flatMapMany(result -> Flux.fromIterable(result.getItems())) - .next(); - } - - @Override - public Mono generate(LocalThumbnail thumbnail) { - Assert.notNull(thumbnail, "Thumbnail must not be null."); - var filePath = toFilePath(thumbnail.getSpec().getFilePath()); - if (Files.exists(filePath)) { - return getResourceMono(() -> new FileSystemResource(filePath)); - } - return updateWithRetry(thumbnail, - record -> nullSafeAnnotations(record) - .put(LocalThumbnail.REQUEST_TO_GENERATE_ANNO, "true")) - .then(Mono.empty()); - } - - private static Mono getResourceMono(Callable callable) { - return Mono.fromCallable(callable) - .subscribeOn(Schedulers.boundedElastic()); - } - - private Mono updateWithRetry(LocalThumbnail localThumbnail, Consumer op) { - op.accept(localThumbnail); - return client.update(localThumbnail) - .onErrorResume(OptimisticLockingFailureException.class, - e -> client.fetch(LocalThumbnail.class, localThumbnail.getMetadata().getName()) - .flatMap(latest -> { - op.accept(latest); - return client.update(latest); - }) - .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) - .filter(OptimisticLockingFailureException.class::isInstance)) - ) - .then(); - } - - @Override - public Mono create(URL imageUrl, ThumbnailSize size) { - Assert.notNull(imageUrl, "Image URL must not be null."); - Assert.notNull(size, "Thumbnail size must not be null."); - var imageUri = URI.create(imageUrl.toString()); - var imageHash = signatureForImageUri(imageUri); - return fetchByImageHashAndSize(imageHash, size) - .switchIfEmpty(Mono.defer(() -> doCreate(imageUri, size))); - } - - private Mono doCreate(URI imageUri, ThumbnailSize size) { - var year = getYear(); - var originalFileName = geImageFileName(imageUri); - return generateUniqueThumbFileName(originalFileName, year, size) - .flatMap(thumbFileName -> { - var filePath = - buildThumbnailStorePath(attachmentDirGetter.get(), thumbFileName, year, size); - var thumbnail = new LocalThumbnail(); - thumbnail.setMetadata(new Metadata()); - thumbnail.getMetadata().setGenerateName("thumbnail-"); - var thumbnailUri = buildThumbnailUri(year, size, thumbFileName); - var thumbSignature = ThumbnailSigner.generateSignature(thumbnailUri); - thumbnail.setSpec(new LocalThumbnail.Spec() - .setImageSignature(signatureForImageUri(imageUri)) - .setFilePath(toRelativeUnixPath(filePath)) - .setImageUri(ensureInSiteUriIsRelative(imageUri).toASCIIString()) - .setSize(size) - .setThumbSignature(thumbSignature) - .setThumbnailUri(thumbnailUri.toASCIIString())); - return client.create(thumbnail); - }); - } - - @Override - public Mono delete(URI imageUri) { - var signature = signatureForImageUri(imageUri); - return client.listAll(LocalThumbnail.class, ListOptions.builder() - .fieldQuery(and( - equal("spec.imageSignature", signature), - isNull("metadata.deletionTimestamp")) - ) - .build(), Sort.unsorted()) - .flatMap(thumbnail -> { - var filePath = toFilePath(thumbnail.getSpec().getFilePath()); - return deleteFile(filePath) - .then(client.delete(thumbnail)); - }) - .then(); - } - - @Override - @NonNull - public URI ensureInSiteUriIsRelative(URI imageUri) { - Assert.notNull(imageUri, "Image URI must not be null."); - var externalUrl = externalUrlSupplier.getRaw(); - if (externalUrl == null || !isSameOrigin(imageUri, externalUrl)) { - return imageUri; - } - var uriStr = imageUri.toString().replaceFirst("^\\w+://", ""); - uriStr = StringUtils.removeStart(uriStr, imageUri.getAuthority()); - return URI.create(uriStr); - } - - Mono generateUniqueThumbFileName(String originalFileName, String year, - ThumbnailSize size) { - Assert.notNull(originalFileName, "Original file name must not be null."); - return generateUniqueThumbFileName(originalFileName, originalFileName, year, size); - } - - private Mono generateUniqueThumbFileName(String originalFileName, String tryFileName, - String year, ThumbnailSize size) { - var thumbnailUri = buildThumbnailUri(year, size, tryFileName); - return fetchThumbnail(thumbnailUri) - .flatMap(thumbnail -> { - // use the original file name to generate a new file name - var newTryFileName = appendRandomSuffix(originalFileName); - return generateUniqueThumbFileName(originalFileName, newTryFileName, year, size); - }) - .switchIfEmpty(Mono.just(tryFileName)); - } - - @Override - public Path toFilePath(String relativeUnixPath) { - Assert.notNull(relativeUnixPath, "Relative path must not be null."); - var systemPath = removeStart(relativeUnixPath, "/") - .replace("/", FileSystems.getDefault().getSeparator()); - return attachmentDirGetter.get().resolve(systemPath); - } - - @Override - public URI buildThumbnailUri(String year, ThumbnailSize size, String filename) { - return URI.create("/upload/thumbnails/%s/w%s/%s".formatted(year, size.getWidth(), - filename)); - } - - private String toRelativeUnixPath(Path filePath) { - var dir = attachmentDirGetter.get().toString(); - var relativePath = removeStart(filePath.toString(), dir); - return relativePath.replace("\\", "/"); - } - - private Mono deleteFile(Path path) { - return Mono.fromRunnable( - () -> { - try { - Files.deleteIfExists(path); - } catch (Exception e) { - throw Exceptions.propagate(e); - } - }) - .subscribeOn(Schedulers.boundedElastic()) - .then(); - } - - /** - * Generate signature for the given image URI. - *

if externalUrl is not configured, it will return the signature generated by the image URI - * directly, otherwise, it will return the signature generated by the relative path of the - * image URL to the external URL.

- */ - String signatureForImageUri(URI imageUri) { - var uriToSign = ensureInSiteUriIsRelative(imageUri); - return ThumbnailSigner.generateSignature(uriToSign); - } - - private boolean isSameOrigin(URI imageUri, URL externalUrl) { - return StringUtils.equals(imageUri.getHost(), externalUrl.getHost()) - && imageUri.getPort() == externalUrl.getPort(); - } - - static String appendRandomSuffix(String fileName) { - var baseName = StringUtils.substringBeforeLast(fileName, "."); - var extension = substringAfterLast(fileName, "."); - var randomSuffix = randomAlphanumeric(6); - return String.format("%s_%s.%s", baseName, randomSuffix, extension); - } -} diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java index 2623dfbfc..5950adff5 100644 --- a/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java @@ -1,187 +1,73 @@ package run.halo.app.core.attachment.reconciler; -import static org.springframework.data.domain.Sort.Order.desc; -import static run.halo.app.core.attachment.extension.LocalThumbnail.REQUEST_TO_GENERATE_ANNO; -import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; -import static run.halo.app.extension.index.query.QueryFactory.and; -import static run.halo.app.extension.index.query.QueryFactory.equal; -import static run.halo.app.extension.index.query.QueryFactory.isNull; -import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; +import static run.halo.app.extension.ExtensionUtil.addFinalizers; +import static run.halo.app.extension.ExtensionUtil.removeFinalizers; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; +import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import run.halo.app.core.attachment.AttachmentRootGetter; -import run.halo.app.core.attachment.AttachmentUtils; -import run.halo.app.core.attachment.LocalThumbnailService; -import run.halo.app.core.attachment.ThumbnailGenerator; import run.halo.app.core.attachment.extension.LocalThumbnail; -import run.halo.app.core.extension.attachment.Attachment; -import run.halo.app.core.extension.attachment.Constant; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; -import run.halo.app.extension.ListOptions; -import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; -import run.halo.app.infra.ExternalLinkProcessor; @Slf4j -// @Component +@Component @RequiredArgsConstructor -public class LocalThumbnailsReconciler implements Reconciler { - private final LocalThumbnailService localThumbnailService; +class LocalThumbnailsReconciler implements Reconciler { + + private static final String CLEAN_UP_FINALIZER = "thumbnail-cleaner"; + private final ExtensionClient client; - private final ExternalLinkProcessor externalLinkProcessor; - private final AttachmentRootGetter attachmentRootGetter; + + private final AttachmentRootGetter attachmentRoot; @Override public Result reconcile(Request request) { client.fetch(LocalThumbnail.class, request.name()) .ifPresent(thumbnail -> { if (ExtensionUtil.isDeleted(thumbnail)) { + if (removeFinalizers(thumbnail.getMetadata(), Set.of(CLEAN_UP_FINALIZER))) { + // clean up thumbnail file + cleanUpThumbnailFile(thumbnail); + client.update(thumbnail); + } return; } - if (shouldGenerate(thumbnail)) { - requestGenerateThumbnail(thumbnail); - nullSafeAnnotations(thumbnail).remove(REQUEST_TO_GENERATE_ANNO); - client.update(thumbnail); - } + // Cleanup all existing local thumbnails + addFinalizers(thumbnail.getMetadata(), Set.of(CLEAN_UP_FINALIZER)); + log.info("Cleaning up local thumbnail: {}", thumbnail.getMetadata().getName()); + client.delete(thumbnail); }); return Result.doNotRetry(); } - private boolean shouldGenerate(LocalThumbnail thumbnail) { - var annotations = nullSafeAnnotations(thumbnail); - return annotations.containsKey(REQUEST_TO_GENERATE_ANNO) - || thumbnailFileNotExists(thumbnail); - } - - private boolean thumbnailFileNotExists(LocalThumbnail thumbnail) { - var filePath = localThumbnailService.toFilePath(thumbnail.getSpec().getFilePath()); - return !Files.exists(filePath); - } - - void requestGenerateThumbnail(LocalThumbnail thumbnail) { - // If the thumbnail generation has failed, we should not retry it - if (isGenerationFailed(thumbnail)) { - return; - } - var imageUri = thumbnail.getSpec().getImageUri(); - var filePath = localThumbnailService.toFilePath(thumbnail.getSpec().getFilePath()); - if (Files.exists(filePath)) { - return; - } - var generator = new ThumbnailGenerator(thumbnail.getSpec().getSize(), filePath); - var imageUrlOpt = toImageUrl(imageUri); - if (imageUrlOpt.isEmpty()) { - if (tryGenerateByAttachment(imageUri, generator)) { - thumbnail.getStatus().setPhase(LocalThumbnail.Phase.SUCCEEDED); - } else { - log.debug("Failed to parse image URL,please check external-url configuration for " - + "record: {}", thumbnail.getMetadata().getName()); - thumbnail.getStatus().setPhase(LocalThumbnail.Phase.FAILED); - } - return; - } - var imageUrl = imageUrlOpt.get(); - if (generateThumbnail(thumbnail, imageUrl, generator)) { - thumbnail.getStatus().setPhase(LocalThumbnail.Phase.SUCCEEDED); - } else { - thumbnail.getStatus().setPhase(LocalThumbnail.Phase.FAILED); - } - } - - private boolean isGenerationFailed(LocalThumbnail thumbnail) { - return LocalThumbnail.Phase.FAILED.equals(thumbnail.getStatus().getPhase()); - } - - private boolean generateThumbnail(LocalThumbnail thumbnail, URL imageUrl, - ThumbnailGenerator generator) { - return tryGenerateByAttachment(thumbnail.getSpec().getImageUri(), generator) - || tryGenerateByUrl(imageUrl, generator); - } - - private boolean tryGenerate(String resourceIdentifier, Runnable generateAction) { - try { - generateAction.run(); - return true; - } catch (Throwable e) { - log.debug("Failed to generate thumbnail for: {}", resourceIdentifier, e); - return false; - } - } - - private boolean tryGenerateByUrl(URL imageUrl, ThumbnailGenerator generator) { - return tryGenerate(imageUrl.toString(), () -> { - log.debug("Generating thumbnail for image URL: {}", imageUrl); - generator.generate(imageUrl); - }); - } - - private boolean tryGenerateByAttachment(String imageUri, ThumbnailGenerator generator) { - return fetchAttachmentFilePath(imageUri) - .map(path -> tryGenerate(imageUri, () -> { - log.debug("Generating thumbnail for attachment file path: {}", path); - generator.generate(path); - })) - .orElse(false); - } - - Optional toImageUrl(String imageUriStr) { - var imageUri = URI.create(imageUriStr); - try { - var url = new URL(externalLinkProcessor.processLink(imageUri.toString())); - return Optional.of(url); - } catch (MalformedURLException e) { - // Ignore - } - return Optional.empty(); - } - - Optional fetchAttachmentFilePath(String imageUri) { - return fetchAttachmentByPermalink(imageUri) - .filter(AttachmentUtils::isImage) - .map(attachment -> { - var annotations = nullSafeAnnotations(attachment); - var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY); - if (StringUtils.isBlank(localRelativePath)) { - return null; - } - var attachmentsRoot = attachmentRootGetter.get(); - var filePath = attachmentsRoot.resolve(localRelativePath); - checkDirectoryTraversal(attachmentsRoot, filePath); - return filePath; - }); - } - - Optional fetchAttachmentByPermalink(String permalink) { - var listOptions = ListOptions.builder() - .fieldQuery(and( - equal("status.permalink", permalink), - isNull("metadata.deletionTimestamp") - )) - .build(); - var pageRequest = PageRequestImpl.ofSize(1) - .withSort(Sort.by(desc("metadata.creationTimestamp"))); - return client.listBy(Attachment.class, listOptions, pageRequest) - .get() - .findFirst(); - } - @Override public Controller setupWith(ControllerBuilder builder) { return builder .extension(new LocalThumbnail()) - .syncAllOnStart(true) .build(); } + + private void cleanUpThumbnailFile(LocalThumbnail thumbnail) { + var filePath = thumbnail.getSpec().getFilePath(); + if (StringUtils.hasText(filePath)) { + var thumbnailFile = attachmentRoot.get().resolve(filePath); + try { + if (Files.deleteIfExists(thumbnailFile)) { + log.info("Deleted thumbnail file: {} for {}", + thumbnailFile, thumbnail.getMetadata().getName()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } } diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/ThumbnailReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/ThumbnailReconciler.java new file mode 100644 index 000000000..8483d88dd --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/ThumbnailReconciler.java @@ -0,0 +1,42 @@ +package run.halo.app.core.attachment.reconciler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import run.halo.app.core.attachment.extension.Thumbnail; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; + +@Slf4j +@Component +class ThumbnailReconciler implements Reconciler { + + private final ExtensionClient client; + + ThumbnailReconciler(ExtensionClient client) { + this.client = client; + } + + @Override + public Result reconcile(Request request) { + client.fetch(Thumbnail.class, request.name()) + .ifPresent(thumbnail -> { + if (ExtensionUtil.isDeleted(thumbnail)) { + return; + } + log.info("Clean up thumbnail: {}", thumbnail.getMetadata().getName()); + client.delete(thumbnail); + }); + return null; + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Thumbnail()) + .build(); + } + +} diff --git a/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java index cd43783b6..3e75fbaff 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java @@ -5,8 +5,10 @@ import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.net.URI; +import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; @@ -52,6 +54,7 @@ public class ThumbnailEndpoint implements CustomEndpoint { .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("size") + .implementation(ThumbnailSize.class) .description("The size of the thumbnail") .required(true) @@ -59,13 +62,19 @@ public class ThumbnailEndpoint implements CustomEndpoint { .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name("width") + .schema(Builder.schemaBuilder() + .type("integer") + .allowableValues(Arrays.stream(ThumbnailSize.allowedWidths()) + .map(String::valueOf) + .toArray(String[]::new) + ) + ) .description(""" The width of the thumbnail, if 'size' is not provided, this \ parameter will be used to determine the size\ """) .required(false) - ) - ; + ); }) .build(); } diff --git a/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java b/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java deleted file mode 100644 index a87dca886..000000000 --- a/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package run.halo.app.core.attachment; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -/** - * Tests for {@link ThumbnailGenerator}. - * - * @author guqing - * @since 2.19.0 - */ -@ExtendWith(MockitoExtension.class) -class ThumbnailGeneratorTest { - - @Test - void sanitizeFileName() { - String sanitizedFileName = ThumbnailGenerator.sanitizeFileName("example.jpg"); - assertThat(sanitizedFileName).isEqualTo("example.jpg"); - - sanitizedFileName = ThumbnailGenerator.sanitizeFileName("exampl./e$%^7*—=.jpg"); - assertThat(sanitizedFileName).isEqualTo("exampl.e7.jpg"); - } - - @Nested - class ImageDownloaderTest { - private final ThumbnailGenerator.ImageDownloader imageDownloader = - new ThumbnailGenerator.ImageDownloader(); - - private Path tempFile; - - @AfterEach - void tearDown() throws IOException { - if (tempFile != null && Files.exists(tempFile)) { - Files.delete(tempFile); - } - } - - @Test - void testDownloadImage_Success() throws Exception { - var imageUrl = new URL("https://example.com/sample-image.jpg"); - URL spyImageUrl = spy(imageUrl); - String mockImageData = "fakeImageData"; - InputStream mockInputStream = new ByteArrayInputStream(mockImageData.getBytes()); - - var urlConnection = mock(HttpURLConnection.class); - doAnswer(invocation -> urlConnection).when(spyImageUrl).openConnection(); - doReturn(mockInputStream).when(urlConnection).getInputStream(); - - var path = imageDownloader.downloadFileInternal(spyImageUrl); - assertThat(path).isNotNull(); - tempFile = path; - assertThat(Files.exists(path)).isTrue(); - try { - assertThat(Files.size(path)).isEqualTo(mockImageData.length()); - } catch (IOException e) { - throw new RuntimeException(e); - } - String fileName = path.getFileName().toString(); - assertThat(fileName).endsWith(".tmp"); - } - - @Test - void downloadImage_FileSizeLimitExceeded() throws Exception { - String largeImageUrl = "https://example.com/large-image.jpg"; - URL spyImageUrl = spy(new URL(largeImageUrl)); - - // larger than MAX_FILE_SIZE - var fileSizeByte = ThumbnailGenerator.MAX_FILE_SIZE + 10; - byte[] largeImageData = new byte[fileSizeByte]; - InputStream mockInputStream = new ByteArrayInputStream(largeImageData); - var urlConnection = mock(HttpURLConnection.class); - doAnswer(invocation -> urlConnection).when(spyImageUrl).openConnection(); - doReturn(mockInputStream).when(urlConnection).getInputStream(); - assertThatThrownBy(() -> imageDownloader.downloadFileInternal(spyImageUrl)) - .isInstanceOf(IOException.class) - .hasMessageContaining("File size exceeds the limit"); - } - } -} diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java deleted file mode 100644 index 6970e89ab..000000000 --- a/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package run.halo.app.core.attachment.impl; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static run.halo.app.core.attachment.ThumbnailSigner.generateSignature; - -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.web.util.UriUtils; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; -import run.halo.app.core.attachment.AttachmentRootGetter; -import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.attachment.extension.LocalThumbnail; -import run.halo.app.extension.ListResult; -import run.halo.app.extension.PageRequest; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.ExternalUrlSupplier; - -/** - * Tests for {@link LocalThumbnailServiceImpl}. - * - * @author guqing - * @since 2.19.0 - */ -@ExtendWith(MockitoExtension.class) -class LocalThumbnailServiceImplTest { - - @Mock - private AttachmentRootGetter attachmentWorkDirGetter; - - @Mock - private ExternalUrlSupplier externalUrlSupplier; - - @Mock - private ReactiveExtensionClient client; - - @InjectMocks - private LocalThumbnailServiceImpl localThumbnailService; - - @Test - void endpointForTest() { - var endpoint = - localThumbnailService.buildThumbnailUri("2024", ThumbnailSize.L, "example.jpg"); - assertThat(endpoint.toString()).isEqualTo("/upload/thumbnails/2024/w1200/example.jpg"); - } - - @Test - void geImageFileNameTest() throws MalformedURLException { - var fileName = - LocalThumbnailServiceImpl.geImageFileName(URI.create("https://halo.run/example.jpg")); - assertThat(fileName).isEqualTo("example.jpg"); - - fileName = LocalThumbnailServiceImpl.geImageFileName(URI.create("https://halo.run/")); - assertThat(fileName).isNotBlank(); - - var encoded = UriUtils.encode("https://halo.run/.1fasfg(*&^%$.jpg", StandardCharsets.UTF_8); - fileName = LocalThumbnailServiceImpl.geImageFileName(URI.create(encoded)); - assertThat(fileName).isNotBlank(); - } - - @Test - void appendRandomSuffixTest() { - var result = LocalThumbnailServiceImpl.appendRandomSuffix("example.jpg"); - assertThat(result).isNotEqualTo("example.jpg"); - assertThat(result).endsWith(".jpg"); - assertThat(result).startsWith("example_"); - } - - @Test - void toFilePathTest() { - when(attachmentWorkDirGetter.get()).thenReturn(Path.of("/tmp")); - var path = localThumbnailService.toFilePath("/thumbnails/2024/w1200/example.jpg"); - assertThat(path).isEqualTo(Path.of("/tmp/thumbnails/2024/w1200/example.jpg")); - } - - @Test - void signatureForImageUriTest() throws MalformedURLException { - when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090")); - var signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); - assertThat(signature).isEqualTo(generateSignature("/example.jpg")); - - signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); - assertThat(signature).isEqualTo(generateSignature("/example.jpg")); - - signature = signatureForImageUriStr("http://localhost:8091/example.jpg"); - assertThat(signature).isEqualTo(generateSignature("http://localhost:8091/example.jpg")); - - signature = signatureForImageUriStr("localhost:8090/example.jpg"); - assertThat(signature).isEqualTo(generateSignature("localhost:8090/example.jpg")); - - when(externalUrlSupplier.getRaw()).thenReturn(null); - signature = signatureForImageUriStr("http://localhost:8090/example.jpg"); - assertThat(signature).isEqualTo(generateSignature("http://localhost:8090/example.jpg")); - } - - String signatureForImageUriStr(String uriStr) { - return localThumbnailService.signatureForImageUri(URI.create(uriStr)); - } - - @Test - void generateUniqueThumbFileNameTest() { - var count = new AtomicInteger(0); - when(client.listBy(eq(LocalThumbnail.class), any(), isA(PageRequest.class))) - .thenAnswer(invocation -> { - if (count.get() > 2) { - return Mono.just(ListResult.emptyResult()); - } - count.incrementAndGet(); - var result = new ListResult<>(List.of(new LocalThumbnail())); - return Mono.just(result); - }); - - localThumbnailService.generateUniqueThumbFileName("example.jpg", "2024", ThumbnailSize.L) - .as(StepVerifier::create) - .consumeNextWith(fileName -> { - assertThat(fileName).startsWith("example_"); - assertThat(fileName).endsWith(".jpg"); - // 6 is the length of the random suffix - assertThat(fileName.length()).isEqualTo("example_.jpg".length() + 6); - }) - .verifyComplete(); - - verify(client, times(4)).listBy(eq(LocalThumbnail.class), any(), isA(PageRequest.class)); - } -}