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();