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;
|
package run.halo.app.config;
|
||||||
|
|
||||||
|
import org.pf4j.PluginManager;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.annotation.Bean;
|
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.Tag;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.core.extension.User;
|
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.CategoryReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
|
import run.halo.app.core.extension.reconciler.MenuItemReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.MenuReconciler;
|
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.TagReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.ThemeReconciler;
|
import run.halo.app.core.extension.reconciler.ThemeReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.UserReconciler;
|
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.core.extension.service.RoleService;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.DefaultSchemeManager;
|
import run.halo.app.extension.DefaultSchemeManager;
|
||||||
|
@ -168,6 +171,14 @@ public class ExtensionConfiguration {
|
||||||
.extension(new ConfigMap())
|
.extension(new ConfigMap())
|
||||||
.build();
|
.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;
|
package run.halo.app.config;
|
||||||
|
|
||||||
|
import static org.springframework.util.ResourceUtils.FILE_URL_PREFIX;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.context.ApplicationContext;
|
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.Jackson2JsonDecoder;
|
||||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.web.reactive.config.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
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 org.springframework.web.reactive.result.view.ViewResolver;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebFluxConfig implements WebFluxConfigurer {
|
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.objectMapper = objectMapper;
|
||||||
|
this.haloProp = haloProp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
@ -63,4 +70,10 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
||||||
return builder.build();
|
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)
|
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
|
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
|
||||||
))
|
))
|
||||||
.response(responseBuilder())
|
.response(responseBuilder().implementation(Plugin.class))
|
||||||
)
|
)
|
||||||
.build();
|
.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;
|
package run.halo.app.extension.router;
|
||||||
|
|
||||||
|
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.lang.NonNull;
|
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.Scheme;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.extension.exception.ExtensionConvertException;
|
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;
|
private final Scheme scheme;
|
||||||
|
|
||||||
|
@ -37,7 +40,6 @@ class ExtensionCreateHandler implements ExtensionRouterFunctionFactory.CreateHan
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String pathPattern() {
|
public String pathPattern() {
|
||||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(
|
return buildExtensionPathPattern(scheme);
|
||||||
scheme);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
package run.halo.app.extension.router;
|
package run.halo.app.extension.router;
|
||||||
|
|
||||||
|
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Scheme;
|
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;
|
private final Scheme scheme;
|
||||||
|
|
||||||
|
@ -31,8 +34,7 @@ class ExtensionDeleteHandler implements ExtensionRouterFunctionFactory.DeleteHan
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String pathPattern() {
|
public String pathPattern() {
|
||||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||||
+ "/{name}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package run.halo.app.extension.router;
|
package run.halo.app.extension.router;
|
||||||
|
|
||||||
|
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
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 reactor.core.publisher.Mono;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Scheme;
|
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 Scheme scheme;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
@ -20,8 +23,7 @@ class ExtensionGetHandler implements ExtensionRouterFunctionFactory.GetHandler {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String pathPattern() {
|
public String pathPattern() {
|
||||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||||
+ "/{name}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package run.halo.app.extension.router;
|
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 static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||||
|
|
||||||
import org.springframework.boot.convert.ApplicationConversionService;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
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 reactor.core.publisher.Mono;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Scheme;
|
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 Scheme scheme;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
@ -24,22 +26,14 @@ class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler
|
||||||
@Override
|
@Override
|
||||||
@NonNull
|
@NonNull
|
||||||
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
|
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
|
||||||
var conversionService = ApplicationConversionService.getSharedInstance();
|
var listRequest = new QueryListRequest(request.queryParams());
|
||||||
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");
|
|
||||||
|
|
||||||
// TODO Resolve comparator from request
|
// TODO Resolve comparator from request
|
||||||
return client.list(scheme.type(),
|
return client.list(scheme.type(),
|
||||||
labelAndFieldSelectorToPredicate(labelSelectors, fieldSelectors), null, page, size)
|
labelAndFieldSelectorToPredicate(listRequest.getLabelSelector(),
|
||||||
|
listRequest.getFieldSelector()),
|
||||||
|
null,
|
||||||
|
listRequest.getPage(),
|
||||||
|
listRequest.getSize())
|
||||||
.flatMap(listResult -> ServerResponse
|
.flatMap(listResult -> ServerResponse
|
||||||
.ok()
|
.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
@ -48,7 +42,6 @@ class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String pathPattern() {
|
public String pathPattern() {
|
||||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(
|
return buildExtensionPathPattern(scheme);
|
||||||
scheme);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package run.halo.app.extension.router;
|
package run.halo.app.extension.router;
|
||||||
|
|
||||||
|
import static run.halo.app.extension.router.ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.util.StringUtils;
|
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.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Scheme;
|
import run.halo.app.extension.Scheme;
|
||||||
import run.halo.app.extension.Unstructured;
|
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;
|
private final Scheme scheme;
|
||||||
|
|
||||||
|
@ -40,7 +43,6 @@ class ExtensionUpdateHandler implements ExtensionRouterFunctionFactory.UpdateHan
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String pathPattern() {
|
public String pathPattern() {
|
||||||
return ExtensionRouterFunctionFactory.PathPatternGenerator.buildExtensionPathPattern(scheme)
|
return buildExtensionPathPattern(scheme) + "/{name}";
|
||||||
+ "/{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 java.util.List;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use {@link IListRequest.QueryListRequest} instead.
|
||||||
|
*/
|
||||||
@Data
|
@Data
|
||||||
|
@Deprecated(forRemoval = true, since = "2.0.0")
|
||||||
public class ListRequest {
|
public class ListRequest {
|
||||||
|
|
||||||
@Schema(description = "The page number. Zero indicates no page.")
|
@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.Tag;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.core.extension.User;
|
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.ConfigMap;
|
||||||
import run.halo.app.extension.SchemeManager;
|
import run.halo.app.extension.SchemeManager;
|
||||||
import run.halo.app.security.authentication.pat.PersonalAccessToken;
|
import run.halo.app.security.authentication.pat.PersonalAccessToken;
|
||||||
|
@ -51,5 +55,10 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
||||||
schemeManager.register(Snapshot.class);
|
schemeManager.register(Snapshot.class);
|
||||||
schemeManager.register(Comment.class);
|
schemeManager.register(Comment.class);
|
||||||
schemeManager.register(Reply.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
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
public class NotFoundException extends RuntimeException {
|
public class NotFoundException extends HaloException {
|
||||||
public NotFoundException(String message) {
|
public NotFoundException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ package run.halo.app.infra.exception;
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
public class ThemeInstallationException extends RuntimeException {
|
public class ThemeInstallationException extends HaloException {
|
||||||
public ThemeInstallationException(String message) {
|
public ThemeInstallationException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ package run.halo.app.infra.exception;
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @since 2.0.0
|
* @since 2.0.0
|
||||||
*/
|
*/
|
||||||
public class ThemeUninstallException extends RuntimeException {
|
public class ThemeUninstallException extends HaloException {
|
||||||
|
|
||||||
public ThemeUninstallException(String message) {
|
public ThemeUninstallException(String message) {
|
||||||
super(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.DirectoryNotEmptyException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
|
@ -12,6 +13,7 @@ import java.util.zip.ZipInputStream;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
import run.halo.app.infra.exception.AccessDeniedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guqing
|
* @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.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.pf4j.DefaultPluginManager;
|
import org.pf4j.DefaultPluginManager;
|
||||||
import org.pf4j.ExtensionFactory;
|
import org.pf4j.ExtensionFactory;
|
||||||
|
@ -24,6 +26,7 @@ import org.springframework.beans.BeansException;
|
||||||
import org.springframework.beans.factory.InitializingBean;
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.ApplicationContextAware;
|
import org.springframework.context.ApplicationContextAware;
|
||||||
|
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||||
|
@ -110,7 +113,15 @@ public class HaloPluginManager extends DefaultPluginManager
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <T> List<T> getExtensions(Class<T> type) {
|
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
|
@Override
|
||||||
|
|
|
@ -200,6 +200,7 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine
|
||||||
try {
|
try {
|
||||||
|
|
||||||
process(templateName, markupSelectors, context, writer);
|
process(templateName, markupSelectors, context, writer);
|
||||||
|
Mono.empty().block();
|
||||||
|
|
||||||
} catch (final Throwable t) {
|
} catch (final Throwable t) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
@ -231,7 +232,8 @@ public class SpringWebFluxTemplateEngine extends SpringTemplateEngine
|
||||||
});
|
});
|
||||||
|
|
||||||
// Will add some logging to the data stream
|
// 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