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
John Niang 2022-09-05 09:48:12 +08:00 committed by GitHub
parent 693b6cc344
commit 703f697bc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1297 additions and 38 deletions

View File

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

View File

@ -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 + "/");
}
}

View File

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

View File

@ -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";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
});
}
}

View File

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

View File

@ -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);
}
}

View File

@ -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}";
}
}

View File

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

View File

@ -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);
}
}

View File

@ -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}";
}
}

View File

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

View File

@ -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.")

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

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

View File

@ -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, "");
}
}

View File

@ -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));
}
}

View File

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

View File

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

View File

@ -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 下的子目录

View File

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

View File

@ -0,0 +1,5 @@
package run.halo.app.core.extension.endpoint;
class AttachmentEndpointTest {
}

View File

@ -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));
}
}

View File

@ -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"));
}
}
}