Expose attachment service to plugin (#3740)

#### What type of PR is this?

/kind feature
/area core
/area plugin

#### What this PR does / why we need it:

This PR refactor AttachmentEndpoint by extracting `upload`, `delete`, `getPremalink` and `getSharedURL` logic in the endpoint into AttachmentService. Meanwhile, I expose the service to plugin, so that we can use the service in plugin conveniently.

#### Special notes for your reviewer:

Please confirm that those changes won't influence existing attachment features.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/3756/head
John Niang 2023-04-14 17:26:49 +08:00 committed by GitHub
parent d1651aa671
commit d760d4d362
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 492 additions and 126 deletions

View File

@ -56,7 +56,11 @@ public class Attachment extends AbstractExtension {
@Data @Data
public static class AttachmentStatus { 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; private String permalink;
} }

View File

@ -1,5 +1,7 @@
package run.halo.app.core.extension.attachment; package run.halo.app.core.extension.attachment;
import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler;
public enum Constant { public enum Constant {
; ;
@ -14,6 +16,13 @@ public enum Constant {
*/ */
public static final String URI_ANNO_KEY = GROUP + "/uri"; 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.
* <p>
*
* @deprecated Use your own group instead.
*/
public static final String EXTERNAL_LINK_ANNO_KEY = GROUP + "/external-link"; public static final String EXTERNAL_LINK_ANNO_KEY = GROUP + "/external-link";
public static final String FINALIZER_NAME = "attachment-manager"; public static final String FINALIZER_NAME = "attachment-manager";

View File

@ -1,5 +1,7 @@
package run.halo.app.core.extension.attachment.endpoint; package run.halo.app.core.extension.attachment.endpoint;
import java.net.URI;
import java.time.Duration;
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;
@ -13,6 +15,44 @@ public interface AttachmentHandler extends ExtensionPoint {
Mono<Attachment> delete(DeleteContext context); Mono<Attachment> 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.
* <p>
* 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<URI> 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.
* <p>
* 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<URI> getPermalink(Attachment attachment,
Policy policy,
ConfigMap configMap) {
return Mono.empty();
}
interface UploadContext { interface UploadContext {
FilePart file(); FilePart file();
@ -31,12 +71,4 @@ public interface AttachmentHandler extends ExtensionPoint {
ConfigMap configMap(); ConfigMap configMap();
} }
record UploadOption(FilePart file,
Policy policy,
ConfigMap configMap) implements UploadContext {
}
record DeleteOption(Attachment attachment, Policy policy, ConfigMap configMap)
implements DeleteContext {
}
} }

View File

@ -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 {
}

View File

@ -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<DataBuffer> content,
MediaType mediaType
) implements FilePart {
@Override
public Mono<Void> 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);
}
}

View File

@ -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<DataBuffer> content,
MediaType mediaType,
Policy policy,
ConfigMap configMap) {
var filePart = new SimpleFilePart(filename, content, mediaType);
return new UploadOption(filePart, policy, configMap);
}
}

View File

@ -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.
* <p>
* 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<Attachment> upload(@NonNull String policyName,
@Nullable String groupName,
@NonNull String filename,
@NonNull Flux<DataBuffer> content,
@Nullable MediaType mediaType);
/**
* Deletes an attachment using handlers in plugins.
* <p>
* 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<Attachment> delete(Attachment attachment);
/**
* Gets permalink using handlers in plugins.
* <p>
* If no handler can be found to delete the given attachment, Mono.empty() will return.
*
* @param attachment is created attachment.
* @return permalink
*/
Mono<URI> getPermalink(Attachment attachment);
/**
* Gets shared URL using handlers in plugins.
* <p>
* 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<URI> getSharedURL(Attachment attachment, Duration ttl);
}

View File

@ -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.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; 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 org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.ListResult.generateGenericClass;
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; 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.core.fn.builders.requestbody.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part; 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.stereotype.Component;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils; 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.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest; 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.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
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.endpoint.AttachmentHandler.UploadOption;
import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.endpoint.SortResolver; 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.Comparators;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.IListRequest.QueryListRequest; import run.halo.app.extension.router.IListRequest.QueryListRequest;
import run.halo.app.plugin.ExtensionComponentsFinder;
@Slf4j @Slf4j
@Component @Component
public class AttachmentEndpoint implements CustomEndpoint { public class AttachmentEndpoint implements CustomEndpoint {
private final AttachmentService attachmentService;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ExtensionComponentsFinder extensionComponentsFinder; public AttachmentEndpoint(AttachmentService attachmentService,
ReactiveExtensionClient client) {
public AttachmentEndpoint(ReactiveExtensionClient client, this.attachmentService = attachmentService;
ExtensionComponentsFinder extensionComponentsFinder) {
this.client = client; this.client = client;
this.extensionComponentsFinder = extensionComponentsFinder;
} }
@Override @Override
public RouterFunction<ServerResponse> endpoint() { public RouterFunction<ServerResponse> endpoint() {
var tag = "api.console.halo.run/v1alpha1/Attachment"; var tag = "api.console.halo.run/v1alpha1/Attachment";
return SpringdocRouteBuilder.route() 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 builder -> builder
.operationId("UploadAttachment") .operationId("UploadAttachment")
.tag(tag) .tag(tag)
@ -98,56 +102,6 @@ public class AttachmentEndpoint implements CustomEndpoint {
.build(); .build();
} }
Mono<ServerResponse> 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<ServerResponse> search(ServerRequest request) { Mono<ServerResponse> search(ServerRequest request) {
var searchRequest = new SearchRequest(request); var searchRequest = new SearchRequest(request);
return client.list(Attachment.class, return client.list(Attachment.class,

View File

@ -5,10 +5,12 @@ import static run.halo.app.infra.utils.FileNameUtils.randomFileName;
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -22,6 +24,7 @@ import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import reactor.core.Exceptions; import reactor.core.Exceptions;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; 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.Attachment.AttachmentSpec;
import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Constant;
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.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.AttachmentAlreadyExistsException; import run.halo.app.infra.exception.AttachmentAlreadyExistsException;
import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
@ -42,8 +47,12 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
private final HaloProperties haloProp; private final HaloProperties haloProp;
public LocalAttachmentUploadHandler(HaloProperties haloProp) { private final ExternalUrlSupplier externalUrl;
public LocalAttachmentUploadHandler(HaloProperties haloProp,
ExternalUrlSupplier externalUrl) {
this.haloProp = haloProp; this.haloProp = haloProp;
this.externalUrl = externalUrl;
} }
Path getAttachmentsRoot() { Path getAttachmentsRoot() {
@ -153,6 +162,35 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
.map(DeleteContext::attachment); .map(DeleteContext::attachment);
} }
@Override
public Mono<URI> 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<URI> getSharedURL(Attachment attachment,
Policy policy,
ConfigMap configMap,
Duration ttl) {
return getPermalink(attachment, policy, configMap);
}
private boolean shouldHandle(Policy policy) { private boolean shouldHandle(Policy policy) {
if (policy == null if (policy == null
|| policy.getSpec() == null || policy.getSpec() == null

View File

@ -1,28 +1,22 @@
package run.halo.app.core.extension.reconciler.attachment; package run.halo.app.core.extension.reconciler.attachment;
import java.net.URI;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
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;
import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.service.AttachmentService;
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.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; 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.infra.ExternalUrlSupplier; import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.plugin.ExtensionComponentsFinder;
@Slf4j @Slf4j
@Component @Component
@ -30,16 +24,15 @@ public class AttachmentReconciler implements Reconciler<Request> {
private final ExtensionClient client; private final ExtensionClient client;
private final ExtensionComponentsFinder extensionComponentsFinder;
private final ExternalUrlSupplier externalUrl; private final ExternalUrlSupplier externalUrl;
private final AttachmentService attachmentService;
public AttachmentReconciler(ExtensionClient client, public AttachmentReconciler(ExtensionClient client,
ExtensionComponentsFinder extensionComponentsFinder, ExternalUrlSupplier externalUrl, AttachmentService attachmentService) {
ExternalUrlSupplier externalUrl) {
this.client = client; this.client = client;
this.extensionComponentsFinder = extensionComponentsFinder;
this.externalUrl = externalUrl; this.externalUrl = externalUrl;
this.attachmentService = attachmentService;
} }
@Override @Override
@ -47,38 +40,24 @@ public class AttachmentReconciler implements Reconciler<Request> {
client.fetch(Attachment.class, request.name()).ifPresent(attachment -> { client.fetch(Attachment.class, request.name()).ifPresent(attachment -> {
// TODO Handle the finalizer // TODO Handle the finalizer
if (attachment.getMetadata().getDeletionTimestamp() != null) { if (attachment.getMetadata().getDeletionTimestamp() != null) {
Policy policy = client.fetch(Policy.class, attachment.getSpec().getPolicyName()) attachmentService.delete(attachment)
.orElseThrow(); .doOnNext(deletedAttachment -> {
var configMap = client.fetch(ConfigMap.class, policy.getSpec().getConfigMapName()) removeFinalizer(attachment.getMetadata().getName());
.orElseThrow(); })
var deleteOption = new DeleteOption(attachment, policy, configMap); .blockOptional();
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();
return; return;
} }
// add finalizer // add finalizer
addFinalizerIfNotSet(request.name(), attachment.getMetadata().getFinalizers()); addFinalizerIfNotSet(request.name(), attachment.getMetadata().getFinalizers());
var annotations = attachment.getMetadata().getAnnotations(); var annotations = attachment.getMetadata().getAnnotations();
if (annotations != null) { if (annotations != null) {
String permalink = null; attachmentService.getPermalink(attachment)
var uri = annotations.get(Constant.URI_ANNO_KEY); .map(URI::toString)
if (uri != null) { .switchIfEmpty(Mono.fromSupplier(() -> {
permalink = UriComponentsBuilder.fromUri(externalUrl.get()) // Only for back-compatibility
// The URI has been encoded before, so there is no need to encode it again. return annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY);
.path(uri) }))
.build() .doOnNext(permalink -> {
.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()); log.debug("Set permalink {} for attachment {}", permalink, request.name());
var status = attachment.getStatus(); var status = attachment.getStatus();
if (status == null) { if (status == null) {
@ -86,7 +65,8 @@ public class AttachmentReconciler implements Reconciler<Request> {
attachment.setStatus(status); attachment.setStatus(status);
} }
status.setPermalink(permalink); status.setPermalink(permalink);
} })
.blockOptional();
} }
updateStatus(request.name(), attachment.getStatus()); updateStatus(request.name(), attachment.getStatus());
}); });

View File

@ -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<Attachment> upload(@NonNull String policyName,
@Nullable String groupName,
@NonNull String filename,
@NonNull Flux<DataBuffer> 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<Attachment> 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<URI> 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<URI> 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 <T> Mono<T> authenticationConsumer(Function<Authentication, Mono<T>> func) {
return ReactiveSecurityContextHolder.getContext()
.switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authentication required.")))
.map(SecurityContext::getAuthentication)
.flatMap(func);
}
}

View File

@ -4,6 +4,7 @@ import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.extension.DefaultSchemeManager; import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
@ -67,6 +68,8 @@ public class SharedApplicationContextHolder {
rootApplicationContext.getBean(ExternalUrlSupplier.class)); rootApplicationContext.getBean(ExternalUrlSupplier.class));
beanFactory.registerSingleton("serverSecurityContextRepository", beanFactory.registerSingleton("serverSecurityContextRepository",
rootApplicationContext.getBean(ServerSecurityContextRepository.class)); rootApplicationContext.getBean(ServerSecurityContextRepository.class));
beanFactory.registerSingleton("attachmentService",
rootApplicationContext.getBean(AttachmentService.class));
// TODO add more shared instance here // TODO add more shared instance here
return sharedApplicationContext; return sharedApplicationContext;

View File

@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.same; import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockUser; 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.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; 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;
import run.halo.app.core.extension.attachment.Policy.PolicySpec; 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.attachment.endpoint.AttachmentEndpoint.SearchRequest;
import run.halo.app.core.extension.service.impl.DefaultAttachmentService;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
@ -48,13 +50,14 @@ class AttachmentEndpointTest {
@Mock @Mock
ExtensionComponentsFinder extensionComponentsFinder; ExtensionComponentsFinder extensionComponentsFinder;
@InjectMocks
AttachmentEndpoint endpoint; AttachmentEndpoint endpoint;
WebTestClient webClient; WebTestClient webClient;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
var attachmentService = new DefaultAttachmentService(client, extensionComponentsFinder);
endpoint = new AttachmentEndpoint(attachmentService, client);
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
.apply(springSecurity()) .apply(springSecurity())
.build(); .build();
@ -65,12 +68,44 @@ class AttachmentEndpointTest {
@Test @Test
void shouldResponseErrorIfNotLogin() { 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 webClient
.post() .post()
.uri("/attachments/upload") .uri("/attachments/upload")
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.exchange() .exchange()
.expectStatus().isUnauthorized(); .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 @Test
@ -100,6 +135,44 @@ class AttachmentEndpointTest {
.expectStatus().isBadRequest(); .expectStatus().isBadRequest();
} }
void prepareForUploading(Consumer<MultipartBodyBuilder> 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 @Test
void shouldUploadSuccessfully() { void shouldUploadSuccessfully() {
var policySpec = new PolicySpec(); var policySpec = new PolicySpec();