mirror of https://github.com/halo-dev/halo
Provide an ability of managing attachments (#2354)
#### What type of PR is this? /kind feature /area core /milestone 2.0 #### What this PR does / why we need it: This PR provides an ability of manging attachments. - Upload an attachment - Delete an attachment - Manage attachment with group - Manage Policy #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2330 #### Does this PR introduce a user-facing change? ```release-note 新增附件管理功能 ```pull/2380/head
parent
693b6cc344
commit
703f697bc3
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.config;
|
||||
|
||||
import org.pf4j.PluginManager;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
@ -20,6 +21,7 @@ import run.halo.app.core.extension.RoleBinding;
|
|||
import run.halo.app.core.extension.Tag;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.reconciler.CategoryReconciler;
|
||||
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
|
||||
import run.halo.app.core.extension.reconciler.MenuReconciler;
|
||||
|
@ -31,6 +33,7 @@ import run.halo.app.core.extension.reconciler.SystemSettingReconciler;
|
|||
import run.halo.app.core.extension.reconciler.TagReconciler;
|
||||
import run.halo.app.core.extension.reconciler.ThemeReconciler;
|
||||
import run.halo.app.core.extension.reconciler.UserReconciler;
|
||||
import run.halo.app.core.extension.reconciler.attachment.AttachmentReconciler;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.DefaultSchemeManager;
|
||||
|
@ -168,6 +171,14 @@ public class ExtensionConfiguration {
|
|||
.extension(new ConfigMap())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller attachmentController(ExtensionClient client, PluginManager pluginManager) {
|
||||
return new ControllerBuilder("attachment-controller", client)
|
||||
.reconciler(new AttachmentReconciler(client, pluginManager))
|
||||
.extension(new Attachment())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package run.halo.app.config;
|
||||
|
||||
import static org.springframework.util.ResourceUtils.FILE_URL_PREFIX;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
@ -11,6 +13,7 @@ import org.springframework.http.codec.ServerCodecConfigurer;
|
|||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.reactive.config.ResourceHandlerRegistry;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
@ -18,14 +21,18 @@ import org.springframework.web.reactive.result.view.ViewResolutionResultHandler;
|
|||
import org.springframework.web.reactive.result.view.ViewResolver;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
|
||||
@Configuration
|
||||
public class WebFluxConfig implements WebFluxConfigurer {
|
||||
|
||||
final ObjectMapper objectMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WebFluxConfig(ObjectMapper objectMapper) {
|
||||
private final HaloProperties haloProp;
|
||||
|
||||
public WebFluxConfig(ObjectMapper objectMapper, HaloProperties haloProp) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.haloProp = haloProp;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
@ -63,4 +70,10 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
var attachmentsRoot = haloProp.getWorkDir().resolve("attachments");
|
||||
registry.addResourceHandler("/upload/**")
|
||||
.addResourceLocations(FILE_URL_PREFIX + attachmentsRoot + "/");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package run.halo.app.core.extension.attachment;
|
||||
|
||||
import static run.halo.app.core.extension.attachment.Attachment.KIND;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Set;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Ref;
|
||||
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND,
|
||||
plural = "attachments", singular = "attachment")
|
||||
public class Attachment extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Attachment";
|
||||
|
||||
@Schema(required = true)
|
||||
private AttachmentSpec spec;
|
||||
|
||||
private AttachmentStatus status;
|
||||
|
||||
@Data
|
||||
public static class AttachmentSpec {
|
||||
|
||||
@Schema(description = "Display name of attachment")
|
||||
private String displayName;
|
||||
|
||||
@Schema(description = "Reference of Group")
|
||||
private Ref groupRef;
|
||||
|
||||
@Schema(description = "Reference of Policy")
|
||||
private Ref policyRef;
|
||||
|
||||
@Schema(description = "Reference of User who uploads the attachment")
|
||||
private Ref uploadedBy;
|
||||
|
||||
@Schema(description = "Media type of attachment")
|
||||
private String mediaType;
|
||||
|
||||
@Schema(description = "Size of attachment. Unit is Byte", minimum = "0")
|
||||
private Long size;
|
||||
|
||||
@ArraySchema(
|
||||
arraySchema = @Schema(description = "Tags of attachment"),
|
||||
schema = @Schema(description = "Tag name"))
|
||||
private Set<String> tags;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AttachmentStatus {
|
||||
|
||||
@Schema(description = "Permalink of attachment")
|
||||
private String permalink;
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package run.halo.app.core.extension.attachment;
|
||||
|
||||
public enum Constant {
|
||||
;
|
||||
|
||||
public static final String GROUP = "storage.halo.run";
|
||||
public static final String VERSION = "v1alpha1";
|
||||
public static final String LOCAL_REL_PATH_ANNO_KEY = GROUP + "/local-relative-path";
|
||||
public static final String EXTERNAL_LINK_ANNO_KEY = GROUP + "/external-link";
|
||||
|
||||
public static final String FINALIZER_NAME = "attachment-manager";
|
||||
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package run.halo.app.core.extension.attachment;
|
||||
|
||||
import static run.halo.app.core.extension.attachment.Group.KIND;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND,
|
||||
plural = "groups", singular = "group")
|
||||
public class Group extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Group";
|
||||
|
||||
@Schema(required = true)
|
||||
private GroupSpec spec;
|
||||
|
||||
private GroupStatus status;
|
||||
|
||||
@Data
|
||||
public static class GroupSpec {
|
||||
|
||||
@Schema(required = true, description = "Display name of group")
|
||||
private String displayName;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GroupStatus {
|
||||
|
||||
@Schema(description = "Update timestamp of the group")
|
||||
private Instant updateTimestamp;
|
||||
|
||||
@Schema(description = "Total of attachments under the current group", minimum = "0")
|
||||
private Long totalAttachments;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package run.halo.app.core.extension.attachment;
|
||||
|
||||
import static run.halo.app.core.extension.attachment.Policy.KIND;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Ref;
|
||||
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "policies",
|
||||
singular = "policy")
|
||||
public class Policy extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Policy";
|
||||
|
||||
@Schema(required = true)
|
||||
private PolicySpec spec;
|
||||
|
||||
@Data
|
||||
public static class PolicySpec {
|
||||
|
||||
@Schema(required = true, description = "Display name of policy")
|
||||
private String displayName;
|
||||
|
||||
@Schema(description = "Reference name of Setting extension")
|
||||
private Ref templateRef;
|
||||
|
||||
@Schema(description = "Reference name of ConfigMap extension")
|
||||
private Ref configMapRef;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package run.halo.app.core.extension.attachment;
|
||||
|
||||
import static run.halo.app.core.extension.attachment.PolicyTemplate.KIND;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.Ref;
|
||||
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND,
|
||||
plural = "policytemplates", singular = "policytemplate")
|
||||
public class PolicyTemplate extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "PolicyTemplate";
|
||||
|
||||
private PolicyTemplateSpec spec;
|
||||
|
||||
@Data
|
||||
public static class PolicyTemplateSpec {
|
||||
|
||||
private String displayName;
|
||||
|
||||
private Ref settingRef;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,267 @@
|
|||
package run.halo.app.core.extension.attachment.endpoint;
|
||||
|
||||
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.web.reactive.function.BodyExtractors.toMultipartData;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||
import static run.halo.app.core.extension.attachment.Constant.FINALIZER_NAME;
|
||||
import static run.halo.app.extension.ListResult.generateGenericClass;
|
||||
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.PluginManager;
|
||||
import org.springdoc.core.fn.builders.requestbody.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
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.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.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.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.extension.router.IListRequest.QueryListRequest;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AttachmentEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final PluginManager pluginManager;
|
||||
|
||||
public AttachmentEndpoint(ReactiveExtensionClient client, PluginManager pluginManager) {
|
||||
this.client = client;
|
||||
this.pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
var tag = "storage.halo.run/v1alpha1/Attachment";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.POST("/attachments/upload", contentType(MediaType.MULTIPART_FORM_DATA), this::upload,
|
||||
builder -> builder
|
||||
.operationId("UploadAttachment")
|
||||
.tag(tag)
|
||||
.requestBody(Builder.requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
.schema(schemaBuilder().implementation(IUploadRequest.class))
|
||||
))
|
||||
.response(responseBuilder().implementation(Attachment.class))
|
||||
.build())
|
||||
.GET("/attachments", this::search,
|
||||
builder -> {
|
||||
builder
|
||||
.operationId("SearchAttachments")
|
||||
.tag(tag)
|
||||
.response(
|
||||
responseBuilder().implementation(generateGenericClass(Attachment.class))
|
||||
);
|
||||
buildParametersFromType(builder, ISearchRequest.class);
|
||||
}
|
||||
)
|
||||
.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 -> policy.getSpec().getConfigMapRef() != null)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||
"Please configure the attachment policy before uploading")))
|
||||
.flatMap(policy -> {
|
||||
var configMapName = policy.getSpec().getConfigMapRef().getName();
|
||||
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(
|
||||
pluginManager.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.setUploadedBy(Ref.of(username));
|
||||
spec.setPolicyRef(Ref.of(uploadOption.policy()));
|
||||
var groupName = uploadRequest.getGroupName();
|
||||
if (groupName != null) {
|
||||
// validate the group name
|
||||
spec.setGroupRef(Ref.of(groupName));
|
||||
}
|
||||
// set finalizers mandatory
|
||||
attachment.getMetadata().setFinalizers(Set.of(FINALIZER_NAME));
|
||||
}))
|
||||
.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.queryParams());
|
||||
return client.list(Attachment.class, searchRequest.toPredicate(), null,
|
||||
searchRequest.getPage(), searchRequest.getSize())
|
||||
.flatMap(listResult -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(listResult));
|
||||
}
|
||||
|
||||
public interface ISearchRequest extends IListRequest {
|
||||
|
||||
@Schema(description = "Display name of attachment")
|
||||
Optional<String> getDisplayName();
|
||||
|
||||
@Schema(description = "Name of policy")
|
||||
Optional<String> getPolicy();
|
||||
|
||||
@Schema(description = "Name of group")
|
||||
Optional<String> getGroup();
|
||||
|
||||
@Schema(description = "Name of user who uploaded the attachment")
|
||||
Optional<String> getUploadedBy();
|
||||
|
||||
}
|
||||
|
||||
public static class SearchRequest extends QueryListRequest implements ISearchRequest {
|
||||
|
||||
public SearchRequest(MultiValueMap<String, String> queryParams) {
|
||||
super(queryParams);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getDisplayName() {
|
||||
return Optional.ofNullable(queryParams.getFirst("displayName"))
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getPolicy() {
|
||||
return Optional.ofNullable(queryParams.getFirst("policy"))
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getGroup() {
|
||||
return Optional.ofNullable(queryParams.getFirst("group"))
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getUploadedBy() {
|
||||
return Optional.ofNullable(queryParams.getFirst("uploadedBy"))
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
|
||||
public Predicate<Attachment> toPredicate() {
|
||||
var predicate = (Predicate<Attachment>) (attachment) -> getDisplayName()
|
||||
.map(displayNameInParam -> {
|
||||
String displayName = attachment.getSpec().getDisplayName();
|
||||
return displayName.contains(displayNameInParam);
|
||||
}).orElse(true)
|
||||
&& getPolicy()
|
||||
.map(policy -> {
|
||||
var policyRef = attachment.getSpec().getPolicyRef();
|
||||
return policyRef != null && policy.equals(policyRef.getName());
|
||||
}).orElse(true)
|
||||
&& getGroup()
|
||||
.map(group -> {
|
||||
var groupRef = attachment.getSpec().getGroupRef();
|
||||
return groupRef != null && group.equals(groupRef.getName());
|
||||
})
|
||||
.orElse(true)
|
||||
&& getUploadedBy()
|
||||
.map(uploadedBy -> {
|
||||
var uploadedByRef = attachment.getSpec().getUploadedBy();
|
||||
return uploadedByRef != null && uploadedBy.equals(uploadedByRef.getName());
|
||||
})
|
||||
.orElse(true);
|
||||
|
||||
var selectorPredicate = labelAndFieldSelectorToPredicate(getLabelSelector(),
|
||||
getFieldSelector());
|
||||
|
||||
return predicate.and(selectorPredicate);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IUploadRequest {
|
||||
|
||||
@Schema(required = true, description = "Attachment file")
|
||||
FilePart getFile();
|
||||
|
||||
@Schema(required = true, description = "Storage policy name")
|
||||
String getPolicyName();
|
||||
|
||||
@Schema(description = "The name of the group to which the attachment belongs")
|
||||
String getGroupName();
|
||||
|
||||
}
|
||||
|
||||
public record UploadRequest(MultiValueMap<String, Part> formData) implements IUploadRequest {
|
||||
|
||||
public FilePart getFile() {
|
||||
if (formData.getFirst("file") instanceof FilePart file) {
|
||||
return file;
|
||||
}
|
||||
throw new ServerWebInputException("Invalid part of file");
|
||||
}
|
||||
|
||||
public String getPolicyName() {
|
||||
if (formData.getFirst("policyName") instanceof FormFieldPart form) {
|
||||
return form.value();
|
||||
}
|
||||
throw new ServerWebInputException("Invalid part of policyName");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroupName() {
|
||||
if (formData.getFirst("groupName") instanceof FormFieldPart form) {
|
||||
return form.value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package run.halo.app.core.extension.attachment.endpoint;
|
||||
|
||||
import org.pf4j.ExtensionPoint;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
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.extension.ConfigMap;
|
||||
|
||||
public interface AttachmentHandler extends ExtensionPoint {
|
||||
|
||||
Mono<Attachment> upload(UploadContext context);
|
||||
|
||||
Mono<Attachment> delete(DeleteContext context);
|
||||
|
||||
interface UploadContext {
|
||||
|
||||
FilePart file();
|
||||
|
||||
Policy policy();
|
||||
|
||||
ConfigMap configMap();
|
||||
|
||||
}
|
||||
|
||||
interface DeleteContext {
|
||||
Attachment attachment();
|
||||
|
||||
Policy policy();
|
||||
|
||||
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,145 @@
|
|||
package run.halo.app.core.extension.attachment.endpoint;
|
||||
|
||||
import static java.nio.file.StandardOpenOption.CREATE_NEW;
|
||||
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
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.Metadata;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||
|
||||
private final HaloProperties haloProp;
|
||||
|
||||
public LocalAttachmentUploadHandler(HaloProperties haloProp) {
|
||||
this.haloProp = haloProp;
|
||||
}
|
||||
|
||||
Path getAttachmentsRoot() {
|
||||
return haloProp.getWorkDir().resolve("attachments");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Attachment> upload(UploadContext uploadOption) {
|
||||
return Mono.just(uploadOption)
|
||||
.filter(option -> this.shouldHandle(option.policy()))
|
||||
.flatMap(option -> {
|
||||
var configMap = option.configMap();
|
||||
var settingJson = configMap.getData().getOrDefault("default", "{}");
|
||||
var setting = JsonUtils.jsonToObject(settingJson, PolicySetting.class);
|
||||
|
||||
var attachmentsRoot = getAttachmentsRoot();
|
||||
var attachmentRoot = attachmentsRoot;
|
||||
if (StringUtils.hasText(setting.getLocation())) {
|
||||
attachmentRoot = attachmentsRoot.resolve(setting.getLocation());
|
||||
}
|
||||
var file = option.file();
|
||||
var attachmentPath = attachmentRoot.resolve(file.filename());
|
||||
// check the directory traversal before saving
|
||||
checkDirectoryTraversal(attachmentsRoot, attachmentPath);
|
||||
|
||||
return Mono.fromRunnable(
|
||||
() -> {
|
||||
try {
|
||||
// init parent folders
|
||||
Files.createDirectories(attachmentPath.getParent());
|
||||
} catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
// save the attachment
|
||||
.then(DataBufferUtils.write(file.content(), attachmentPath, CREATE_NEW))
|
||||
.then(Mono.fromCallable(() -> {
|
||||
log.info("Wrote attachment {} into {}", file.filename(), attachmentPath);
|
||||
// TODO check the file extension
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
metadata.setAnnotations(Map.of(Constant.LOCAL_REL_PATH_ANNO_KEY,
|
||||
attachmentsRoot.relativize(attachmentPath).toString()));
|
||||
var spec = new AttachmentSpec();
|
||||
spec.setSize(attachmentPath.toFile().length());
|
||||
file.headers().getContentType();
|
||||
spec.setMediaType(Optional.ofNullable(file.headers().getContentType())
|
||||
.map(MediaType::toString)
|
||||
.orElse(null));
|
||||
spec.setDisplayName(file.filename());
|
||||
var attachment = new Attachment();
|
||||
attachment.setMetadata(metadata);
|
||||
attachment.setSpec(spec);
|
||||
return attachment;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Attachment> delete(DeleteContext deleteContext) {
|
||||
return Mono.just(deleteContext)
|
||||
.filter(context -> this.shouldHandle(context.policy()))
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.doOnNext(context -> {
|
||||
var attachment = context.attachment();
|
||||
log.info("Trying to delete {} from local", attachment.getMetadata().getName());
|
||||
var annotations = attachment.getMetadata().getAnnotations();
|
||||
if (annotations != null) {
|
||||
var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY);
|
||||
if (StringUtils.hasText(localRelativePath)) {
|
||||
var attachmentsRoot = getAttachmentsRoot();
|
||||
var attachmentPath = attachmentsRoot.resolve(localRelativePath);
|
||||
checkDirectoryTraversal(attachmentsRoot, attachmentPath);
|
||||
|
||||
// delete it permanently
|
||||
try {
|
||||
log.info("{} is being deleted", attachmentPath);
|
||||
boolean deleted = Files.deleteIfExists(attachmentPath);
|
||||
if (deleted) {
|
||||
log.info("{} was deleted successfully", attachment);
|
||||
} else {
|
||||
log.info("{} was not exist", attachment);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(DeleteContext::attachment);
|
||||
}
|
||||
|
||||
private boolean shouldHandle(Policy policy) {
|
||||
if (policy == null
|
||||
|| policy.getSpec() == null
|
||||
|| policy.getSpec().getTemplateRef() == null) {
|
||||
return false;
|
||||
}
|
||||
return "local".equals(policy.getSpec().getTemplateRef().getName());
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PolicySetting {
|
||||
|
||||
private String location;
|
||||
|
||||
}
|
||||
}
|
|
@ -56,7 +56,7 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
|
||||
))
|
||||
.response(responseBuilder())
|
||||
.response(responseBuilder().implementation(Plugin.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
package run.halo.app.core.extension.reconciler.attachment;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.PluginManager;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
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.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.controller.Reconciler.Request;
|
||||
import run.halo.app.infra.exception.NotFoundException;
|
||||
|
||||
@Slf4j
|
||||
public class AttachmentReconciler implements Reconciler<Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
private final PluginManager pluginManager;
|
||||
|
||||
public AttachmentReconciler(ExtensionClient client, PluginManager pluginManager) {
|
||||
this.client = client;
|
||||
this.pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request 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().getPolicyRef().getName())
|
||||
.orElseThrow();
|
||||
var configMap =
|
||||
client.fetch(ConfigMap.class, policy.getSpec().getConfigMapRef().getName())
|
||||
.orElseThrow();
|
||||
var deleteOption = new DeleteOption(attachment, policy, configMap);
|
||||
Flux.fromIterable(pluginManager.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;
|
||||
}
|
||||
var annotations = attachment.getMetadata().getAnnotations();
|
||||
if (annotations != null) {
|
||||
String permalink = null;
|
||||
var localRelativePath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY);
|
||||
if (localRelativePath != null) {
|
||||
// TODO Add router function here.
|
||||
permalink = "http://localhost:8090/upload/" + localRelativePath;
|
||||
permalink = UriUtils.encodePath(permalink, StandardCharsets.UTF_8);
|
||||
} else {
|
||||
var externalLink = annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY);
|
||||
if (externalLink != null) {
|
||||
// TODO Set the external link into status
|
||||
permalink = externalLink;
|
||||
}
|
||||
}
|
||||
if (permalink != null) {
|
||||
log.debug("Set permalink {} for attachment {}", permalink, request.name());
|
||||
var status = attachment.getStatus();
|
||||
if (status == null) {
|
||||
status = new AttachmentStatus();
|
||||
}
|
||||
status.setPermalink(permalink);
|
||||
|
||||
// update status
|
||||
attachment.setStatus(status);
|
||||
client.update(attachment);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void removeFinalizer(String attachmentName) {
|
||||
client.fetch(Attachment.class, attachmentName)
|
||||
.ifPresent(attachment -> {
|
||||
var finalizers = attachment.getMetadata().getFinalizers();
|
||||
if (finalizers != null && finalizers.remove(Constant.FINALIZER_NAME)) {
|
||||
// update it
|
||||
client.update(attachment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package run.halo.app.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Extension reference object. The name is mandatory")
|
||||
public class Ref {
|
||||
|
||||
@Schema(description = "Extension group")
|
||||
private String group;
|
||||
|
||||
@Schema(description = "Extension version")
|
||||
private String version;
|
||||
|
||||
@Schema(description = "Extension kind")
|
||||
private String kind;
|
||||
|
||||
@Schema(required = true, description = "Extension name. This field is mandatory")
|
||||
private String name;
|
||||
|
||||
public static Ref of(String name) {
|
||||
Ref ref = new Ref();
|
||||
ref.setName(name);
|
||||
return ref;
|
||||
}
|
||||
|
||||
public static Ref of(Extension extension) {
|
||||
var metadata = extension.getMetadata();
|
||||
var gvk = extension.groupVersionKind();
|
||||
var ref = new Ref();
|
||||
ref.setName(metadata.getName());
|
||||
ref.setGroup(gvk.group());
|
||||
ref.setVersion(gvk.version());
|
||||
ref.setKind(gvk.kind());
|
||||
return ref;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
@ -10,8 +12,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.extension.exception.ExtensionConvertException;
|
||||
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.CreateHandler;
|
||||
|
||||
class ExtensionCreateHandler implements ExtensionRouterFunctionFactory.CreateHandler {
|
||||
class ExtensionCreateHandler implements CreateHandler {
|
||||
|
||||
private final Scheme scheme;
|
||||
|
||||
|
@ -37,7 +40,6 @@ class ExtensionCreateHandler implements ExtensionRouterFunctionFactory.CreateHan
|
|||
|
||||
@Override
|
||||
public String pathPattern() {
|
||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(
|
||||
scheme);
|
||||
return buildExtensionPathPattern(scheme);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.DeleteHandler;
|
||||
|
||||
class ExtensionDeleteHandler implements ExtensionRouterFunctionFactory.DeleteHandler {
|
||||
class ExtensionDeleteHandler implements DeleteHandler {
|
||||
|
||||
private final Scheme scheme;
|
||||
|
||||
|
@ -31,8 +34,7 @@ class ExtensionDeleteHandler implements ExtensionRouterFunctionFactory.DeleteHan
|
|||
|
||||
@Override
|
||||
public String pathPattern() {
|
||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
||||
+ "/{name}";
|
||||
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
|
@ -7,8 +9,9 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.GetHandler;
|
||||
|
||||
class ExtensionGetHandler implements ExtensionRouterFunctionFactory.GetHandler {
|
||||
class ExtensionGetHandler implements GetHandler {
|
||||
private final Scheme scheme;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
@ -20,8 +23,7 @@ class ExtensionGetHandler implements ExtensionRouterFunctionFactory.GetHandler {
|
|||
|
||||
@Override
|
||||
public String pathPattern() {
|
||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
||||
+ "/{name}";
|
||||
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||
|
||||
import org.springframework.boot.convert.ApplicationConversionService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
|
@ -10,8 +10,10 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.ListHandler;
|
||||
import run.halo.app.extension.router.IListRequest.QueryListRequest;
|
||||
|
||||
class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler {
|
||||
class ExtensionListHandler implements ListHandler {
|
||||
private final Scheme scheme;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
@ -24,22 +26,14 @@ class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler
|
|||
@Override
|
||||
@NonNull
|
||||
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
|
||||
var conversionService = ApplicationConversionService.getSharedInstance();
|
||||
var page =
|
||||
request.queryParam("page")
|
||||
.map(pageString -> conversionService.convert(pageString, Integer.class))
|
||||
.orElse(0);
|
||||
|
||||
var size = request.queryParam("size")
|
||||
.map(sizeString -> conversionService.convert(sizeString, Integer.class))
|
||||
.orElse(0);
|
||||
|
||||
var labelSelectors = request.queryParams().get("labelSelector");
|
||||
var fieldSelectors = request.queryParams().get("fieldSelector");
|
||||
|
||||
var listRequest = new QueryListRequest(request.queryParams());
|
||||
// TODO Resolve comparator from request
|
||||
return client.list(scheme.type(),
|
||||
labelAndFieldSelectorToPredicate(labelSelectors, fieldSelectors), null, page, size)
|
||||
labelAndFieldSelectorToPredicate(listRequest.getLabelSelector(),
|
||||
listRequest.getFieldSelector()),
|
||||
null,
|
||||
listRequest.getPage(),
|
||||
listRequest.getSize())
|
||||
.flatMap(listResult -> ServerResponse
|
||||
.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
@ -48,7 +42,6 @@ class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler
|
|||
|
||||
@Override
|
||||
public String pathPattern() {
|
||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(
|
||||
scheme);
|
||||
return buildExtensionPathPattern(scheme);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
@ -10,8 +12,9 @@ import reactor.core.publisher.Mono;
|
|||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.UpdateHandler;
|
||||
|
||||
class ExtensionUpdateHandler implements ExtensionRouterFunctionFactory.UpdateHandler {
|
||||
class ExtensionUpdateHandler implements UpdateHandler {
|
||||
|
||||
private final Scheme scheme;
|
||||
|
||||
|
@ -40,7 +43,6 @@ class ExtensionUpdateHandler implements ExtensionRouterFunctionFactory.UpdateHan
|
|||
|
||||
@Override
|
||||
public String pathPattern() {
|
||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
||||
+ "/{name}";
|
||||
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package run.halo.app.extension.router;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.convert.ApplicationConversionService;
|
||||
import org.springframework.core.convert.ConversionService;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
public interface IListRequest {
|
||||
|
||||
@Schema(description = "The page number. Zero indicates no page.")
|
||||
Integer getPage();
|
||||
|
||||
@Schema(description = "Size of one page. Zero indicates no limit.")
|
||||
Integer getSize();
|
||||
|
||||
@Schema(description = "Label selector for filtering.")
|
||||
List<String> getLabelSelector();
|
||||
|
||||
@Schema(description = "Field selector for filtering.")
|
||||
List<String> getFieldSelector();
|
||||
|
||||
class QueryListRequest implements IListRequest {
|
||||
|
||||
protected final MultiValueMap<String, String> queryParams;
|
||||
|
||||
private final ConversionService conversionService =
|
||||
ApplicationConversionService.getSharedInstance();
|
||||
|
||||
public QueryListRequest(MultiValueMap<String, String> queryParams) {
|
||||
this.queryParams = queryParams;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getPage() {
|
||||
var page = queryParams.getFirst("page");
|
||||
if (StringUtils.hasText(page)) {
|
||||
return conversionService.convert(page, Integer.class);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getSize() {
|
||||
var size = queryParams.getFirst("size");
|
||||
if (StringUtils.hasText(size)) {
|
||||
return conversionService.convert(size, Integer.class);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getLabelSelector() {
|
||||
return queryParams.getOrDefault("labelSelector", Collections.emptyList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getFieldSelector() {
|
||||
return queryParams.getOrDefault("fieldSelector", Collections.emptyList());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Use {@link IListRequest.QueryListRequest} instead.
|
||||
*/
|
||||
@Data
|
||||
@Deprecated(forRemoval = true, since = "2.0.0")
|
||||
public class ListRequest {
|
||||
|
||||
@Schema(description = "The page number. Zero indicates no page.")
|
||||
|
|
|
@ -19,6 +19,10 @@ import run.halo.app.core.extension.Snapshot;
|
|||
import run.halo.app.core.extension.Tag;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Group;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
import run.halo.app.core.extension.attachment.PolicyTemplate;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.security.authentication.pat.PersonalAccessToken;
|
||||
|
@ -51,5 +55,10 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
|||
schemeManager.register(Snapshot.class);
|
||||
schemeManager.register(Comment.class);
|
||||
schemeManager.register(Reply.class);
|
||||
// storage.halo.run
|
||||
schemeManager.register(Group.class);
|
||||
schemeManager.register(Policy.class);
|
||||
schemeManager.register(Attachment.class);
|
||||
schemeManager.register(PolicyTemplate.class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
public class AccessDeniedException extends HaloException {
|
||||
|
||||
public AccessDeniedException() {
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AccessDeniedException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
public class HaloException extends RuntimeException {
|
||||
|
||||
public HaloException() {
|
||||
}
|
||||
|
||||
public HaloException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public HaloException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public HaloException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public HaloException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ package run.halo.app.infra.exception;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class NotFoundException extends RuntimeException {
|
||||
public class NotFoundException extends HaloException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ package run.halo.app.infra.exception;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeInstallationException extends RuntimeException {
|
||||
public class ThemeInstallationException extends HaloException {
|
||||
public ThemeInstallationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ package run.halo.app.infra.exception;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeUninstallException extends RuntimeException {
|
||||
public class ThemeUninstallException extends HaloException {
|
||||
|
||||
public ThemeUninstallException(String message) {
|
||||
super(message);
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package run.halo.app.infra.utils;
|
||||
|
||||
public final class FileNameUtils {
|
||||
|
||||
private FileNameUtils() {
|
||||
}
|
||||
|
||||
public static String removeFileExtension(String filename, boolean removeAllExtensions) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return filename;
|
||||
}
|
||||
var extPattern = "(?<!^)[.]" + (removeAllExtensions ? ".*" : "[^.]*$");
|
||||
return filename.replaceAll(extPattern, "");
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import java.io.IOException;
|
|||
import java.nio.file.DirectoryNotEmptyException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
|
@ -12,6 +13,7 @@ import java.util.zip.ZipInputStream;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
|
@ -131,4 +133,45 @@ public abstract class FileUtils {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks directory traversal vulnerability.
|
||||
*
|
||||
* @param parentPath parent path must not be null.
|
||||
* @param pathToCheck path to check must not be null
|
||||
*/
|
||||
public static void checkDirectoryTraversal(@NonNull Path parentPath,
|
||||
@NonNull Path pathToCheck) {
|
||||
Assert.notNull(parentPath, "Parent path must not be null");
|
||||
Assert.notNull(pathToCheck, "Path to check must not be null");
|
||||
|
||||
if (pathToCheck.normalize().startsWith(parentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AccessDeniedException(pathToCheck.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks directory traversal vulnerability.
|
||||
*
|
||||
* @param parentPath parent path must not be null.
|
||||
* @param pathToCheck path to check must not be null
|
||||
*/
|
||||
public static void checkDirectoryTraversal(@NonNull String parentPath,
|
||||
@NonNull String pathToCheck) {
|
||||
checkDirectoryTraversal(Paths.get(parentPath), Paths.get(pathToCheck));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks directory traversal vulnerability.
|
||||
*
|
||||
* @param parentPath parent path must not be null.
|
||||
* @param pathToCheck path to check must not be null
|
||||
*/
|
||||
public static void checkDirectoryTraversal(@NonNull Path parentPath,
|
||||
@NonNull String pathToCheck) {
|
||||
checkDirectoryTraversal(parentPath, Paths.get(pathToCheck));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import java.util.HashMap;
|
|||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.DefaultPluginManager;
|
||||
import org.pf4j.ExtensionFactory;
|
||||
|
@ -24,6 +26,7 @@ import org.springframework.beans.BeansException;
|
|||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
|
||||
import org.springframework.lang.NonNull;
|
||||
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
||||
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||
|
@ -110,7 +113,15 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
|
||||
@Override
|
||||
public <T> List<T> getExtensions(Class<T> type) {
|
||||
return this.getExtensions(extensionFinder.find(type));
|
||||
// we will collect implementations from Halo core at last.
|
||||
return Stream.concat(
|
||||
this.getExtensions(extensionFinder.find(type))
|
||||
.stream(),
|
||||
rootApplicationContext.getBeansOfType(type)
|
||||
.values()
|
||||
.stream()
|
||||
.sorted(AnnotationAwareOrderComparator.INSTANCE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -200,6 +200,7 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine
|
|||
try {
|
||||
|
||||
process(templateName, markupSelectors, context, writer);
|
||||
Mono.empty().block();
|
||||
|
||||
} catch (final Throwable t) {
|
||||
logger.error(
|
||||
|
@ -231,7 +232,8 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine
|
|||
});
|
||||
|
||||
// Will add some logging to the data stream
|
||||
return stream.log(LOG_CATEGORY_FULL_OUTPUT, Level.FINEST);
|
||||
return stream.log(LOG_CATEGORY_FULL_OUTPUT, Level.FINEST)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
apiVersion: storage.halo.run/v1alpha1
|
||||
kind: PolicyTemplate
|
||||
metadata:
|
||||
name: local
|
||||
spec:
|
||||
displayName: Local Storage
|
||||
settingRef:
|
||||
name: local-policy-template-setting
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: local-policy-template-setting
|
||||
spec:
|
||||
- group: default
|
||||
label: Default
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: location
|
||||
label: 存储位置
|
||||
help: ~/halo-next/attachments 下的子目录
|
|
@ -0,0 +1,151 @@
|
|||
package run.halo.app.core.extension.attachment.endpoint;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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.springSecurity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.pf4j.PluginManager;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
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.Policy.PolicySpec;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Ref;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AttachmentEndpointTest {
|
||||
|
||||
@Mock
|
||||
ReactiveExtensionClient client;
|
||||
|
||||
@Mock
|
||||
PluginManager pluginManager;
|
||||
|
||||
@InjectMocks
|
||||
AttachmentEndpoint endpoint;
|
||||
|
||||
WebTestClient webClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
|
||||
.apply(springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class UploadTest {
|
||||
|
||||
@Test
|
||||
void shouldResponseErrorIfNotLogin() {
|
||||
webClient
|
||||
.post()
|
||||
.uri("/attachments/upload")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.exchange()
|
||||
.expectStatus().isUnauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResponseErrorIfNoBodyProvided() {
|
||||
webClient
|
||||
.mutateWith(mockUser("fake-user").password("fake-password"))
|
||||
.post()
|
||||
.uri("/attachments/upload")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.exchange()
|
||||
.expectStatus().is5xxServerError();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResponseErrorIfPolicyNameIsMissing() {
|
||||
var builder = new MultipartBodyBuilder();
|
||||
builder.part("file", "fake-file")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.filename("fake-filename");
|
||||
webClient
|
||||
.mutateWith(mockUser("fake-user").password("fake-password"))
|
||||
.post()
|
||||
.uri("/attachments/upload")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(builder.build()))
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUploadSuccessfully() {
|
||||
var policySpec = new PolicySpec();
|
||||
policySpec.setConfigMapRef(Ref.of("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(pluginManager.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");
|
||||
webClient
|
||||
.mutateWith(mockUser("fake-user").password("fake-password"))
|
||||
.post()
|
||||
.uri("/attachments/upload")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(builder.build()))
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.metadata.name").isEqualTo("fake-attachment")
|
||||
.jsonPath("$.spec.uploadedBy.name").isEqualTo("fake-user")
|
||||
.jsonPath("$.spec.policyRef.name").isEqualTo("fake-policy")
|
||||
.jsonPath("$.spec.groupRef.name").isEqualTo("fake-group")
|
||||
;
|
||||
|
||||
verify(client).get(Policy.class, "fake-policy");
|
||||
verify(client).get(ConfigMap.class, "fake-configmap");
|
||||
verify(client).create(attachment);
|
||||
verify(pluginManager).getExtensions(AttachmentHandler.class);
|
||||
verify(handler).upload(any());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
class AttachmentEndpointTest {
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package run.halo.app.infra.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FileNameUtilsTest {
|
||||
|
||||
@Test
|
||||
public void shouldNotRemovExtIfNoExt() {
|
||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo", true));
|
||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRemoveExtIfHasOnlyOneExt() {
|
||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", true));
|
||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.run", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotRemoveExtIfDotfile() {
|
||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", true));
|
||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRemoveExtIfDotfileHasOneExt() {
|
||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", true));
|
||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.run", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRemoveExtIfHasTwoExt() {
|
||||
assertEquals("halo", FileNameUtils.removeFileExtension("halo.tar.gz", true));
|
||||
assertEquals("halo.tar", FileNameUtils.removeFileExtension("halo.tar.gz", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRemoveExtIfDotfileHasTwoExt() {
|
||||
assertEquals(".halo", FileNameUtils.removeFileExtension(".halo.tar.gz", true));
|
||||
assertEquals(".halo.tar", FileNameUtils.removeFileExtension(".halo.tar.gz", false));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package run.halo.app.infra.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
||||
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
|
||||
class FileUtilsTest {
|
||||
|
||||
@Nested
|
||||
class DirectoryTraversalTest {
|
||||
|
||||
@Test
|
||||
void traversalTestWhenSuccess() {
|
||||
checkDirectoryTraversal("/etc/", "/etc/halo/halo/../test");
|
||||
checkDirectoryTraversal("/etc/", "/etc/halo/../test");
|
||||
checkDirectoryTraversal("/etc/", "/etc/test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void traversalTestWhenFailure() {
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> checkDirectoryTraversal("/etc/", "/etc/../tmp"));
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> checkDirectoryTraversal("/etc/", "/../tmp"));
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> checkDirectoryTraversal("/etc/", "/tmp"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue