Implement thumbnail link retrieval in attachment handling

feat/add-thumbnail-router
John Niang 2025-09-24 11:06:57 +08:00
parent 503e9b8c7f
commit 8f198d9583
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
8 changed files with 141 additions and 55 deletions

View File

@ -2,12 +2,15 @@ package run.halo.app.core.extension.attachment.endpoint;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import org.pf4j.ExtensionPoint; import org.pf4j.ExtensionPoint;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.exception.NotImplementedException;
public interface AttachmentHandler extends ExtensionPoint { public interface AttachmentHandler extends ExtensionPoint {
@ -53,6 +56,24 @@ public interface AttachmentHandler extends ExtensionPoint {
return Mono.empty(); return Mono.empty();
} }
/**
* Gets thumbnail links for given attachment.
* The default implementation will raise NotImplementedException.
*
* @param attachment the attachment
* @param policy the policy
* @param configMap the config map
* @return a map of thumbnail sizes to their respective URIs
*/
default Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment,
Policy policy,
ConfigMap configMap) {
return Mono.error(new NotImplementedException(
"getThumbnailLinks method is not implemented for " + attachment.getMetadata().getName()
+ ", please try to upgrade the corresponding plugin"
));
}
interface UploadContext { interface UploadContext {
FilePart file(); FilePart file();

View File

@ -3,6 +3,7 @@ package run.halo.app.core.extension.service;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -11,6 +12,7 @@ import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment;
/** /**
@ -92,6 +94,8 @@ public interface AttachmentService {
*/ */
Mono<URI> getSharedURL(Attachment attachment, Duration ttl); Mono<URI> getSharedURL(Attachment attachment, Duration ttl);
Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment);
/** /**
* Transfer external links to attachments. * Transfer external links to attachments.
* *

View File

@ -188,6 +188,8 @@ public class DefaultController<R> implements Controller {
log.warn("Optimistic locking failure when reconciling request: {}/{}", log.warn("Optimistic locking failure when reconciling request: {}/{}",
this.name, entry.getEntry()); this.name, entry.getEntry());
} else if (t instanceof RequeueException re) { } else if (t instanceof RequeueException re) {
log.warn("{}: Requeue {} due to {}",
this.name, entry.getEntry(), re.getMessage());
result = re.getResult(); result = re.getResult();
} else { } else {
log.error("Reconciler in " + this.name log.error("Reconciler in " + this.name

View File

@ -0,0 +1,18 @@
package run.halo.app.extension.exception;
/**
* Exception thrown to indicate that the requested operation is not implemented.
*
* @author johnniang
*/
public class NotImplementedException extends UnsupportedOperationException {
public NotImplementedException() {
this("Not implemented");
}
public NotImplementedException(String message) {
super(message);
}
}

View File

@ -15,11 +15,13 @@ import java.nio.file.Path;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.RandomStringUtils;
@ -42,6 +44,7 @@ import reactor.core.publisher.SynchronousSink;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry; import reactor.util.retry.Retry;
import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.AttachmentRootGetter;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Constant;
@ -240,6 +243,25 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
} else { } else {
log.info("{} was not exist", attachment); log.info("{} was not exist", attachment);
} }
// TODO Delete thumbnails if present
var thumbnailsRoot = attachmentsRoot.resolve("thumbnails");
var relativeAttachmentPath =
attachmentsRoot.resolve("upload").relativize(attachmentPath);
log.info("Clean up thumbnails for {}", localRelativePath);
Arrays.stream(ThumbnailSize.values())
.forEach(thumbnailSize -> {
var thumbnailPath =
thumbnailsRoot.resolve("w" + thumbnailSize.getWidth())
.resolve(relativeAttachmentPath);
try {
Files.deleteIfExists(thumbnailPath);
log.info("Deleted thumbnail {}", thumbnailPath);
} catch (IOException e) {
// ignore the exception to continue deleting other
// thumbnails
log.warn("Cannot delete thumbnail {}", thumbnailPath, e);
}
});
} catch (IOException e) { } catch (IOException e) {
throw Exceptions.propagate(e); throw Exceptions.propagate(e);
} }
@ -278,6 +300,28 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
return getPermalink(attachment, policy, configMap); return getPermalink(attachment, policy, configMap);
} }
@Override
public Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment, Policy policy,
ConfigMap configMap) {
if (!this.shouldHandle(policy)) {
return Mono.empty();
}
if (attachment.getStatus() == null
|| !StringUtils.hasText(attachment.getStatus().getPermalink())) {
return Mono.just(Map.of());
}
var thumbnails = Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(t -> t, t -> {
var permalink = URI.create(attachment.getStatus().getPermalink());
var prefix = "/thumbnails/w" + t.getWidth();
return UriComponentsBuilder.fromUri(permalink)
.replacePath(prefix + permalink.getPath())
.build()
.toUri();
}));
return Mono.just(thumbnails);
}
private boolean shouldHandle(Policy policy) { private boolean shouldHandle(Policy policy) {
if (policy == null if (policy == null
|| policy.getSpec() == null || policy.getSpec() == null

View File

@ -5,18 +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.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import run.halo.app.core.attachment.AttachmentUtils;
import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
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;
@ -28,6 +23,7 @@ import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request; import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.extension.controller.RequeueException; import run.halo.app.extension.controller.RequeueException;
import run.halo.app.extension.exception.NotImplementedException;
@Slf4j @Slf4j
@Component @Component
@ -44,47 +40,51 @@ public class AttachmentReconciler implements Reconciler<Request> {
public Result reconcile(Request request) { public Result reconcile(Request request) {
client.fetch(Attachment.class, request.name()).ifPresent(attachment -> { client.fetch(Attachment.class, request.name()).ifPresent(attachment -> {
if (ExtensionUtil.isDeleted(attachment)) { if (ExtensionUtil.isDeleted(attachment)) {
if (removeFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME))) { if (removeFinalizers(attachment.getMetadata(),
Set.of(Constant.FINALIZER_NAME))) {
cleanUpResources(attachment); cleanUpResources(attachment);
client.update(attachment); client.update(attachment);
} }
return; return;
} }
// add finalizer // add finalizer
if (addFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME))) { addFinalizers(attachment.getMetadata(), Set.of(Constant.FINALIZER_NAME));
client.update(attachment);
}
var annotations = attachment.getMetadata().getAnnotations(); if (attachment.getStatus() == null) {
if (annotations != null) { attachment.setStatus(new AttachmentStatus());
}
var permalink = attachmentService.getPermalink(attachment) var permalink = attachmentService.getPermalink(attachment)
.map(URI::toASCIIString) .map(URI::toASCIIString)
.blockOptional() .blockOptional(Duration.ofSeconds(10))
.orElseThrow(() -> new RequeueException(new Result(true, null), .orElseThrow(() -> new RequeueException(new Result(true, null),
"Attachment handler is unavailable, requeue the request" "Attachment handler is unavailable, requeue the request"
)); ));
log.debug("Set permalink {} for attachment {}", permalink, request.name()); log.debug("Set attachment permalink: {} for {}", permalink, request.name());
var status = nullSafeStatus(attachment); attachment.getStatus().setPermalink(permalink);
status.setPermalink(permalink); var thumbnails = attachmentService.getThumbnailLinks(attachment)
} .map(map -> map.keySet()
var permalink = nullSafeStatus(attachment).getPermalink(); .stream()
if (StringUtils.isNotBlank(permalink) && AttachmentUtils.isImage(attachment)) { .collect(Collectors.toMap(Enum::name, k -> map.get(k).toASCIIString()))
populateThumbnails(permalink, attachment.getStatus()); )
} .onErrorMap(NotImplementedException.class,
updateStatus(request.name(), attachment.getStatus()); e -> new RequeueException(new Result(true, null),
"Attachment handler does not implement thumbnail generation, requeue "
+ "the "
+ "request"
))
.blockOptional(Duration.ofSeconds(10))
.orElseThrow(() -> new RequeueException(new Result(true, null), """
Attachment handler is unavailable for getting thumbnails links, \
requeue the request\
"""
));
attachment.getStatus().setThumbnails(thumbnails);
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
client.update(attachment);
}); });
return null; return null;
} }
private static AttachmentStatus nullSafeStatus(Attachment attachment) {
var status = attachment.getStatus();
if (status == null) {
status = new AttachmentStatus();
attachment.setStatus(status);
}
return status;
}
@Override @Override
public Controller setupWith(ControllerBuilder builder) { public Controller setupWith(ControllerBuilder builder) {
return builder return builder
@ -92,26 +92,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
.build(); .build();
} }
void populateThumbnails(String permalink, AttachmentStatus status) {
var imageUri = URI.create(permalink);
Flux.fromArray(ThumbnailSize.values())
.flatMap(size -> thumbnailService.generate(imageUri, size)
.map(thumbUri -> Map.entry(size.name(), thumbUri.toString()))
)
.collectMap(Map.Entry::getKey, Map.Entry::getValue)
.doOnNext(status::setThumbnails)
.block();
}
void updateStatus(String attachmentName, AttachmentStatus status) {
client.fetch(Attachment.class, attachmentName)
.filter(attachment -> !Objects.deepEquals(attachment.getStatus(), status))
.ifPresent(attachment -> {
attachment.setStatus(status);
client.update(attachment);
});
}
void cleanUpResources(Attachment attachment) { void cleanUpResources(Attachment attachment) {
var timeout = Duration.ofSeconds(20); var timeout = Duration.ofSeconds(20);
Optional.ofNullable(attachment.getStatus()) Optional.ofNullable(attachment.getStatus())

View File

@ -4,6 +4,7 @@ import java.net.URI;
import java.net.URL; import java.net.URL;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
@ -24,6 +25,7 @@ import org.springframework.web.server.ServerErrorException;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
@ -139,6 +141,19 @@ public class DefaultAttachmentService implements AttachmentService {
); );
} }
@Override
public Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment) {
return client.get(Policy.class, attachment.getSpec().getPolicyName())
.zipWhen(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName()))
.flatMap(tuple2 -> {
var policy = tuple2.getT1();
var configMap = tuple2.getT2();
return extensionGetter.getExtensions(AttachmentHandler.class)
.concatMap(handler -> handler.getThumbnailLinks(attachment, policy, configMap))
.next();
});
}
@Override @Override
public Mono<Attachment> uploadFromUrl(@NonNull URL url, @NonNull String policyName, public Mono<Attachment> uploadFromUrl(@NonNull URL url, @NonNull String policyName,
String groupName, String filename) { String groupName, String filename) {

View File

@ -40,5 +40,7 @@ public class PathPrefixPredicateTest {
void urlTest() { void urlTest() {
URI uri = URI.create("https:///path"); URI uri = URI.create("https:///path");
System.out.println(uri); System.out.println(uri);
System.out.println(uri.getPath());
System.out.println(URI.create("/"));
} }
} }