Update S3 plugin version and enhance thumbnail link generation

This commit is contained in:
John Niang
2025-12-23 11:21:38 +08:00
parent 0f4e69e0df
commit 9c4e7d71e5
5 changed files with 245 additions and 135 deletions

View File

@@ -25,7 +25,7 @@ repositories {
}
dependencies {
implementation platform('run.halo.tools.platform:plugin:2.21.0-alpha.1')
implementation platform('run.halo.tools.platform:plugin:2.22.0-alpha.1')
compileOnly 'run.halo.app:api'
implementation platform('software.amazon.awssdk:bom:2.31.58')

View File

@@ -1,15 +1,5 @@
package run.halo.s3os;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.file.FileAlreadyExistsException;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.Extension;
@@ -19,17 +9,21 @@ import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerErrorException;
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.context.Context;
import reactor.util.retry.Retry;
import run.halo.app.core.attachment.ThumbnailSize;
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.AttachmentStatus;
import run.halo.app.core.extension.attachment.Constant;
import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
@@ -44,18 +38,47 @@ import software.amazon.awssdk.http.SdkHttpResponse;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
import software.amazon.awssdk.services.s3.model.CompletedPart;
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.utils.SdkAutoCloseable;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.file.FileAlreadyExistsException;
import java.time.Duration;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Extension
public class S3OsAttachmentHandler implements AttachmentHandler {
public static final String OBJECT_KEY = "s3os.plugin.halo.run/object-key";
public static final String URL_SUFFIX_ANNO_KEY = "s3os.plugin.halo.run/url-suffix";
public static final String SKIP_REMOTE_DELETION_ANNO = "s3os.plugin.halo.run/skip-remote-deletion";
private static final MediaType IMAGE_MEDIA_TYPE = MediaType.parseMediaType("image/*");
public static final int MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
/**
@@ -156,18 +179,73 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
if (!this.shouldHandle(policy)) {
return Mono.empty();
}
var objectKey = getObjectKey(attachment);
if (objectKey == null) {
// fallback to default handler for backward compatibility
return Mono.justOrEmpty(doGetPermalink(attachment, S3OsProperties.convertFrom(configMap)));
}
@Override
public Mono<Map<ThumbnailSize, URI>> getThumbnailLinks(Attachment attachment, Policy policy, ConfigMap configMap) {
if (!this.shouldHandle(policy)) {
return Mono.empty();
}
var properties = S3OsProperties.convertFrom(configMap);
return Mono.just(doGetThumbnailLinks(attachment, properties));
}
private Optional<URI> doGetPermalink(Attachment attachment, S3OsProperties properties) {
var objectKey = getObjectKey(attachment);
if (objectKey == null) {
// fallback to default handler for backward compatibility
return Optional.empty();
}
var objectURL = properties.toObjectURL(objectKey);
var urlSuffix = getUrlSuffixAnnotation(attachment);
if (StringUtils.isNotBlank(urlSuffix)) {
objectURL += urlSuffix;
}
return Mono.just(URI.create(objectURL));
return Optional.of(URI.create(objectURL));
}
@NonNull
private Map<ThumbnailSize, URI> doGetThumbnailLinks(Attachment attachment, S3OsProperties properties) {
// TODO Support configuring media types that support thumbnails
var support = Optional.ofNullable(attachment.getSpec().getMediaType())
.map(MediaType::parseMediaType)
.map(IMAGE_MEDIA_TYPE::isCompatibleWith)
.orElse(false);
if (!support) {
if (log.isDebugEnabled()) {
log.debug("Attachment {} media type {} is not compatible with image/*, skip generating thumbnail links",
attachment.getMetadata().getName(), attachment.getSpec().getMediaType());
}
return Map.of();
}
var thumbnailParamPattern = properties.getThumbnailParamPattern();
if (StringUtils.isBlank(thumbnailParamPattern) || !thumbnailParamPattern.contains("{width}")) {
return Map.of();
}
return Optional.ofNullable(attachment.getStatus())
.map(AttachmentStatus::getPermalink)
.filter(StringUtils::isNotBlank)
.map(URI::create)
.or(() -> doGetPermalink(attachment, properties))
.map(permalink -> Arrays.stream(ThumbnailSize.values())
.collect(Collectors.toMap(Function.identity(), size -> {
var thumbnailParam = thumbnailParamPattern.replace("{width}", String.valueOf(size.getWidth()));
var isQueryPattern = thumbnailParam.startsWith("?");
if (isQueryPattern) {
return UriComponentsBuilder.fromUri(permalink)
.query(thumbnailParam.substring(1))
.build(true)
.toUri();
}
return UriComponentsBuilder.fromUri(permalink)
.path(thumbnailParam)
.build(true)
.toUri();
}))
)
.orElse(Map.of());
}
@Nullable
@@ -213,6 +291,17 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
var attachment = new Attachment();
attachment.setMetadata(metadata);
attachment.setSpec(spec);
attachment.setStatus(new AttachmentStatus());
doGetPermalink(attachment, properties).ifPresent(permalink ->
attachment.getStatus().setPermalink(permalink.toString())
);
var thumbnails = doGetThumbnailLinks(attachment, properties);
var mappedThumbnails = thumbnails.keySet()
.stream()
.collect(Collectors.toMap(ThumbnailSize::name, size -> thumbnails.get(size).toString()));
if (!mappedThumbnails.isEmpty()) {
attachment.getStatus().setThumbnails(mappedThumbnails);
}
log.info("Built attachment {} successfully", objectDetail.uploadState.objectKey);
return attachment;
}
@@ -369,8 +458,8 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
if (uploadingFile.put(uploadState.getUploadingMapKey(),
uploadState.getUploadingMapKey()) != null) {
return Mono.error(new FileAlreadyExistsException("文件 " + uploadState.objectKey
+
" 已存在,建议更名后重试。[local]"));
+
" 已存在,建议更名后重试。[local]"));
}
uploadState.needRemoveMapKey = true;
// check whether file exists
@@ -388,7 +477,7 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
&& response.sdkHttpResponse().isSuccessful()) {
return Mono.error(
new FileAlreadyExistsException("文件 " + uploadState.objectKey
+ " 已存在,建议更名后重试。[remote]"));
+ " 已存在,建议更名后重试。[remote]"));
} else {
return Mono.just(uploadState);
}
@@ -463,18 +552,29 @@ public class S3OsAttachmentHandler implements AttachmentHandler {
}
record ObjectDetail(UploadState uploadState, HeadObjectResponse objectMetadata) {
}
static class UploadState {
final S3OsProperties properties;
final String originalFileName;
String uploadId;
int partCounter;
Map<Integer, CompletedPart> completedParts = new HashMap<>();
int buffered = 0;
String contentType;
String fileName;
String objectKey;
boolean needRemoveMapKey = false;
public UploadState(S3OsProperties properties, String fileName, boolean needRandomJudge) {

View File

@@ -1,106 +0,0 @@
package run.halo.s3os;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailProvider;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
@Component
@RequiredArgsConstructor
public class S3ThumbnailProvider implements ThumbnailProvider {
static final String WIDTH_PLACEHOLDER = "{width}";
private final Cache<String, S3PropsCacheValue> s3PropsCache = CacheBuilder.newBuilder()
.maximumSize(50)
.build();
private final ReactiveExtensionClient client;
private final S3LinkService s3LinkService;
@Override
public Mono<URI> generate(ThumbnailContext thumbnailContext) {
var url = thumbnailContext.getImageUrl().toString();
var size = thumbnailContext.getSize();
return getCacheValue(url)
.mapNotNull(cacheValue -> placedPattern(cacheValue.pattern(), size))
.map(param -> {
if (param.startsWith("?")) {
return UriComponentsBuilder.fromHttpUrl(url)
.queryParam(param.substring(1))
.build()
.toString();
}
return url + param;
})
.map(URI::create);
}
private static String placedPattern(String pattern, ThumbnailSize size) {
return StringUtils.replace(pattern, WIDTH_PLACEHOLDER, String.valueOf(size.getWidth()));
}
@Override
public Mono<Void> delete(URL url) {
// do nothing for s3
return Mono.empty();
}
@Override
public Mono<Boolean> supports(ThumbnailContext thumbnailContext) {
var url = thumbnailContext.getImageUrl().toString();
return getCacheValue(url).hasElement();
}
private Mono<S3PropsCacheValue> getCacheValue(String imageUrl) {
return Flux.fromIterable(s3PropsCache.asMap().entrySet())
.filter(entry -> imageUrl.startsWith(entry.getKey()))
.next()
.map(Map.Entry::getValue)
.switchIfEmpty(Mono.defer(() -> listAllS3ObjectDomain()
.filter(entry -> imageUrl.startsWith(entry.getKey()))
.map(Map.Entry::getValue)
.next()
));
}
@Builder
record S3PropsCacheValue(String pattern, String configMapName) {
}
private Flux<Map.Entry<String, S3PropsCacheValue>> listAllS3ObjectDomain() {
return s3LinkService.listS3Policies()
.flatMap(s3Policy -> {
var s3ConfigMapName = s3Policy.getSpec().getConfigMapName();
return fetchS3PropsByConfigMapName(s3ConfigMapName)
.mapNotNull(properties -> {
var thumbnailParam = properties.getThumbnailParamPattern();
if (StringUtils.isBlank(thumbnailParam)) {
return null;
}
var objectDomain = properties.toObjectURL("");
var cacheValue = S3PropsCacheValue.builder()
.pattern(thumbnailParam)
.configMapName(s3ConfigMapName)
.build();
return Map.entry(objectDomain, cacheValue);
});
})
.doOnNext(cache -> s3PropsCache.put(cache.getKey(), cache.getValue()));
}
private Mono<S3OsProperties> fetchS3PropsByConfigMapName(String name) {
return client.fetch(ConfigMap.class, name)
.map(S3OsProperties::convertFrom);
}
}

View File

@@ -4,7 +4,7 @@ metadata:
name: PluginS3ObjectStorage
spec:
enabled: true
requires: ">=2.21.0"
requires: ">=2.22.0"
author:
name: Halo
website: https://github.com/halo-dev

View File

@@ -1,5 +1,22 @@
package run.halo.s3os;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -7,15 +24,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.attachment.Policy;
class S3OsAttachmentHandlerTest {
S3OsAttachmentHandler handler;
@@ -93,4 +101,112 @@ class S3OsAttachmentHandlerTest {
})
.verifyComplete();
}
@Test
void shouldGetThumbnailsIfPatternIsQueryParam() {
var attachment = createAttachment("https://s3.halo.run/halo.png?existing-query=existing-value");
var policy = new Policy();
policy.setSpec(new Policy.PolicySpec());
policy.getSpec().setTemplateName("s3os");
var configMap = new ConfigMap();
configMap.setData(new HashMap<>());
configMap.getData().put("default", """
{
"thumbnailParamPattern": "?width={width}&quality=80"
}
""");
handler.getThumbnailLinks(attachment, policy, configMap)
.as(StepVerifier::create)
.expectNext(Map.of(
ThumbnailSize.S, URI.create("https://s3.halo.run/halo.png?existing-query=existing-value&width=400&quality=80"),
ThumbnailSize.M, URI.create("https://s3.halo.run/halo.png?existing-query=existing-value&width=800&quality=80"),
ThumbnailSize.L, URI.create("https://s3.halo.run/halo.png?existing-query=existing-value&width=1200&quality=80"),
ThumbnailSize.XL, URI.create("https://s3.halo.run/halo.png?existing-query=existing-value&width=1600&quality=80")
))
.verifyComplete();
}
@Test
void shouldGetThumbnailsIfPatternIsPath() {
var attachment = createAttachment("https://s3.halo.run/existing-path/halo.png");
var policy = new Policy();
policy.setSpec(new Policy.PolicySpec());
policy.getSpec().setTemplateName("s3os");
var configMap = new ConfigMap();
configMap.setData(new HashMap<>());
configMap.getData().put("default", """
{
"thumbnailParamPattern": "!path/width/{width}"
}
""");
handler.getThumbnailLinks(attachment, policy, configMap)
.as(StepVerifier::create)
.expectNext(Map.of(
ThumbnailSize.S, URI.create("https://s3.halo.run/existing-path/halo.png!path/width/400"),
ThumbnailSize.M, URI.create("https://s3.halo.run/existing-path/halo.png!path/width/800"),
ThumbnailSize.L, URI.create("https://s3.halo.run/existing-path/halo.png!path/width/1200"),
ThumbnailSize.XL, URI.create("https://s3.halo.run/existing-path/halo.png!path/width/1600")
))
.verifyComplete();
}
@Test
void shouldGetEmptyThumbnailsIfNoPattern() {
var attachment = createAttachment("https://s3.halo.run/halo.png");
var policy = new Policy();
policy.setSpec(new Policy.PolicySpec());
policy.getSpec().setTemplateName("s3os");
var configMap = new ConfigMap();
configMap.setData(new HashMap<>());
configMap.getData().put("default", """
{}
""");
handler.getThumbnailLinks(attachment, policy, configMap)
.as(StepVerifier::create)
.expectNext(Map.of())
.verifyComplete();
}
@Test
void shouldGetEmptyThumbnailsIfNotImage() {
var attachment = createAttachment("application/pdf", "https://s3.halo.run/halo.pdf");
var policy = new Policy();
policy.setSpec(new Policy.PolicySpec());
policy.getSpec().setTemplateName("s3os");
var configMap = new ConfigMap();
configMap.setData(new HashMap<>());
configMap.getData().put("default", """
{
"thumbnailParamPattern": "!path/width/{width}"
}
""");
handler.getThumbnailLinks(attachment, policy, configMap)
.as(StepVerifier::create)
.expectNext(Map.of())
.verifyComplete();
}
static Attachment createAttachment(String permalink) {
return createAttachment("image/png", permalink);
}
static Attachment createAttachment(String mediaType, String permalink) {
var attachment = new Attachment();
attachment.setMetadata(new Metadata());
attachment.getMetadata().setName("fake-attachment");
attachment.setSpec(new Attachment.AttachmentSpec());
attachment.getSpec().setMediaType(mediaType);
attachment.setStatus(new Attachment.AttachmentStatus());
attachment.getStatus().setPermalink(permalink);
return attachment;
}
}