mirror of https://github.com/halo-dev/halo
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
parent
d1651aa671
commit
d760d4d362
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
* <p>
|
||||
*
|
||||
* @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";
|
||||
|
|
|
@ -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<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 {
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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<ServerResponse> 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<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) {
|
||||
var searchRequest = new SearchRequest(request);
|
||||
return client.list(Attachment.class,
|
||||
|
|
|
@ -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<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) {
|
||||
if (policy == null
|
||||
|| policy.getSpec() == null
|
||||
|
|
|
@ -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<Request> {
|
|||
|
||||
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<Request> {
|
|||
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());
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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<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
|
||||
void shouldUploadSuccessfully() {
|
||||
var policySpec = new PolicySpec();
|
||||
|
|
Loading…
Reference in New Issue