mirror of https://github.com/halo-dev/halo
Add event handling for attachment changes and update thumbnail service integration
parent
68e80d16ed
commit
230550d0df
|
@ -0,0 +1,22 @@
|
||||||
|
package run.halo.app.core.attachment;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when an attachment is created, updated, or deleted.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
*/
|
||||||
|
public class AttachmentChangedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private final Attachment attachment;
|
||||||
|
|
||||||
|
public AttachmentChangedEvent(Object source, Attachment attachment) {
|
||||||
|
super(source);
|
||||||
|
this.attachment = attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,33 +1,32 @@
|
||||||
package run.halo.app.core.attachment;
|
package run.halo.app.core.attachment;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.util.Map;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.infra.ExternalLinkProcessor;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing thumbnails.
|
||||||
|
*
|
||||||
|
* @author johnniang
|
||||||
|
* @since 2.22.0
|
||||||
|
*/
|
||||||
public interface ThumbnailService {
|
public interface ThumbnailService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate thumbnail by the given image uri and size.
|
* Get the thumbnail link for the given image URI and size.
|
||||||
* <p>if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor}
|
|
||||||
* .</p>
|
|
||||||
* <p>if externalUrl is not configured, it will return empty.</p>
|
|
||||||
*
|
*
|
||||||
* @param imageUri image uri to generate thumbnail
|
* @param permalink the permalink of the image
|
||||||
* @param size thumbnail size to generate
|
* @param size the size of the thumbnail
|
||||||
* @return generated thumbnail uri if success, otherwise empty.
|
* @return the thumbnail link
|
||||||
*/
|
*/
|
||||||
Mono<URI> generate(URI imageUri, ThumbnailSize size);
|
Mono<URI> get(URI permalink, ThumbnailSize size);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>Get thumbnail by the given image uri and size.</p>
|
* Get all thumbnail links for the given image URI.
|
||||||
* <p>It depends on the {@link #generate(URI, ThumbnailSize)} method, currently the thumbnail
|
|
||||||
* generation is limited to the attachment service, that is, the thumbnail is strongly
|
|
||||||
* associated with the attachment.</p>
|
|
||||||
*
|
*
|
||||||
* @return if thumbnail exists, return the thumbnail uri, otherwise return the original image
|
* @param permalink the permalink of the image
|
||||||
* uri
|
* @return the map of thumbnail size to thumbnail link
|
||||||
*/
|
*/
|
||||||
Mono<URI> get(URI imageUri, ThumbnailSize size);
|
Mono<Map<ThumbnailSize, URI>> get(URI permalink);
|
||||||
|
|
||||||
Mono<Void> delete(URI imageUri);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,162 +1,100 @@
|
||||||
package run.halo.app.core.attachment.impl;
|
package run.halo.app.core.attachment.impl;
|
||||||
|
|
||||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
import static run.halo.app.extension.index.query.QueryFactory.startsWith;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
|
||||||
import java.net.MalformedURLException;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.attachment.AttachmentUtils;
|
import run.halo.app.core.attachment.AttachmentChangedEvent;
|
||||||
import run.halo.app.core.attachment.LocalThumbnailService;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailProvider;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailProvider.ThumbnailContext;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailService;
|
import run.halo.app.core.attachment.ThumbnailService;
|
||||||
import run.halo.app.core.attachment.ThumbnailSigner;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailSize;
|
import run.halo.app.core.attachment.ThumbnailSize;
|
||||||
import run.halo.app.core.attachment.extension.Thumbnail;
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
|
import run.halo.app.extension.ExtensionUtil;
|
||||||
import run.halo.app.extension.ListOptions;
|
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.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.infra.ExternalLinkProcessor;
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
class ThumbnailServiceImpl implements ThumbnailService {
|
||||||
public class ThumbnailServiceImpl implements ThumbnailService {
|
|
||||||
private final ExtensionGetter extensionGetter;
|
private final Cache<String, Map<ThumbnailSize, URI>> thumbnailCache;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final ExternalLinkProcessor externalLinkProcessor;
|
|
||||||
private final ThumbnailProvider thumbnailProvider;
|
|
||||||
private final LocalThumbnailService localThumbnailService;
|
|
||||||
private final Map<CacheKey, Mono<URI>> ongoingTasks = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
public ThumbnailServiceImpl(ReactiveExtensionClient client) {
|
||||||
public Mono<URI> generate(URI imageUri, ThumbnailSize size) {
|
this.client = client;
|
||||||
var cacheKey = new CacheKey(imageUri, size);
|
this.thumbnailCache = Caffeine.newBuilder()
|
||||||
// Combine caching to implement more elegant deduplication logic, ensure that only
|
.maximumSize(10_000)
|
||||||
// one thread executes the logic of create at the same time, and there is no global lock
|
|
||||||
// restriction
|
|
||||||
return ongoingTasks.computeIfAbsent(cacheKey, k -> doGenerate(imageUri, size)
|
|
||||||
// In the case of concurrency, doGenerate must return the same instance
|
|
||||||
.doFinally(signalType -> ongoingTasks.remove(cacheKey))
|
|
||||||
.cache()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
record CacheKey(URI imageUri, ThumbnailSize size) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<URI> doGenerate(URI imageUri, ThumbnailSize size) {
|
|
||||||
var imageUrlOpt = toImageUrl(imageUri);
|
|
||||||
if (imageUrlOpt.isEmpty()) {
|
|
||||||
return Mono.empty();
|
|
||||||
}
|
|
||||||
var imageUrl = imageUrlOpt.get();
|
|
||||||
return fetchThumbnail(imageUri, size)
|
|
||||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
|
||||||
.switchIfEmpty(Mono.defer(() -> create(imageUrl, size)))
|
|
||||||
.onErrorResume(Throwable.class, e -> {
|
|
||||||
log.warn("Failed to generate thumbnail for image: {}", imageUrl, e);
|
|
||||||
return Mono.just(URI.create(imageUrl.toString()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Mono<URI> get(URI imageUri, ThumbnailSize size) {
|
|
||||||
return fetchThumbnail(imageUri, size)
|
|
||||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
|
||||||
.defaultIfEmpty(imageUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Mono<Void> delete(URI imageUri) {
|
|
||||||
Assert.notNull(imageUri, "Image uri must not be null");
|
|
||||||
Mono<Void> deleteMono;
|
|
||||||
if (imageUri.isAbsolute()) {
|
|
||||||
deleteMono = thumbnailProvider.delete(AttachmentUtils.toUrl(imageUri));
|
|
||||||
} else {
|
|
||||||
// Local thumbnails maybe a relative path, so we need to process it.
|
|
||||||
deleteMono = localThumbnailService.delete(imageUri);
|
|
||||||
}
|
|
||||||
return deleteMono.then(deleteThumbnailRecord(imageUri));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<Void> deleteThumbnailRecord(URI imageUri) {
|
|
||||||
var imageHash = signatureFor(imageUri);
|
|
||||||
var listOptions = ListOptions.builder()
|
|
||||||
.fieldQuery(startsWith(Thumbnail.ID_INDEX, Thumbnail.idIndexFunc(imageHash, "")))
|
|
||||||
.build();
|
.build();
|
||||||
return client.listAll(Thumbnail.class, listOptions, Sort.unsorted())
|
|
||||||
.flatMap(client::delete)
|
|
||||||
.then();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<URL> toImageUrl(URI imageUri) {
|
@EventListener
|
||||||
try {
|
void handleAttachmentChangedEvent(AttachmentChangedEvent event) {
|
||||||
if (imageUri.isAbsolute()) {
|
invalidateOrUpdateCache(event.getAttachment());
|
||||||
return Optional.of(imageUri.toURL());
|
}
|
||||||
|
|
||||||
|
void invalidateOrUpdateCache(Attachment attachment) {
|
||||||
|
if (attachment.getStatus() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var permalink = attachment.getStatus().getPermalink();
|
||||||
|
if (!StringUtils.hasText(permalink)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ExtensionUtil.isDeleted(attachment)) {
|
||||||
|
thumbnailCache.invalidate(permalink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var thumbnails = attachment.getStatus().getThumbnails();
|
||||||
|
if (CollectionUtils.isEmpty(thumbnails)) {
|
||||||
|
thumbnailCache.put(permalink, Map.of());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ThumbnailSize, URI> validThumbnails = new HashMap<>();
|
||||||
|
thumbnails.forEach((key, value) -> {
|
||||||
|
var size = ThumbnailSize.optionalValueOf(key);
|
||||||
|
if (size.isPresent() && StringUtils.hasText(value)) {
|
||||||
|
validThumbnails.put(size.get(), URI.create(value));
|
||||||
}
|
}
|
||||||
var url = new URL(externalLinkProcessor.processLink(imageUri.toString()));
|
});
|
||||||
return Optional.of(url);
|
if (validThumbnails.isEmpty()) {
|
||||||
} catch (MalformedURLException e) {
|
thumbnailCache.put(permalink, Map.of());
|
||||||
// Ignore
|
} else {
|
||||||
|
thumbnailCache.put(permalink, validThumbnails);
|
||||||
}
|
}
|
||||||
return Optional.empty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Mono<URI> create(URL imageUrl, ThumbnailSize size) {
|
@Override
|
||||||
var context = ThumbnailContext.builder()
|
public Mono<URI> get(URI permalink, ThumbnailSize size) {
|
||||||
.imageUrl(imageUrl)
|
return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size));
|
||||||
.size(size)
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Map<ThumbnailSize, URI>> get(URI permalink) {
|
||||||
|
var permalinkString = permalink.toASCIIString();
|
||||||
|
var thumbnails = thumbnailCache.getIfPresent(permalinkString);
|
||||||
|
if (thumbnails != null) {
|
||||||
|
return Mono.just(thumbnails);
|
||||||
|
}
|
||||||
|
// query from attachments
|
||||||
|
var listOptions = ListOptions.builder()
|
||||||
|
.andQuery(QueryFactory.equal("status.permalink", permalinkString))
|
||||||
.build();
|
.build();
|
||||||
var imageUri =
|
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
|
||||||
localThumbnailService.ensureInSiteUriIsRelative(URI.create(imageUrl.toString()));
|
|
||||||
return extensionGetter.getEnabledExtensions(ThumbnailProvider.class)
|
|
||||||
.filterWhen(provider -> provider.supports(context))
|
|
||||||
.next()
|
.next()
|
||||||
.flatMap(provider -> provider.generate(context))
|
.map(attachment -> {
|
||||||
.flatMap(uri -> {
|
// Here we allow concurrent updates
|
||||||
var thumb = new Thumbnail();
|
invalidateOrUpdateCache(attachment);
|
||||||
thumb.setMetadata(new Metadata());
|
return this.thumbnailCache.getIfPresent(permalinkString);
|
||||||
thumb.getMetadata().setGenerateName("thumb-");
|
|
||||||
thumb.setSpec(new Thumbnail.Spec()
|
|
||||||
.setSize(size)
|
|
||||||
.setThumbnailUri(uri.toASCIIString())
|
|
||||||
.setImageUri(imageUri.toASCIIString())
|
|
||||||
.setImageSignature(signatureFor(imageUri))
|
|
||||||
);
|
|
||||||
// double check
|
|
||||||
return fetchThumbnail(imageUri, size)
|
|
||||||
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
|
|
||||||
.switchIfEmpty(Mono.defer(() -> client.create(thumb)
|
|
||||||
.thenReturn(uri))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signatureFor(URI imageUri) {
|
|
||||||
var uri = localThumbnailService.ensureInSiteUriIsRelative(imageUri);
|
|
||||||
return ThumbnailSigner.generateSignature(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
Mono<Thumbnail> fetchThumbnail(URI imageUri, ThumbnailSize size) {
|
|
||||||
var imageHash = signatureFor(imageUri);
|
|
||||||
var id = Thumbnail.idIndexFunc(imageHash, size.name());
|
|
||||||
return client.listBy(Thumbnail.class, ListOptions.builder()
|
|
||||||
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
|
|
||||||
.build(), PageRequestImpl.ofSize(1))
|
|
||||||
.flatMap(result -> Mono.justOrEmpty(ListResult.first(result)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,13 +5,13 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import run.halo.app.core.attachment.ThumbnailService;
|
import run.halo.app.core.attachment.AttachmentChangedEvent;
|
||||||
import run.halo.app.core.extension.attachment.Attachment;
|
import run.halo.app.core.extension.attachment.Attachment;
|
||||||
import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus;
|
import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus;
|
||||||
import run.halo.app.core.extension.attachment.Constant;
|
import run.halo.app.core.extension.attachment.Constant;
|
||||||
|
@ -33,7 +33,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
||||||
|
|
||||||
private final AttachmentService attachmentService;
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
private final ThumbnailService thumbnailService;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result reconcile(Request request) {
|
public Result reconcile(Request request) {
|
||||||
|
@ -43,6 +43,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
||||||
Set.of(Constant.FINALIZER_NAME))) {
|
Set.of(Constant.FINALIZER_NAME))) {
|
||||||
cleanUpResources(attachment);
|
cleanUpResources(attachment);
|
||||||
client.update(attachment);
|
client.update(attachment);
|
||||||
|
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -74,6 +75,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
||||||
attachment.getStatus().setThumbnails(thumbnails);
|
attachment.getStatus().setThumbnails(thumbnails);
|
||||||
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
|
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
|
||||||
client.update(attachment);
|
client.update(attachment);
|
||||||
|
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -86,12 +88,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void cleanUpResources(Attachment attachment) {
|
void cleanUpResources(Attachment attachment) {
|
||||||
var timeout = Duration.ofSeconds(20);
|
attachmentService.delete(attachment).block(Duration.ofSeconds(20));
|
||||||
Optional.ofNullable(attachment.getStatus())
|
|
||||||
.map(AttachmentStatus::getPermalink)
|
|
||||||
.map(URI::create)
|
|
||||||
.ifPresent(uri -> thumbnailService.delete(uri).block(timeout));
|
|
||||||
|
|
||||||
attachmentService.delete(attachment).block(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,14 +20,11 @@ import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.attachment.ThumbnailService;
|
||||||
import run.halo.app.core.attachment.ThumbnailSize;
|
import run.halo.app.core.attachment.ThumbnailSize;
|
||||||
import run.halo.app.core.extension.attachment.Attachment;
|
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.extension.ExtensionUtil;
|
|
||||||
import run.halo.app.extension.GroupVersion;
|
import run.halo.app.extension.GroupVersion;
|
||||||
import run.halo.app.extension.ListOptions;
|
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.index.query.QueryFactory;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thumbnail endpoint for thumbnail resource access.
|
* Thumbnail endpoint for thumbnail resource access.
|
||||||
|
@ -39,6 +36,8 @@ import run.halo.app.extension.index.query.QueryFactory;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ThumbnailEndpoint implements CustomEndpoint {
|
public class ThumbnailEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private final ThumbnailService thumbnailService;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -49,32 +48,49 @@ public class ThumbnailEndpoint implements CustomEndpoint {
|
||||||
builder.operationId("GetThumbnailByUri")
|
builder.operationId("GetThumbnailByUri")
|
||||||
.description("Get thumbnail by URI")
|
.description("Get thumbnail by URI")
|
||||||
.tag(tag)
|
.tag(tag)
|
||||||
.response(responseBuilder()
|
.response(responseBuilder().implementation(Resource.class))
|
||||||
.implementation(Resource.class));
|
.parameter(parameterBuilder()
|
||||||
ThumbnailQuery.buildParameters(builder);
|
.in(ParameterIn.QUERY)
|
||||||
|
.name("uri")
|
||||||
|
.description("The URI of the image")
|
||||||
|
.required(true)
|
||||||
|
)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.in(ParameterIn.QUERY)
|
||||||
|
.name("size")
|
||||||
|
.implementation(ThumbnailSize.class)
|
||||||
|
.description("The size of the thumbnail")
|
||||||
|
.required(true)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ThumbnailService getThumbnailService() {
|
||||||
|
return thumbnailService;
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
|
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
|
||||||
var query = new ThumbnailQuery(request.queryParams());
|
var uri = request.queryParam("uri")
|
||||||
var size = query.getSize();
|
.filter(StringUtils::isNotBlank)
|
||||||
var uri = query.getUri().toASCIIString();
|
.map(URI::create);
|
||||||
var listOptions = ListOptions.builder()
|
if (uri.isEmpty()) {
|
||||||
.andQuery(ExtensionUtil.notDeleting())
|
return Mono.error(
|
||||||
.andQuery(QueryFactory.equal("status.permalink", uri))
|
new ServerWebInputException("Required parameter 'uri' is missing or invalid")
|
||||||
.build();
|
);
|
||||||
// query by permalink
|
}
|
||||||
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
|
var size = request.queryParam("size")
|
||||||
// find the first one
|
.filter(StringUtils::isNotBlank)
|
||||||
.next()
|
.flatMap(ThumbnailSize::optionalValueOf);
|
||||||
.mapNotNull(attachment -> {
|
if (size.isEmpty()) {
|
||||||
var thumbnails = attachment.getStatus().getThumbnails();
|
return Mono.error(
|
||||||
return thumbnails.get(size.name());
|
new ServerWebInputException("Required parameter 'size' is missing or invalid")
|
||||||
})
|
);
|
||||||
.defaultIfEmpty(uri)
|
}
|
||||||
|
return thumbnailService.get(uri.get(), size.get())
|
||||||
|
.defaultIfEmpty(uri.get())
|
||||||
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
|
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
|
||||||
.location(URI.create(thumbnailLink))
|
.location(thumbnailLink)
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package run.halo.app.theme.finders.impl;
|
package run.halo.app.theme.finders.impl;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.attachment.ThumbnailService;
|
||||||
|
import run.halo.app.core.attachment.ThumbnailSize;
|
||||||
import run.halo.app.theme.finders.Finder;
|
import run.halo.app.theme.finders.Finder;
|
||||||
import run.halo.app.theme.finders.ThumbnailFinder;
|
import run.halo.app.theme.finders.ThumbnailFinder;
|
||||||
|
|
||||||
|
@ -11,9 +14,12 @@ import run.halo.app.theme.finders.ThumbnailFinder;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ThumbnailFinderImpl implements ThumbnailFinder {
|
public class ThumbnailFinderImpl implements ThumbnailFinder {
|
||||||
|
|
||||||
|
private final ThumbnailService thumbnailService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<String> gen(String uriStr, String size) {
|
public Mono<String> gen(String uriStr, String size) {
|
||||||
// TODO Implement me
|
return thumbnailService.get(URI.create(uriStr), ThumbnailSize.fromName(size))
|
||||||
return Mono.just(uriStr);
|
.map(URI::toASCIIString);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,225 +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.assertArg;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.ArgumentMatchers.isA;
|
|
||||||
import static org.mockito.Mockito.doReturn;
|
|
||||||
import static org.mockito.Mockito.spy;
|
|
||||||
import static org.mockito.Mockito.times;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
|
||||||
|
|
||||||
import java.net.MalformedURLException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
|
||||||
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.skyscreamer.jsonassert.JSONAssert;
|
|
||||||
import reactor.core.publisher.Flux;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
import reactor.test.StepVerifier;
|
|
||||||
import run.halo.app.core.attachment.LocalThumbnailProvider;
|
|
||||||
import run.halo.app.core.attachment.LocalThumbnailService;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailProvider;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailSigner;
|
|
||||||
import run.halo.app.core.attachment.ThumbnailSize;
|
|
||||||
import run.halo.app.core.attachment.extension.Thumbnail;
|
|
||||||
import run.halo.app.extension.ListOptions;
|
|
||||||
import run.halo.app.extension.PageRequest;
|
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
|
||||||
import run.halo.app.infra.ExternalLinkProcessor;
|
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
|
||||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link ThumbnailServiceImpl}.
|
|
||||||
*
|
|
||||||
* @author guqing
|
|
||||||
* @since 2.19.0
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class ThumbnailServiceImplTest {
|
|
||||||
@Mock
|
|
||||||
private ExternalLinkProcessor externalLinkProcessor;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ExtensionGetter extensionGetter;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private LocalThumbnailProvider localThumbnailProvider;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private LocalThumbnailService localThumbnailService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ReactiveExtensionClient client;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private ThumbnailServiceImpl thumbnailService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void toImageUrl() {
|
|
||||||
var link = "/test.jpg";
|
|
||||||
when(externalLinkProcessor.processLink(link)).thenReturn("http://localhost:8090/test.jpg");
|
|
||||||
var imageUrl = thumbnailService.toImageUrl(URI.create(link));
|
|
||||||
assertThat(imageUrl).isPresent();
|
|
||||||
assertThat(imageUrl.get().toString()).isEqualTo("http://localhost:8090/test.jpg");
|
|
||||||
|
|
||||||
var absoluteLink = "https://halo.run/test.jpg";
|
|
||||||
imageUrl = thumbnailService.toImageUrl(URI.create(absoluteLink));
|
|
||||||
assertThat(imageUrl).isPresent();
|
|
||||||
assertThat(imageUrl.get().toString()).isEqualTo(absoluteLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generateTest() {
|
|
||||||
var uri = URI.create("http://localhost:8090/test.jpg");
|
|
||||||
var size = ThumbnailSize.L;
|
|
||||||
when(localThumbnailService.ensureInSiteUriIsRelative(eq(uri)))
|
|
||||||
.thenReturn(uri);
|
|
||||||
var imageHash = ThumbnailSigner.generateSignature(uri.toString());
|
|
||||||
var id = Thumbnail.idIndexFunc(imageHash, size.name());
|
|
||||||
var listOptions = ListOptions.builder()
|
|
||||||
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
|
|
||||||
.build();
|
|
||||||
when(client.listBy(eq(Thumbnail.class), any(), any())).thenReturn(Mono.empty());
|
|
||||||
|
|
||||||
var spyThumbnailService = spy(thumbnailService);
|
|
||||||
doReturn(Mono.empty()).when(spyThumbnailService).create(any(), any());
|
|
||||||
|
|
||||||
spyThumbnailService.generate(uri, size)
|
|
||||||
.as(StepVerifier::create)
|
|
||||||
.verifyComplete();
|
|
||||||
|
|
||||||
verify(client).listBy(eq(Thumbnail.class), assertArg(options -> {
|
|
||||||
assertThat(options.toString()).isEqualTo(listOptions.toString());
|
|
||||||
}), isA(PageRequest.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createTest() throws MalformedURLException, URISyntaxException {
|
|
||||||
var url = new URL("http://localhost:8090/test.jpg");
|
|
||||||
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
|
|
||||||
.thenReturn(Flux.just(localThumbnailProvider));
|
|
||||||
var thumbUri = URI.create("/test-thumb.jpg");
|
|
||||||
when(localThumbnailProvider.generate(any())).thenReturn(Mono.just(thumbUri));
|
|
||||||
when(localThumbnailProvider.supports(any())).thenReturn(Mono.just(true));
|
|
||||||
|
|
||||||
var insiteUri = URI.create("/test.jpg");
|
|
||||||
when(localThumbnailService.ensureInSiteUriIsRelative(any()))
|
|
||||||
.thenReturn(insiteUri);
|
|
||||||
when(client.create(any())).thenReturn(Mono.empty());
|
|
||||||
|
|
||||||
when(client.listBy(eq(Thumbnail.class), any(), isA(PageRequest.class)))
|
|
||||||
.thenReturn(Mono.empty());
|
|
||||||
|
|
||||||
thumbnailService.create(url, ThumbnailSize.M)
|
|
||||||
.as(StepVerifier::create)
|
|
||||||
.expectNext(thumbUri)
|
|
||||||
.verifyComplete();
|
|
||||||
|
|
||||||
thumbnailService.fetchThumbnail(url.toURI(), ThumbnailSize.M)
|
|
||||||
.as(StepVerifier::create)
|
|
||||||
.verifyComplete();
|
|
||||||
var hash = ThumbnailSigner.generateSignature(insiteUri.toString());
|
|
||||||
|
|
||||||
verify(client, times(2)).listBy(eq(Thumbnail.class),
|
|
||||||
assertArg(options -> {
|
|
||||||
var exceptOptions = ListOptions.builder()
|
|
||||||
.fieldQuery(equal(Thumbnail.ID_INDEX,
|
|
||||||
Thumbnail.idIndexFunc(hash, ThumbnailSize.M.name())
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
assertThat(options.toString()).isEqualTo(exceptOptions.toString());
|
|
||||||
}), isA(PageRequest.class));
|
|
||||||
|
|
||||||
verify(localThumbnailProvider).generate(any());
|
|
||||||
|
|
||||||
verify(client).create(assertArg(thumb -> {
|
|
||||||
JSONAssert.assertEquals("""
|
|
||||||
{
|
|
||||||
"spec": {
|
|
||||||
"imageSignature": "%s",
|
|
||||||
"imageUri": "/test.jpg",
|
|
||||||
"size": "M",
|
|
||||||
"thumbnailUri": "/test-thumb.jpg"
|
|
||||||
},
|
|
||||||
"apiVersion": "storage.halo.run/v1alpha1",
|
|
||||||
"kind": "Thumbnail",
|
|
||||||
"metadata": {
|
|
||||||
"generateName": "thumb-"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".formatted(hash), JsonUtils.objectToJson(thumb), true);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createTest2() throws MalformedURLException {
|
|
||||||
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
|
|
||||||
.thenReturn(Flux.empty());
|
|
||||||
|
|
||||||
// no thumbnail provider will do nothing
|
|
||||||
var url = new URL("http://localhost:8090/test.jpg");
|
|
||||||
thumbnailService.create(url, ThumbnailSize.M)
|
|
||||||
.as(StepVerifier::create)
|
|
||||||
.verifyComplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
class ThumbnailGenerateConcurrencyTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void concurrentThumbnailGeneration() throws InterruptedException {
|
|
||||||
var spyThumbnailService = spy(thumbnailService);
|
|
||||||
|
|
||||||
URI imageUri = URI.create("http://localhost:8090/test.jpg");
|
|
||||||
|
|
||||||
doReturn(Mono.empty()).when(spyThumbnailService).fetchThumbnail(eq(imageUri), any());
|
|
||||||
|
|
||||||
var createdUri = URI.create("/test-thumb.jpg");
|
|
||||||
doReturn(Mono.just(createdUri)).when(spyThumbnailService).create(any(), any());
|
|
||||||
|
|
||||||
int threadCount = 100;
|
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
|
||||||
var latch = new CountDownLatch(threadCount);
|
|
||||||
|
|
||||||
var results = new ConcurrentLinkedQueue<Mono<URI>>();
|
|
||||||
|
|
||||||
for (int i = 0; i < threadCount; i++) {
|
|
||||||
executor.submit(() -> {
|
|
||||||
try {
|
|
||||||
results.add(spyThumbnailService.generate(imageUri, ThumbnailSize.M));
|
|
||||||
} finally {
|
|
||||||
latch.countDown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
latch.await();
|
|
||||||
|
|
||||||
results.forEach(result -> {
|
|
||||||
StepVerifier.create(result)
|
|
||||||
.expectNext(createdUri)
|
|
||||||
.verifyComplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
verify(spyThumbnailService).fetchThumbnail(eq(imageUri), eq(ThumbnailSize.M));
|
|
||||||
verify(spyThumbnailService).create(any(), eq(ThumbnailSize.M));
|
|
||||||
|
|
||||||
executor.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue