diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java
index 8c56ccbc8..e21b2b64c 100644
--- a/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/Attachment.java
@@ -56,7 +56,11 @@ public class Attachment extends AbstractExtension {
@Data
public static class AttachmentStatus {
- @Schema(description = "Permalink of attachment")
+ @Schema(description = """
+ Permalink of attachment.
+ If it is in local storage, the public URL will be set.
+ If it is in s3 storage, the Object URL will be set.
+ """)
private String permalink;
}
diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java b/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java
index 0c32d3cb8..061525abf 100644
--- a/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/Constant.java
@@ -1,5 +1,7 @@
package run.halo.app.core.extension.attachment;
+import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
+
public enum Constant {
;
@@ -14,6 +16,13 @@ public enum Constant {
*/
public static final String URI_ANNO_KEY = GROUP + "/uri";
+ /**
+ * Do not use this key to set external link. You could implement
+ * {@link AttachmentHandler#getPermalink} by your self.
+ *
+ *
+ * @deprecated Use your own group instead.
+ */
public static final String EXTERNAL_LINK_ANNO_KEY = GROUP + "/external-link";
public static final String FINALIZER_NAME = "attachment-manager";
diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java
index fbc371c5f..38369f7a8 100644
--- a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentHandler.java
@@ -1,5 +1,7 @@
package run.halo.app.core.extension.attachment.endpoint;
+import java.net.URI;
+import java.time.Duration;
import org.pf4j.ExtensionPoint;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
@@ -13,6 +15,44 @@ public interface AttachmentHandler extends ExtensionPoint {
Mono delete(DeleteContext context);
+ /**
+ * Gets a shared URL which could be accessed publicly.
+ * 1. If the attachment is in local storage, the permalink will be returned.
+ * 2. If the attachment is in s3 storage, the Presigned URL will be returned.
+ *
+ * Please note that the default implementation is only for back compatibility.
+ *
+ * @param attachment contains detail of attachment.
+ * @param policy is storage policy.
+ * @param configMap contains configuration needed by handler.
+ * @param ttl indicates how long the URL is alive.
+ * @return shared URL which could be accessed publicly. Might be relative URL.
+ */
+ default Mono getSharedURL(Attachment attachment,
+ Policy policy,
+ ConfigMap configMap,
+ Duration ttl) {
+ return Mono.empty();
+ }
+
+ /**
+ * Gets a permalink representing a unique attachment.
+ * If the attachment is in local storage, the permalink will be returned.
+ * If the attachment is in s3 storage, the Object URL will be returned.
+ *
+ * Please note that the default implementation is only for back compatibility.
+ *
+ * @param attachment contains detail of attachment.
+ * @param policy is storage policy.
+ * @param configMap contains configuration needed by handler.
+ * @return permalink representing a unique attachment. Might be relative URL.
+ */
+ default Mono getPermalink(Attachment attachment,
+ Policy policy,
+ ConfigMap configMap) {
+ return Mono.empty();
+ }
+
interface UploadContext {
FilePart file();
@@ -31,12 +71,4 @@ public interface AttachmentHandler extends ExtensionPoint {
ConfigMap configMap();
}
- record UploadOption(FilePart file,
- Policy policy,
- ConfigMap configMap) implements UploadContext {
- }
-
- record DeleteOption(Attachment attachment, Policy policy, ConfigMap configMap)
- implements DeleteContext {
- }
}
diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java
new file mode 100644
index 000000000..1235954e4
--- /dev/null
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/DeleteOption.java
@@ -0,0 +1,9 @@
+package run.halo.app.core.extension.attachment.endpoint;
+
+import run.halo.app.core.extension.attachment.Attachment;
+import run.halo.app.core.extension.attachment.Policy;
+import run.halo.app.extension.ConfigMap;
+
+public record DeleteOption(Attachment attachment, Policy policy, ConfigMap configMap)
+ implements AttachmentHandler.DeleteContext {
+}
diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java
new file mode 100644
index 000000000..db7427087
--- /dev/null
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java
@@ -0,0 +1,40 @@
+package run.halo.app.core.extension.attachment.endpoint;
+
+import java.nio.file.Path;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.multipart.FilePart;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * SimpleFilePart is an adapter of simple data for uploading.
+ *
+ * @param filename is name of the attachment file.
+ * @param content is binary data of the attachment file.
+ * @param mediaType is media type of the attachment file.
+ */
+record SimpleFilePart(
+ String filename,
+ Flux content,
+ MediaType mediaType
+) implements FilePart {
+ @Override
+ public Mono transferTo(Path dest) {
+ return DataBufferUtils.write(content(), dest);
+ }
+
+ @Override
+ public String name() {
+ return filename();
+ }
+
+ @Override
+ public HttpHeaders headers() {
+ var headers = new HttpHeaders();
+ headers.setContentType(mediaType);
+ return HttpHeaders.readOnlyHttpHeaders(headers);
+ }
+}
diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java
new file mode 100644
index 000000000..0a050ef9b
--- /dev/null
+++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/UploadOption.java
@@ -0,0 +1,23 @@
+package run.halo.app.core.extension.attachment.endpoint;
+
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.multipart.FilePart;
+import reactor.core.publisher.Flux;
+import run.halo.app.core.extension.attachment.Policy;
+import run.halo.app.extension.ConfigMap;
+
+public record UploadOption(FilePart file,
+ Policy policy,
+ ConfigMap configMap) implements AttachmentHandler.UploadContext {
+
+ public static UploadOption from(String filename,
+ Flux content,
+ MediaType mediaType,
+ Policy policy,
+ ConfigMap configMap) {
+ var filePart = new SimpleFilePart(filename, content, mediaType);
+ return new UploadOption(filePart, policy, configMap);
+ }
+
+}
diff --git a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java
new file mode 100644
index 000000000..710f28172
--- /dev/null
+++ b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java
@@ -0,0 +1,73 @@
+package run.halo.app.core.extension.service;
+
+import java.net.URI;
+import java.time.Duration;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.attachment.Attachment;
+
+/**
+ * AttachmentService
+ *
+ * @author johnniang
+ * @since 2.5.0
+ */
+public interface AttachmentService {
+
+ /**
+ * Uploads the given attachment to specific storage using handlers in plugins. Please note
+ * that we will make sure the request is authenticated, or an unauthorized exception throws.
+ *
+ * If no handler can be found to upload the given attachment, ServerError exception will be
+ * thrown.
+ *
+ * @param policyName is attachment policy name.
+ * @param groupName is group name the attachment belongs.
+ * @param filename is filename of the attachment.
+ * @param content is binary data of the attachment.
+ * @param mediaType is media type of the attachment.
+ * @return attachment.
+ */
+ Mono upload(@NonNull String policyName,
+ @Nullable String groupName,
+ @NonNull String filename,
+ @NonNull Flux content,
+ @Nullable MediaType mediaType);
+
+ /**
+ * Deletes an attachment using handlers in plugins.
+ *
+ * If no handler can be found to delete the given attachment, Mono.empty() will return.
+ *
+ * @param attachment is to be deleted.
+ * @return deleted attachment.
+ */
+ Mono delete(Attachment attachment);
+
+ /**
+ * Gets permalink using handlers in plugins.
+ *
+ * If no handler can be found to delete the given attachment, Mono.empty() will return.
+ *
+ * @param attachment is created attachment.
+ * @return permalink
+ */
+ Mono getPermalink(Attachment attachment);
+
+ /**
+ * Gets shared URL using handlers in plugins.
+ *
+ * If no handler can be found to delete the given attachment, Mono.empty() will return.
+ *
+ * @param attachment is created attachment.
+ * @param ttl is time to live of the shared URL.
+ * @return time-to-live shared URL. Please note that, if the attachment is stored in local, the
+ * shared URL is equal to permalink.
+ */
+ Mono getSharedURL(Attachment attachment, Duration ttl);
+
+}
diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java
index b1598df86..dbebf1a93 100644
--- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java
+++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java
@@ -5,7 +5,6 @@ import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance;
-import static org.springframework.web.reactive.function.BodyExtractors.toMultipartData;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static run.halo.app.extension.ListResult.generateGenericClass;
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
@@ -23,56 +22,61 @@ import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.fn.builders.requestbody.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.data.domain.Sort;
-import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.ReactiveSecurityContextHolder;
-import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
+import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
-import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
-import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.attachment.Attachment;
-import run.halo.app.core.extension.attachment.Policy;
-import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler.UploadOption;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.endpoint.SortResolver;
+import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.Comparators;
-import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.IListRequest.QueryListRequest;
-import run.halo.app.plugin.ExtensionComponentsFinder;
@Slf4j
@Component
public class AttachmentEndpoint implements CustomEndpoint {
+ private final AttachmentService attachmentService;
+
private final ReactiveExtensionClient client;
- private final ExtensionComponentsFinder extensionComponentsFinder;
-
- public AttachmentEndpoint(ReactiveExtensionClient client,
- ExtensionComponentsFinder extensionComponentsFinder) {
+ public AttachmentEndpoint(AttachmentService attachmentService,
+ ReactiveExtensionClient client) {
+ this.attachmentService = attachmentService;
this.client = client;
- this.extensionComponentsFinder = extensionComponentsFinder;
}
@Override
public RouterFunction endpoint() {
var tag = "api.console.halo.run/v1alpha1/Attachment";
return SpringdocRouteBuilder.route()
- .POST("/attachments/upload", contentType(MediaType.MULTIPART_FORM_DATA), this::upload,
+ .POST("/attachments/upload", contentType(MediaType.MULTIPART_FORM_DATA),
+ request -> request.body(BodyExtractors.toMultipartData())
+ .map(UploadRequest::new)
+ .flatMap(uploadReq -> {
+ var policyName = uploadReq.getPolicyName();
+ var groupName = uploadReq.getGroupName();
+ var filePart = uploadReq.getFile();
+ return attachmentService.upload(policyName,
+ groupName,
+ filePart.filename(),
+ filePart.content(),
+ filePart.headers().getContentType());
+ })
+ .flatMap(attachment -> ServerResponse.ok().bodyValue(attachment)),
builder -> builder
.operationId("UploadAttachment")
.tag(tag)
@@ -98,56 +102,6 @@ public class AttachmentEndpoint implements CustomEndpoint {
.build();
}
- Mono upload(ServerRequest request) {
- return ReactiveSecurityContextHolder.getContext()
- .switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,
- "Please login first and try it again")))
- .map(SecurityContext::getAuthentication)
- .map(Authentication::getName)
- .flatMap(username -> request.body(toMultipartData())
- .map(UploadRequest::new)
- // prepare the upload option
- .flatMap(uploadRequest -> client.get(Policy.class, uploadRequest.getPolicyName())
- .filter(policy -> StringUtils.hasText(policy.getSpec().getConfigMapName()))
- .switchIfEmpty(Mono.error(() -> new ServerWebInputException(
- "Please configure the attachment policy before uploading")))
- .flatMap(policy -> {
- var configMapName = policy.getSpec().getConfigMapName();
- return client.get(ConfigMap.class, configMapName)
- .map(configMap -> new UploadOption(uploadRequest.getFile(), policy,
- configMap));
- })
- // find the proper handler to handle the attachment
- .flatMap(uploadOption -> Flux.fromIterable(
- extensionComponentsFinder.getExtensions(AttachmentHandler.class))
- .concatMap(uploadHandler -> uploadHandler.upload(uploadOption)
- .doOnNext(attachment -> {
- var spec = attachment.getSpec();
- if (spec == null) {
- spec = new Attachment.AttachmentSpec();
- attachment.setSpec(spec);
- }
- spec.setOwnerName(username);
- spec.setPolicyName(uploadOption.policy().getMetadata().getName());
- var groupName = uploadRequest.getGroupName();
- if (groupName != null) {
- // validate the group name
- spec.setGroupName(groupName);
- }
- }))
- .next()
- .switchIfEmpty(Mono.error(
- () -> new ResponseStatusException(
- HttpStatus.INTERNAL_SERVER_ERROR,
- "No suitable handler found for uploading the attachment"))))
- )
- // create the attachment
- .flatMap(client::create)
- .flatMap(attachment -> ServerResponse.ok()
- .contentType(MediaType.APPLICATION_JSON)
- .bodyValue(attachment)));
- }
-
Mono search(ServerRequest request) {
var searchRequest = new SearchRequest(request);
return client.list(Attachment.class,
diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
index 45608eb3c..283ea4432 100644
--- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
+++ b/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java
@@ -5,10 +5,12 @@ import static run.halo.app.infra.utils.FileNameUtils.randomFileName;
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
import java.io.IOException;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Map;
import java.util.Optional;
@@ -22,6 +24,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -31,7 +34,9 @@ import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
import run.halo.app.core.extension.attachment.Constant;
import run.halo.app.core.extension.attachment.Policy;
+import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
+import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.AttachmentAlreadyExistsException;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
@@ -42,8 +47,12 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
private final HaloProperties haloProp;
- public LocalAttachmentUploadHandler(HaloProperties haloProp) {
+ private final ExternalUrlSupplier externalUrl;
+
+ public LocalAttachmentUploadHandler(HaloProperties haloProp,
+ ExternalUrlSupplier externalUrl) {
this.haloProp = haloProp;
+ this.externalUrl = externalUrl;
}
Path getAttachmentsRoot() {
@@ -153,6 +162,35 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
.map(DeleteContext::attachment);
}
+ @Override
+ public Mono getPermalink(Attachment attachment, Policy policy, ConfigMap configMap) {
+ if (!this.shouldHandle(policy)) {
+ return Mono.empty();
+ }
+ var annotations = attachment.getMetadata().getAnnotations();
+ if (annotations == null
+ || !annotations.containsKey(Constant.URI_ANNO_KEY)) {
+ return Mono.empty();
+ }
+ var uriStr = annotations.get(Constant.URI_ANNO_KEY);
+ // the uriStr is encoded before.
+ uriStr = UriUtils.decode(uriStr, StandardCharsets.UTF_8);
+ var uri = UriComponentsBuilder.fromUri(externalUrl.get())
+ // The URI has been encoded before, so there is no need to encode it again.
+ .path(uriStr)
+ .build()
+ .toUri();
+ return Mono.just(uri);
+ }
+
+ @Override
+ public Mono getSharedURL(Attachment attachment,
+ Policy policy,
+ ConfigMap configMap,
+ Duration ttl) {
+ return getPermalink(attachment, policy, configMap);
+ }
+
private boolean shouldHandle(Policy policy) {
if (policy == null
|| policy.getSpec() == null
diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java
index d9aa34c59..f1ef94fcb 100644
--- a/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java
+++ b/application/src/main/java/run/halo/app/core/extension/reconciler/attachment/AttachmentReconciler.java
@@ -1,28 +1,22 @@
package run.halo.app.core.extension.reconciler.attachment;
+import java.net.URI;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
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.extension.attachment.Attachment;
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;
-import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler.DeleteOption;
-import run.halo.app.extension.ConfigMap;
+import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.ExtensionClient;
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.extension.controller.Reconciler.Request;
import run.halo.app.infra.ExternalUrlSupplier;
-import run.halo.app.infra.exception.NotFoundException;
-import run.halo.app.plugin.ExtensionComponentsFinder;
@Slf4j
@Component
@@ -30,16 +24,15 @@ public class AttachmentReconciler implements Reconciler {
private final ExtensionClient client;
- private final ExtensionComponentsFinder extensionComponentsFinder;
-
private final ExternalUrlSupplier externalUrl;
+ private final AttachmentService attachmentService;
+
public AttachmentReconciler(ExtensionClient client,
- ExtensionComponentsFinder extensionComponentsFinder,
- ExternalUrlSupplier externalUrl) {
+ ExternalUrlSupplier externalUrl, AttachmentService attachmentService) {
this.client = client;
- this.extensionComponentsFinder = extensionComponentsFinder;
this.externalUrl = externalUrl;
+ this.attachmentService = attachmentService;
}
@Override
@@ -47,46 +40,33 @@ public class AttachmentReconciler implements Reconciler {
client.fetch(Attachment.class, request.name()).ifPresent(attachment -> {
// TODO Handle the finalizer
if (attachment.getMetadata().getDeletionTimestamp() != null) {
- Policy policy = client.fetch(Policy.class, attachment.getSpec().getPolicyName())
- .orElseThrow();
- var configMap = client.fetch(ConfigMap.class, policy.getSpec().getConfigMapName())
- .orElseThrow();
- var deleteOption = new DeleteOption(attachment, policy, configMap);
- Flux.fromIterable(extensionComponentsFinder.getExtensions(AttachmentHandler.class))
- .concatMap(handler -> handler.delete(deleteOption)).next().switchIfEmpty(
- Mono.error(() -> new NotFoundException(
- "No suitable handler found to delete the attachment")))
- .doOnNext(deleted -> removeFinalizer(deleted.getMetadata().getName())).block();
+ attachmentService.delete(attachment)
+ .doOnNext(deletedAttachment -> {
+ removeFinalizer(attachment.getMetadata().getName());
+ })
+ .blockOptional();
return;
}
// add finalizer
addFinalizerIfNotSet(request.name(), attachment.getMetadata().getFinalizers());
-
var annotations = attachment.getMetadata().getAnnotations();
if (annotations != null) {
- String permalink = null;
- var uri = annotations.get(Constant.URI_ANNO_KEY);
- if (uri != null) {
- permalink = UriComponentsBuilder.fromUri(externalUrl.get())
- // The URI has been encoded before, so there is no need to encode it again.
- .path(uri)
- .build()
- .toString();
- } else {
- var externalLink = annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY);
- if (externalLink != null) {
- permalink = externalLink;
- }
- }
- if (permalink != null) {
- log.debug("Set permalink {} for attachment {}", permalink, request.name());
- var status = attachment.getStatus();
- if (status == null) {
- status = new AttachmentStatus();
- attachment.setStatus(status);
- }
- status.setPermalink(permalink);
- }
+ attachmentService.getPermalink(attachment)
+ .map(URI::toString)
+ .switchIfEmpty(Mono.fromSupplier(() -> {
+ // Only for back-compatibility
+ return annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY);
+ }))
+ .doOnNext(permalink -> {
+ log.debug("Set permalink {} for attachment {}", permalink, request.name());
+ var status = attachment.getStatus();
+ if (status == null) {
+ status = new AttachmentStatus();
+ attachment.setStatus(status);
+ }
+ status.setPermalink(permalink);
+ })
+ .blockOptional();
}
updateStatus(request.name(), attachment.getStatus());
});
diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java
new file mode 100644
index 000000000..ee7d48f06
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java
@@ -0,0 +1,128 @@
+package run.halo.app.core.extension.service.impl;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.function.Function;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerErrorException;
+import org.springframework.web.server.ServerWebInputException;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.attachment.Attachment;
+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.DeleteOption;
+import run.halo.app.core.extension.attachment.endpoint.UploadOption;
+import run.halo.app.core.extension.service.AttachmentService;
+import run.halo.app.extension.ConfigMap;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.plugin.ExtensionComponentsFinder;
+
+@Component
+public class DefaultAttachmentService implements AttachmentService {
+
+ private final ReactiveExtensionClient client;
+
+ private final ExtensionComponentsFinder extensionComponentsFinder;
+
+ public DefaultAttachmentService(ReactiveExtensionClient client,
+ ExtensionComponentsFinder extensionComponentsFinder) {
+ this.client = client;
+ this.extensionComponentsFinder = extensionComponentsFinder;
+ }
+
+ @Override
+ public Mono upload(@NonNull String policyName,
+ @Nullable String groupName,
+ @NonNull String filename,
+ @NonNull Flux content,
+ @Nullable MediaType mediaType) {
+ return authenticationConsumer(authentication -> client.get(Policy.class, policyName)
+ .flatMap(policy -> {
+ var configMapName = policy.getSpec().getConfigMapName();
+ if (!StringUtils.hasText(configMapName)) {
+ return Mono.error(new ServerWebInputException(
+ "ConfigMap name not found in Policy " + policyName));
+ }
+ return client.get(ConfigMap.class, configMapName)
+ .map(configMap -> UploadOption.from(filename,
+ content,
+ mediaType,
+ policy,
+ configMap));
+ })
+ .flatMap(uploadContext -> {
+ var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class);
+ return Flux.fromIterable(handlers)
+ .concatMap(handler -> handler.upload(uploadContext))
+ .next();
+ })
+ .switchIfEmpty(Mono.error(() -> new ServerErrorException(
+ "No suitable handler found for uploading the attachment.", null)))
+ .doOnNext(attachment -> {
+ var spec = attachment.getSpec();
+ if (spec == null) {
+ spec = new Attachment.AttachmentSpec();
+ attachment.setSpec(spec);
+ }
+ spec.setOwnerName(authentication.getName());
+ if (StringUtils.hasText(groupName)) {
+ spec.setGroupName(groupName);
+ }
+ spec.setPolicyName(policyName);
+ })
+ .flatMap(client::create));
+ }
+
+ @Override
+ public Mono delete(Attachment attachment) {
+ var spec = attachment.getSpec();
+ return client.get(Policy.class, spec.getPolicyName())
+ .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())
+ .map(configMap -> new DeleteOption(attachment, policy, configMap)))
+ .flatMap(deleteOption -> {
+ var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class);
+ return Flux.fromIterable(handlers)
+ .concatMap(handler -> handler.delete(deleteOption))
+ .next();
+ });
+ }
+
+ @Override
+ public Mono getPermalink(Attachment attachment) {
+ var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class);
+ return client.get(Policy.class, attachment.getSpec().getPolicyName())
+ .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())
+ .flatMap(configMap -> Flux.fromIterable(handlers)
+ .concatMap(handler -> handler.getPermalink(attachment, policy, configMap))
+ .next()));
+ }
+
+ @Override
+ public Mono getSharedURL(Attachment attachment, Duration ttl) {
+ var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class);
+ return client.get(Policy.class, attachment.getSpec().getPolicyName())
+ .flatMap(policy -> client.get(ConfigMap.class, policy.getSpec().getConfigMapName())
+ .flatMap(configMap -> Flux.fromIterable(handlers)
+ .concatMap(handler -> handler.getSharedURL(attachment, policy, configMap, ttl))
+ .next()));
+ }
+
+ private Mono authenticationConsumer(Function> func) {
+ return ReactiveSecurityContextHolder.getContext()
+ .switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,
+ "Authentication required.")))
+ .map(SecurityContext::getAuthentication)
+ .flatMap(func);
+ }
+}
diff --git a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextHolder.java b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextHolder.java
index 19b342075..3559c4140 100644
--- a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextHolder.java
+++ b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextHolder.java
@@ -4,6 +4,7 @@ import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component;
+import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
@@ -67,6 +68,8 @@ public class SharedApplicationContextHolder {
rootApplicationContext.getBean(ExternalUrlSupplier.class));
beanFactory.registerSingleton("serverSecurityContextRepository",
rootApplicationContext.getBean(ServerSecurityContextRepository.class));
+ beanFactory.registerSingleton("attachmentService",
+ rootApplicationContext.getBean(AttachmentService.class));
// TODO add more shared instance here
return sharedApplicationContext;
diff --git a/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java
index 63610887b..e23f5176f 100644
--- a/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java
+++ b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser;
@@ -13,11 +14,11 @@ import static org.springframework.security.test.web.reactive.server.SecurityMock
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import org.junit.jupiter.api.BeforeEach;
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.springframework.http.MediaType;
@@ -33,6 +34,7 @@ import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
import run.halo.app.core.extension.attachment.Policy;
import run.halo.app.core.extension.attachment.Policy.PolicySpec;
import run.halo.app.core.extension.attachment.endpoint.AttachmentEndpoint.SearchRequest;
+import run.halo.app.core.extension.service.impl.DefaultAttachmentService;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
@@ -48,13 +50,14 @@ class AttachmentEndpointTest {
@Mock
ExtensionComponentsFinder extensionComponentsFinder;
- @InjectMocks
AttachmentEndpoint endpoint;
WebTestClient webClient;
@BeforeEach
void setUp() {
+ var attachmentService = new DefaultAttachmentService(client, extensionComponentsFinder);
+ endpoint = new AttachmentEndpoint(attachmentService, client);
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
.apply(springSecurity())
.build();
@@ -65,12 +68,44 @@ class AttachmentEndpointTest {
@Test
void shouldResponseErrorIfNotLogin() {
+ var policySpec = new PolicySpec();
+ policySpec.setConfigMapName("fake-configmap");
+ var policyMetadata = new Metadata();
+ policyMetadata.setName("fake-policy");
+ var policy = new Policy();
+ policy.setSpec(policySpec);
+ policy.setMetadata(policyMetadata);
+
+ var cm = new ConfigMap();
+ var cmMetadata = new Metadata();
+ cmMetadata.setName("fake-configmap");
+ cm.setData(Map.of());
+
+ var handler = mock(AttachmentHandler.class);
+ var metadata = new Metadata();
+ metadata.setName("fake-attachment");
+ var attachment = new Attachment();
+ attachment.setMetadata(metadata);
+
+ var builder = new MultipartBodyBuilder();
+ builder.part("policyName", "fake-policy");
+ builder.part("groupName", "fake-group");
+ builder.part("file", "fake-file")
+ .contentType(MediaType.TEXT_PLAIN)
+ .filename("fake-filename");
webClient
.post()
.uri("/attachments/upload")
.contentType(MediaType.MULTIPART_FORM_DATA)
+ .body(BodyInserters.fromMultipartData(builder.build()))
.exchange()
.expectStatus().isUnauthorized();
+
+ verify(client, never()).get(Policy.class, "fake-policy");
+ verify(client, never()).get(ConfigMap.class, "fake-configmap");
+ verify(client, never()).create(attachment);
+ verify(extensionComponentsFinder, never()).getExtensions(AttachmentHandler.class);
+ verify(handler, never()).upload(any());
}
@Test
@@ -100,6 +135,44 @@ class AttachmentEndpointTest {
.expectStatus().isBadRequest();
}
+ void prepareForUploading(Consumer consumer) {
+ var policySpec = new PolicySpec();
+ policySpec.setConfigMapName("fake-configmap");
+ var policyMetadata = new Metadata();
+ policyMetadata.setName("fake-policy");
+ var policy = new Policy();
+ policy.setSpec(policySpec);
+ policy.setMetadata(policyMetadata);
+
+ var cm = new ConfigMap();
+ var cmMetadata = new Metadata();
+ cmMetadata.setName("fake-configmap");
+ cm.setData(Map.of());
+
+ when(client.get(Policy.class, "fake-policy")).thenReturn(Mono.just(policy));
+ when(client.get(ConfigMap.class, "fake-configmap")).thenReturn(Mono.just(cm));
+
+ var handler = mock(AttachmentHandler.class);
+ var metadata = new Metadata();
+ metadata.setName("fake-attachment");
+ var attachment = new Attachment();
+ attachment.setMetadata(metadata);
+
+ when(handler.upload(any())).thenReturn(Mono.just(attachment));
+ when(extensionComponentsFinder.getExtensions(AttachmentHandler.class)).thenReturn(
+ List.of(handler));
+ when(client.create(attachment)).thenReturn(Mono.just(attachment));
+
+ var builder = new MultipartBodyBuilder();
+ builder.part("policyName", "fake-policy");
+ builder.part("groupName", "fake-group");
+ builder.part("file", "fake-file")
+ .contentType(MediaType.TEXT_PLAIN)
+ .filename("fake-filename");
+
+ consumer.accept(builder);
+ }
+
@Test
void shouldUploadSuccessfully() {
var policySpec = new PolicySpec();