diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java index db7427087..e2a9d2680 100644 --- a/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java +++ b/api/src/main/java/run/halo/app/core/extension/attachment/endpoint/SimpleFilePart.java @@ -16,7 +16,7 @@ import reactor.core.publisher.Mono; * @param content is binary data of the attachment file. * @param mediaType is media type of the attachment file. */ -record SimpleFilePart( +public record SimpleFilePart( String filename, Flux content, MediaType mediaType diff --git a/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java b/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java index b9dae4240..0694a23b8 100644 --- a/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java +++ b/api/src/main/java/run/halo/app/core/extension/content/Snapshot.java @@ -9,6 +9,7 @@ import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import org.springframework.lang.NonNull; import org.springframework.util.Assert; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @@ -27,6 +28,8 @@ import run.halo.app.extension.Ref; public class Snapshot extends AbstractExtension { public static final String KIND = "Snapshot"; public static final String KEEP_RAW_ANNO = "content.halo.run/keep-raw"; + public static final String PATCHED_CONTENT_ANNO = "content.halo.run/patched-content"; + public static final String PATCHED_RAW_ANNO = "content.halo.run/patched-raw"; @Schema(requiredMode = REQUIRED) private SnapShotSpec spec; @@ -67,4 +70,18 @@ public class Snapshot extends AbstractExtension { contributors.add(name); } + /** + * Check if the given snapshot is a base snapshot. + * + * @param snapshot must not be null. + * @return true if the given snapshot is a base snapshot; false otherwise. + */ + public static boolean isBaseSnapshot(@NonNull Snapshot snapshot) { + var annotations = snapshot.getMetadata().getAnnotations(); + if (annotations == null) { + return false; + } + return Boolean.parseBoolean(annotations.get(Snapshot.KEEP_RAW_ANNO)); + } + } diff --git a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java index 5e8a7df94..275460b56 100644 --- a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java +++ b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java @@ -2,8 +2,10 @@ package run.halo.app.core.extension.service; import java.net.URI; import java.time.Duration; +import java.util.function.Consumer; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; @@ -18,6 +20,25 @@ import run.halo.app.core.extension.attachment.Attachment; */ public interface AttachmentService { + /** + * Uploads the given attachment to specific storage using handlers in plugins. + *

+ * If no handler can be found to upload the given attachment, ServerError exception will be + * thrown. + * + * @param policyName is attachment policy name. + * @param groupName is group name the attachment belongs. + * @param filePart contains filename, content and media type. + * @param beforeCreating is an attachment modifier before creating. + * @return attachment. + */ + Mono upload( + @NonNull String username, + @NonNull String policyName, + @Nullable String groupName, + @NonNull FilePart filePart, + @Nullable Consumer beforeCreating); + /** * Uploads the given attachment to specific storage using handlers in plugins. Please note * that we will make sure the request is authenticated, or an unauthorized exception throws. diff --git a/api/src/main/java/run/halo/app/extension/Ref.java b/api/src/main/java/run/halo/app/extension/Ref.java index 1c464bf30..d275e6c7d 100644 --- a/api/src/main/java/run/halo/app/extension/Ref.java +++ b/api/src/main/java/run/halo/app/extension/Ref.java @@ -5,6 +5,7 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; import java.util.Objects; import lombok.Data; +import org.springframework.lang.NonNull; @Data @Schema(description = "Extension reference object. The name is mandatory") @@ -59,4 +60,18 @@ public class Ref { return Objects.equals(ref.getGroup(), gvk.group()) && Objects.equals(ref.getKind(), gvk.kind()); } + + /** + * Check if the extension is equal to the ref. + * + * @param ref must not be null. + * @param extension must not be null. + * @return true if they are equal; false otherwise. + */ + public static boolean equals(@NonNull Ref ref, @NonNull ExtensionOperator extension) { + var gvk = extension.groupVersionKind(); + var name = extension.getMetadata().getName(); + return groupKindEquals(ref, gvk) && Objects.equals(ref.getName(), name); + } + } diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java index 14b3cd51a..60e29851c 100644 --- a/api/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -80,6 +80,9 @@ public class SystemSetting { Integer tagPageSize; Boolean review; String slugGenerationStrategy; + + String attachmentPolicyName; + String attachmentGroupName; } @Data diff --git a/application/src/main/java/run/halo/app/config/SwaggerConfig.java b/application/src/main/java/run/halo/app/config/SwaggerConfig.java index 161c101fa..f997f4b2d 100644 --- a/application/src/main/java/run/halo/app/config/SwaggerConfig.java +++ b/application/src/main/java/run/halo/app/config/SwaggerConfig.java @@ -75,6 +75,15 @@ public class SwaggerConfig { .build(); } + @Bean + GroupedOpenApi userCenterApi() { + return GroupedOpenApi.builder() + .group("uc.api") + .displayName("User center APIs.") + .pathsToMatch("/apis/uc.api.*/**") + .build(); + } + @Bean GroupedOpenApi allApi() { return GroupedOpenApi.builder() diff --git a/application/src/main/java/run/halo/app/content/AbstractContentService.java b/application/src/main/java/run/halo/app/content/AbstractContentService.java index ea3add8d4..7b971b730 100644 --- a/application/src/main/java/run/halo/app/content/AbstractContentService.java +++ b/application/src/main/java/run/halo/app/content/AbstractContentService.java @@ -51,9 +51,7 @@ public abstract class AbstractContentService { protected void checkBaseSnapshot(Snapshot snapshot) { Assert.notNull(snapshot, "The snapshot must not be null."); - String keepRawAnno = - MetadataUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO); - if (!StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) { + if (!Snapshot.isBaseSnapshot(snapshot)) { throw new IllegalArgumentException( String.format("The snapshot [%s] is not a base snapshot.", snapshot.getMetadata().getName())); @@ -68,7 +66,7 @@ public abstract class AbstractContentService { snapshot.getSpec().setParentSnapshotName(parentSnapshotName); final String baseSnapshotNameToUse = - StringUtils.defaultString(baseSnapshotName, snapshot.getMetadata().getName()); + StringUtils.defaultIfBlank(baseSnapshotName, snapshot.getMetadata().getName()); return client.fetch(Snapshot.class, baseSnapshotName) .doOnNext(this::checkBaseSnapshot) .defaultIfEmpty(snapshot) @@ -119,7 +117,8 @@ public abstract class AbstractContentService { .map(baseSnapshot -> ContentWrapper.patchSnapshot(headSnapshot, baseSnapshot)); } - protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot, + protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, + Snapshot baseSnapshot, ContentRequest contentRequest) { Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); Assert.notNull(contentRequest, "The contentRequest must not be null."); @@ -130,7 +129,7 @@ public abstract class AbstractContentService { snapshotToUse.getSpec().setLastModifyTime(Instant.now()); // it is the v1 snapshot, set the content directly - if (org.thymeleaf.util.StringUtils.equals(baseSnapshotName, + if (StringUtils.equals(baseSnapshotName, snapshotToUse.getMetadata().getName())) { snapshotToUse.getSpec().setRawPatch(contentRequest.raw()); snapshotToUse.getSpec().setContentPatch(contentRequest.content()); diff --git a/application/src/main/java/run/halo/app/content/Content.java b/application/src/main/java/run/halo/app/content/Content.java new file mode 100644 index 000000000..b9e3177ca --- /dev/null +++ b/application/src/main/java/run/halo/app/content/Content.java @@ -0,0 +1,4 @@ +package run.halo.app.content; + +public record Content(String raw, String content, String rawType) { +} diff --git a/application/src/main/java/run/halo/app/content/PostQuery.java b/application/src/main/java/run/halo/app/content/PostQuery.java index 3f9a6963d..c5992d2aa 100644 --- a/application/src/main/java/run/halo/app/content/PostQuery.java +++ b/application/src/main/java/run/halo/app/content/PostQuery.java @@ -3,17 +3,20 @@ package run.halo.app.content; import static java.util.Comparator.comparing; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.server.ServerWebExchange; import run.halo.app.core.extension.content.Post; @@ -31,9 +34,22 @@ public class PostQuery extends IListRequest.QueryListRequest { private final ServerWebExchange exchange; + private final String username; + public PostQuery(ServerRequest request) { + this(request, null); + } + + public PostQuery(ServerRequest request, @Nullable String username) { super(request.queryParams()); this.exchange = request.exchange(); + this.username = username; + } + + @Schema(hidden = true) + @JsonIgnore + public String getUsername() { + return username; } @Nullable @@ -131,14 +147,27 @@ public class PostQuery extends IListRequest.QueryListRequest { * @return a predicate */ public Predicate toPredicate() { - Predicate paramPredicate = post -> - contains(getCategories(), post.getSpec().getCategories()) - && contains(getTags(), post.getSpec().getTags()) - && contains(getContributors(), post.getStatusOrDefault().getContributors()); + Predicate predicate = labelAndFieldSelectorToPredicate(getLabelSelector(), + getFieldSelector()); + + if (!CollectionUtils.isEmpty(getCategories())) { + predicate = + predicate.and(post -> contains(getCategories(), post.getSpec().getCategories())); + } + if (!CollectionUtils.isEmpty(getTags())) { + predicate = predicate.and(post -> contains(getTags(), post.getSpec().getTags())); + } + if (!CollectionUtils.isEmpty(getContributors())) { + Predicate hasStatus = post -> post.getStatus() != null; + var containsContributors = hasStatus.and( + post -> contains(getContributors(), post.getStatus().getContributors()) + ); + predicate = predicate.and(containsContributors); + } String keyword = getKeyword(); if (keyword != null) { - paramPredicate = paramPredicate.and(post -> { + predicate = predicate.and(post -> { String excerpt = post.getStatusOrDefault().getExcerpt(); return StringUtils.containsIgnoreCase(excerpt, keyword) || StringUtils.containsIgnoreCase(post.getSpec().getSlug(), keyword) @@ -148,7 +177,7 @@ public class PostQuery extends IListRequest.QueryListRequest { Post.PostPhase publishPhase = getPublishPhase(); if (publishPhase != null) { - paramPredicate = paramPredicate.and(post -> { + predicate = predicate.and(post -> { if (Post.PostPhase.PENDING_APPROVAL.equals(publishPhase)) { return !post.isPublished() && Post.PostPhase.PENDING_APPROVAL.name() @@ -165,13 +194,15 @@ public class PostQuery extends IListRequest.QueryListRequest { Post.VisibleEnum visible = getVisible(); if (visible != null) { - paramPredicate = - paramPredicate.and(post -> visible.equals(post.getSpec().getVisible())); + predicate = + predicate.and(post -> visible.equals(post.getSpec().getVisible())); } - Predicate predicate = labelAndFieldSelectorToPredicate(getLabelSelector(), - getFieldSelector()); - return predicate.and(paramPredicate); + if (StringUtils.isNotBlank(username)) { + Predicate isOwner = post -> Objects.equals(username, post.getSpec().getOwner()); + predicate = predicate.and(isOwner); + } + return predicate; } boolean contains(Collection left, List right) { diff --git a/application/src/main/java/run/halo/app/content/PostRequest.java b/application/src/main/java/run/halo/app/content/PostRequest.java index 3f206239c..1d6dfb00d 100644 --- a/application/src/main/java/run/halo/app/content/PostRequest.java +++ b/application/src/main/java/run/halo/app/content/PostRequest.java @@ -3,22 +3,23 @@ package run.halo.app.content; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.lang.NonNull; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.Ref; /** + * Post and content data for creating and updating post. + * * @author guqing * @since 2.0.0 */ -public record PostRequest(@Schema(requiredMode = REQUIRED) Post post, - @Schema(requiredMode = REQUIRED) Content content) { +public record PostRequest(@Schema(requiredMode = REQUIRED) @NonNull Post post, + Content content) { public ContentRequest contentRequest() { Ref subjectRef = Ref.of(post); - return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw, - content.content, content.rawType); + return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw(), + content.content(), content.rawType()); } - public record Content(String raw, String content, String rawType) { - } } diff --git a/application/src/main/java/run/halo/app/content/PostService.java b/application/src/main/java/run/halo/app/content/PostService.java index 4eba4652a..756b2c9ce 100644 --- a/application/src/main/java/run/halo/app/content/PostService.java +++ b/application/src/main/java/run/halo/app/content/PostService.java @@ -1,5 +1,6 @@ package run.halo.app.content; +import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; @@ -18,9 +19,28 @@ public interface PostService { Mono updatePost(PostRequest postRequest); + Mono updateBy(@NonNull Post post); + Mono getHeadContent(String postName); + Mono getHeadContent(Post post); + Mono getReleaseContent(String postName); + Mono getReleaseContent(Post post); + Mono getContent(String snapshotName, String baseSnapshotName); + + Mono publish(Post post); + + Mono unpublish(Post post); + + /** + * Get post by username. + * + * @param postName is post name. + * @param username is username. + * @return full post data or empty. + */ + Mono getByUsername(String postName, String username); } diff --git a/application/src/main/java/run/halo/app/content/SinglePageRequest.java b/application/src/main/java/run/halo/app/content/SinglePageRequest.java index 8fda8fc18..93ad8c762 100644 --- a/application/src/main/java/run/halo/app/content/SinglePageRequest.java +++ b/application/src/main/java/run/halo/app/content/SinglePageRequest.java @@ -17,10 +17,8 @@ public record SinglePageRequest(@Schema(requiredMode = REQUIRED) SinglePage page public ContentRequest contentRequest() { Ref subjectRef = Ref.of(page); - return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw, - content.content, content.rawType); + return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw(), + content.content(), content.rawType()); } - public record Content(String raw, String content, String rawType) { - } } diff --git a/application/src/main/java/run/halo/app/content/SnapshotService.java b/application/src/main/java/run/halo/app/content/SnapshotService.java new file mode 100644 index 000000000..d97e4227c --- /dev/null +++ b/application/src/main/java/run/halo/app/content/SnapshotService.java @@ -0,0 +1,22 @@ +package run.halo.app.content; + +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Snapshot; + +public interface SnapshotService { + + Mono getBy(String snapshotName); + + Mono getPatchedBy(String snapshotName, String baseSnapshotName); + + Mono patchAndCreate(@NonNull Snapshot snapshot, + @Nullable Snapshot baseSnapshot, + @NonNull Content content); + + Mono patchAndUpdate(@NonNull Snapshot snapshot, + @NonNull Snapshot baseSnapshot, + @NonNull Content content); + +} diff --git a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index b8149c7c2..3892d765c 100644 --- a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -8,6 +8,7 @@ import java.util.function.Function; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import reactor.core.publisher.Flux; @@ -185,6 +186,9 @@ public class PostServiceImpl extends AbstractContentService implements PostServi ) .flatMap(client::create) .flatMap(post -> { + if (postRequest.content() == null) { + return Mono.just(post); + } var contentRequest = new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(), postRequest.content().raw(), postRequest.content().content(), @@ -248,21 +252,59 @@ public class PostServiceImpl extends AbstractContentService implements PostServi .filter(throwable -> throwable instanceof OptimisticLockingFailureException)); } + @Override + public Mono updateBy(@NonNull Post post) { + return client.update(post); + } + @Override public Mono getHeadContent(String postName) { return client.get(Post.class, postName) - .flatMap(post -> { - String headSnapshot = post.getSpec().getHeadSnapshot(); - return getContent(headSnapshot, post.getSpec().getBaseSnapshot()); - }); + .flatMap(this::getHeadContent); + } + + @Override + public Mono getHeadContent(Post post) { + var headSnapshot = post.getSpec().getHeadSnapshot(); + return getContent(headSnapshot, post.getSpec().getBaseSnapshot()); } @Override public Mono getReleaseContent(String postName) { return client.get(Post.class, postName) - .flatMap(post -> { - String releaseSnapshot = post.getSpec().getReleaseSnapshot(); - return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot()); - }); + .flatMap(this::getReleaseContent); + } + + @Override + public Mono getReleaseContent(Post post) { + var releaseSnapshot = post.getSpec().getReleaseSnapshot(); + return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot()); + } + + @Override + public Mono publish(Post post) { + return Mono.just(post) + .doOnNext(p -> { + var spec = post.getSpec(); + spec.setPublish(true); + if (spec.getHeadSnapshot() == null) { + spec.setHeadSnapshot(spec.getBaseSnapshot()); + } + spec.setReleaseSnapshot(spec.getHeadSnapshot()); + }).flatMap(client::update); + } + + @Override + public Mono unpublish(Post post) { + return Mono.just(post) + .doOnNext(p -> p.getSpec().setPublish(false)) + .flatMap(client::update); + } + + @Override + public Mono getByUsername(String postName, String username) { + return client.get(Post.class, postName) + .filter(post -> post.getSpec() != null) + .filter(post -> Objects.equals(username, post.getSpec().getOwner())); } } diff --git a/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java new file mode 100644 index 000000000..00e758d6e --- /dev/null +++ b/application/src/main/java/run/halo/app/content/impl/SnapshotServiceImpl.java @@ -0,0 +1,125 @@ +package run.halo.app.content.impl; + +import java.time.Clock; +import java.util.HashMap; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import run.halo.app.content.Content; +import run.halo.app.content.PatchUtils; +import run.halo.app.content.SnapshotService; +import run.halo.app.core.extension.content.Snapshot; +import run.halo.app.extension.ReactiveExtensionClient; + +@Service +public class SnapshotServiceImpl implements SnapshotService { + + private final ReactiveExtensionClient client; + + private Clock clock; + + public SnapshotServiceImpl(ReactiveExtensionClient client) { + this.client = client; + this.clock = Clock.systemDefaultZone(); + } + + @Override + public Mono getBy(String snapshotName) { + return client.get(Snapshot.class, snapshotName); + } + + @Override + public Mono getPatchedBy(String snapshotName, String baseSnapshotName) { + if (StringUtils.isBlank(snapshotName) || StringUtils.isBlank(baseSnapshotName)) { + return Mono.empty(); + } + + return client.fetch(Snapshot.class, baseSnapshotName) + .filter(Snapshot::isBaseSnapshot) + .switchIfEmpty(Mono.error(() -> new IllegalArgumentException( + "The snapshot " + baseSnapshotName + " is not a base snapshot."))) + .flatMap(baseSnapshot -> + Mono.defer(() -> { + if (Objects.equals(snapshotName, baseSnapshotName)) { + return Mono.just(baseSnapshot); + } + return client.fetch(Snapshot.class, snapshotName); + }).doOnNext(snapshot -> { + var baseRaw = baseSnapshot.getSpec().getRawPatch(); + var baseContent = baseSnapshot.getSpec().getContentPatch(); + + var rawPatch = snapshot.getSpec().getRawPatch(); + var contentPatch = snapshot.getSpec().getContentPatch(); + + var annotations = snapshot.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + snapshot.getMetadata().setAnnotations(annotations); + } + + String patchedContent = baseContent; + String patchedRaw = baseRaw; + if (!Objects.equals(snapshot, baseSnapshot)) { + patchedContent = PatchUtils.applyPatch(baseContent, contentPatch); + patchedRaw = PatchUtils.applyPatch(baseRaw, rawPatch); + } + + annotations.put(Snapshot.PATCHED_CONTENT_ANNO, patchedContent); + annotations.put(Snapshot.PATCHED_RAW_ANNO, patchedRaw); + }) + ); + } + + @Override + public Mono patchAndCreate(@NonNull Snapshot snapshot, + @Nullable Snapshot baseSnapshot, + @NonNull Content content) { + return Mono.just(snapshot) + .doOnNext(s -> this.patch(s, baseSnapshot, content)) + .flatMap(client::create); + } + + @Override + public Mono patchAndUpdate(@NonNull Snapshot snapshot, + @NonNull Snapshot baseSnapshot, + @NonNull Content content) { + return Mono.just(snapshot) + .doOnNext(s -> this.patch(s, baseSnapshot, content)) + .flatMap(client::update); + } + + private void patch(@NonNull Snapshot snapshot, + @Nullable Snapshot baseSnapshot, + @NonNull Content content) { + var annotations = snapshot.getMetadata().getAnnotations(); + if (annotations != null) { + annotations.remove(Snapshot.PATCHED_CONTENT_ANNO); + annotations.remove(Snapshot.PATCHED_RAW_ANNO); + } + var spec = snapshot.getSpec(); + if (spec == null) { + spec = new Snapshot.SnapShotSpec(); + } + spec.setRawType(content.rawType()); + if (baseSnapshot == null || Objects.equals(snapshot, baseSnapshot)) { + // indicate the snapshot is a base snapshot + // update raw and content directly + spec.setRawPatch(content.raw()); + spec.setContentPatch(content.content()); + } else { + // apply the patch and set the raw and content + var baseSpec = baseSnapshot.getSpec(); + var baseContent = baseSpec.getContentPatch(); + var baseRaw = baseSpec.getRawPatch(); + + var rawPatch = PatchUtils.diffToJsonPatch(baseRaw, content.raw()); + var contentPatch = PatchUtils.diffToJsonPatch(baseContent, content.content()); + spec.setRawPatch(rawPatch); + spec.setContentPatch(contentPatch); + } + spec.setLastModifyTime(clock.instant()); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java index b888db542..559afb512 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -23,6 +23,7 @@ import org.springframework.web.server.ServerErrorException; import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.content.Content; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ListedPost; import run.halo.app.content.PostQuery; @@ -132,7 +133,7 @@ public class PostEndpoint implements CustomEndpoint { .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() - .implementation(PostRequest.Content.class)) + .implementation(Content.class)) )) .response(responseBuilder() .implementation(Post.class)) @@ -191,7 +192,7 @@ public class PostEndpoint implements CustomEndpoint { Mono updateContent(ServerRequest request) { String postName = request.pathVariable("name"); - return request.bodyToMono(PostRequest.Content.class) + return request.bodyToMono(Content.class) .flatMap(content -> client.fetch(Post.class, postName) .flatMap(post -> { PostRequest postRequest = new PostRequest(post, content); diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java index 13eff25f5..1952d62b5 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java @@ -21,6 +21,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.thymeleaf.util.StringUtils; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.content.Content; import run.halo.app.content.ContentWrapper; import run.halo.app.content.ListedSinglePage; import run.halo.app.content.SinglePageQuery; @@ -130,7 +131,7 @@ public class SinglePageEndpoint implements CustomEndpoint { .content(contentBuilder() .mediaType(MediaType.APPLICATION_JSON_VALUE) .schema(Builder.schemaBuilder() - .implementation(SinglePageRequest.Content.class)) + .implementation(Content.class)) )) .response(responseBuilder() .implementation(Post.class)) @@ -169,7 +170,7 @@ public class SinglePageEndpoint implements CustomEndpoint { Mono updateContent(ServerRequest request) { String pageName = request.pathVariable("name"); - return request.bodyToMono(SinglePageRequest.Content.class) + return request.bodyToMono(Content.class) .flatMap(content -> client.fetch(SinglePage.class, pageName) .flatMap(page -> { SinglePageRequest pageRequest = new SinglePageRequest(page, content); diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java index a9f98f0a3..e9ec3cca4 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -66,11 +66,9 @@ public class DefaultRoleService implements RoleService { // search all permissions return extensionClient.list(Role.class, shouldFilterHidden(true), - compareCreationTimestamp(true)) - .filter(DefaultRoleService::isRoleTemplate); + compareCreationTimestamp(true)); } - return listDependencies(names, shouldFilterHidden(true)) - .filter(DefaultRoleService::isRoleTemplate); + return listDependencies(names, shouldFilterHidden(true)); } @Override diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java index ee7d48f06..f66be7f7c 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java @@ -2,10 +2,12 @@ package run.halo.app.core.extension.service.impl; import java.net.URI; import java.time.Duration; +import java.util.function.Consumer; import java.util.function.Function; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.security.core.Authentication; @@ -22,6 +24,7 @@ import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.core.extension.attachment.endpoint.DeleteOption; +import run.halo.app.core.extension.attachment.endpoint.SimpleFilePart; import run.halo.app.core.extension.attachment.endpoint.UploadOption; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.ConfigMap; @@ -42,12 +45,13 @@ public class DefaultAttachmentService implements AttachmentService { } @Override - public Mono upload(@NonNull String policyName, + public Mono upload( + @NonNull String username, + @NonNull String policyName, @Nullable String groupName, - @NonNull String filename, - @NonNull Flux content, - @Nullable MediaType mediaType) { - return authenticationConsumer(authentication -> client.get(Policy.class, policyName) + @NonNull FilePart filePart, + @Nullable Consumer beforeCreating) { + return client.get(Policy.class, policyName) .flatMap(policy -> { var configMapName = policy.getSpec().getConfigMapName(); if (!StringUtils.hasText(configMapName)) { @@ -55,11 +59,7 @@ public class DefaultAttachmentService implements AttachmentService { "ConfigMap name not found in Policy " + policyName)); } return client.get(ConfigMap.class, configMapName) - .map(configMap -> UploadOption.from(filename, - content, - mediaType, - policy, - configMap)); + .map(configMap -> new UploadOption(filePart, policy, configMap)); }) .flatMap(uploadContext -> { var handlers = extensionComponentsFinder.getExtensions(AttachmentHandler.class); @@ -75,13 +75,29 @@ public class DefaultAttachmentService implements AttachmentService { spec = new Attachment.AttachmentSpec(); attachment.setSpec(spec); } - spec.setOwnerName(authentication.getName()); + spec.setOwnerName(username); if (StringUtils.hasText(groupName)) { spec.setGroupName(groupName); } spec.setPolicyName(policyName); }) - .flatMap(client::create)); + .doOnNext(attachment -> { + if (beforeCreating != null) { + beforeCreating.accept(attachment); + } + }) + .flatMap(client::create); + } + + @Override + public Mono upload(@NonNull String policyName, + @Nullable String groupName, + @NonNull String filename, + @NonNull Flux content, + @Nullable MediaType mediaType) { + var file = new SimpleFilePart(filename, content, mediaType); + return authenticationConsumer( + authentication -> upload(authentication.getName(), policyName, groupName, file, null)); } @Override diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java new file mode 100644 index 000000000..268d73f08 --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java @@ -0,0 +1,208 @@ +package run.halo.app.endpoint.uc.content; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +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.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; +import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.HashMap; +import java.util.function.Consumer; +import org.apache.commons.lang3.StringUtils; +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.web.reactive.function.BodyExtractors; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.content.PostService; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.service.AttachmentService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.NotFoundException; + +@Component +public class UcPostAttachmentEndpoint implements CustomEndpoint { + + public static final String POST_NAME_LABEL = "content.halo.run/post-name"; + public static final String SINGLE_PAGE_NAME_LABEL = "content.halo.run/single-page-name"; + + private final AttachmentService attachmentService; + + private final PostService postService; + + private final SystemConfigurableEnvironmentFetcher systemSettingFetcher; + + public UcPostAttachmentEndpoint(AttachmentService attachmentService, PostService postService, + SystemConfigurableEnvironmentFetcher systemSettingFetcher) { + this.attachmentService = attachmentService; + this.postService = postService; + this.systemSettingFetcher = systemSettingFetcher; + } + + @Override + public RouterFunction endpoint() { + var tag = groupVersion() + "/Attachment"; + return route() + .POST("/attachments", + this::createAttachmentForPost, + builder -> builder.operationId("CreateAttachmentForPost").tag(tag) + .description("Create attachment for the given post.") + .parameter(parameterBuilder() + .name("waitForPermalink") + .description("Wait for permalink.") + .in(ParameterIn.QUERY) + .required(false) + .implementation(boolean.class)) + .requestBody(requestBodyBuilder() + .content(contentBuilder() + .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(schemaBuilder().implementation(PostAttachmentRequest.class))) + ) + .response(responseBuilder().implementation(Attachment.class)) + ) + .build(); + } + + private Mono createAttachmentForPost(ServerRequest request) { + var postAttachmentRequestMono = request.body(BodyExtractors.toMultipartData()) + .map(PostAttachmentRequest::from) + .cache(); + + var postSettingMono = systemSettingFetcher.fetchPost() + .handle((postSetting, sink) -> { + var attachmentPolicyName = postSetting.getAttachmentPolicyName(); + if (StringUtils.isBlank(attachmentPolicyName)) { + sink.error(new ServerWebInputException( + "Please configure storage policy for post attachment first.")); + return; + } + sink.next(postSetting); + }); + + // get settings + var createdAttachment = postSettingMono.flatMap(postSetting -> postAttachmentRequestMono + .flatMap(postAttachmentRequest -> getCurrentUser().flatMap( + username -> attachmentService.upload(username, + postSetting.getAttachmentPolicyName(), + postSetting.getAttachmentGroupName(), + postAttachmentRequest.file(), + linkWith(postAttachmentRequest))))); + + var waitForPermalink = request.queryParam("waitForPermalink") + .map(Boolean::valueOf) + .orElse(false); + if (waitForPermalink) { + createdAttachment = createdAttachment.flatMap(attachment -> + attachmentService.getPermalink(attachment) + .doOnNext(permalink -> { + var status = attachment.getStatus(); + if (status == null) { + status = new Attachment.AttachmentStatus(); + attachment.setStatus(status); + } + status.setPermalink(permalink.toString()); + }) + .thenReturn(attachment)); + } + return ServerResponse.ok().body(createdAttachment, Attachment.class); + } + + private Consumer linkWith(PostAttachmentRequest request) { + return attachment -> { + var labels = attachment.getMetadata().getLabels(); + if (labels == null) { + labels = new HashMap<>(); + attachment.getMetadata().setLabels(labels); + } + if (StringUtils.isNotBlank(request.postName())) { + labels.put(POST_NAME_LABEL, request.postName()); + } + if (StringUtils.isNotBlank(request.singlePageName())) { + labels.put(SINGLE_PAGE_NAME_LABEL, request.singlePageName()); + } + }; + } + + private Mono checkPostOwnership(Mono postAttachmentRequest) { + // check the post + var postNotFoundError = Mono.error( + () -> new NotFoundException("The post was not found or deleted.") + ); + return postAttachmentRequest.map(PostAttachmentRequest::postName) + .flatMap(postName -> getCurrentUser() + .flatMap(username -> postService.getByUsername(postName, username) + .switchIfEmpty(postNotFoundError))) + .then(); + } + + private Mono getCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); + } + + @Schema(types = "object") + public record PostAttachmentRequest( + @Schema(requiredMode = REQUIRED, description = "Attachment data.") + FilePart file, + + @Schema(requiredMode = NOT_REQUIRED, description = "Post name.") + String postName, + + @Schema(requiredMode = NOT_REQUIRED, description = "Single page name.") + String singlePageName + ) { + + /** + * Convert multipart data into PostAttachmentRequest. + * + * @param multipartData is multipart data from request. + * @return post attachment request data. + */ + public static PostAttachmentRequest from(MultiValueMap multipartData) { + var part = multipartData.getFirst("postName"); + String postName = null; + if (part instanceof FormFieldPart formFieldPart) { + postName = formFieldPart.value(); + } + + part = multipartData.getFirst("singlePageName"); + String singlePageName = null; + if (part instanceof FormFieldPart formFieldPart) { + singlePageName = formFieldPart.value(); + } + + part = multipartData.getFirst("file"); + if (!(part instanceof FilePart file)) { + throw new ServerWebInputException("Invalid type of parameter 'file'."); + } + + return new PostAttachmentRequest(file, postName, singlePageName); + } + + } +} diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java new file mode 100644 index 000000000..18a82119a --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java @@ -0,0 +1,333 @@ +package run.halo.app.endpoint.uc.content; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; +import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +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.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.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.content.Content; +import run.halo.app.content.ListedPost; +import run.halo.app.content.PostQuery; +import run.halo.app.content.PostRequest; +import run.halo.app.content.PostService; +import run.halo.app.content.SnapshotService; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Snapshot; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.Ref; +import run.halo.app.infra.exception.NotFoundException; +import run.halo.app.infra.utils.JsonUtils; + +@Component +public class UcPostEndpoint implements CustomEndpoint { + + private static final String CONTENT_JSON_ANNO = "content.halo.run/content-json"; + + private final PostService postService; + + private final SnapshotService snapshotService; + + public UcPostEndpoint(PostService postService, SnapshotService snapshotService) { + this.postService = postService; + this.snapshotService = snapshotService; + } + + @Override + public RouterFunction endpoint() { + var tag = groupVersion() + "/Post"; + var namePathParam = parameterBuilder().name("name") + .description("Post name") + .in(ParameterIn.PATH) + .required(true); + return route().nest( + path("/posts"), + () -> route() + .GET(this::listMyPost, builder -> { + builder.operationId("ListMyPosts") + .description("List posts owned by the current user.") + .tag(tag) + .response(responseBuilder().implementation( + ListResult.generateGenericClass(ListedPost.class))); + buildParametersFromType(builder, PostQuery.class); + } + ) + .POST(this::createMyPost, builder -> builder.operationId("CreateMyPost") + .tag(tag) + .description(""" + Create my post. If you want to create a post with content, please set + annotation: "content.halo.run/content-json" into annotations and refer + to Content for corresponding data type. + """) + .requestBody(requestBodyBuilder().implementation(Post.class)) + .response(responseBuilder().implementation(Post.class)) + ) + .GET("/{name}", this::getMyPost, builder -> builder.operationId("GetMyPost") + .tag(tag) + .parameter(namePathParam) + .description("Get post that belongs to the current user.") + .response(responseBuilder().implementation(Post.class)) + ) + .PUT("/{name}", this::updateMyPost, builder -> + builder.operationId("UpdateMyPost") + .tag(tag) + .parameter(namePathParam) + .description("Update my post.") + .requestBody(requestBodyBuilder().implementation(Post.class)) + .response(responseBuilder().implementation(Post.class)) + ) + .GET("/{name}/draft", this::getMyPostDraft, builder -> builder.tag(tag) + .operationId("GetMyPostDraft") + .description("Get my post draft.") + .parameter(namePathParam) + .parameter(parameterBuilder() + .name("patched") + .in(ParameterIn.QUERY) + .required(false) + .implementation(Boolean.class) + .description("Should include patched content and raw or not.") + ) + .response(responseBuilder().implementation(Snapshot.class)) + ) + .PUT("/{name}/draft", this::updateMyPostDraft, builder -> builder.tag(tag) + .operationId("UpdateMyPostDraft") + .description(""" + Update draft of my post. Please make sure set annotation: + "content.halo.run/content-json" into annotations and refer to + Content for corresponding data type. + """) + .parameter(namePathParam) + .requestBody(requestBodyBuilder().implementation(Snapshot.class)) + .response(responseBuilder().implementation(Snapshot.class))) + .PUT("/{name}/publish", this::publishMyPost, builder -> builder.tag(tag) + .operationId("PublishMyPost") + .description("Publish my post.") + .parameter(namePathParam) + .response(responseBuilder().implementation(Post.class))) + .PUT("/{name}/unpublish", this::unpublishMyPost, builder -> builder.tag(tag) + .operationId("UnpublishMyPost") + .description("Unpublish my post.") + .parameter(namePathParam) + .response(responseBuilder().implementation(Post.class))) + .build(), + builder -> { + }) + .build(); + } + + private Mono getMyPostDraft(ServerRequest request) { + var name = request.pathVariable("name"); + var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false); + var draft = getMyPost(name) + .flatMap(post -> { + var headSnapshotName = post.getSpec().getHeadSnapshot(); + var baseSnapshotName = post.getSpec().getBaseSnapshot(); + if (StringUtils.isBlank(headSnapshotName)) { + headSnapshotName = baseSnapshotName; + } + if (patched) { + return snapshotService.getPatchedBy(headSnapshotName, baseSnapshotName); + } + return snapshotService.getBy(headSnapshotName); + }); + return ServerResponse.ok().body(draft, Snapshot.class); + } + + private Mono unpublishMyPost(ServerRequest request) { + var name = request.pathVariable("name"); + var postMono = getCurrentUser() + .flatMap(username -> postService.getByUsername(name, username)); + var unpublishedPost = postMono.flatMap(postService::unpublish); + return ServerResponse.ok().body(unpublishedPost, Post.class); + } + + private Mono publishMyPost(ServerRequest request) { + var name = request.pathVariable("name"); + var postMono = getCurrentUser() + .flatMap(username -> postService.getByUsername(name, username)); + + var publishedPost = postMono.flatMap(postService::publish); + return ServerResponse.ok().body(publishedPost, Post.class); + } + + private Mono updateMyPostDraft(ServerRequest request) { + var name = request.pathVariable("name"); + var postMono = getMyPost(name).cache(); + var snapshotMono = request.bodyToMono(Snapshot.class).cache(); + + var contentMono = snapshotMono + .map(Snapshot::getMetadata) + .filter(metadata -> { + var annotations = metadata.getAnnotations(); + return annotations != null && annotations.containsKey(CONTENT_JSON_ANNO); + }) + .map(metadata -> { + var contentJson = metadata.getAnnotations().remove(CONTENT_JSON_ANNO); + return JsonUtils.jsonToObject(contentJson, Content.class); + }) + .cache(); + + // check the snapshot belongs to the post. + var checkSnapshot = postMono.flatMap(post -> snapshotMono.filter( + snapshot -> Ref.equals(snapshot.getSpec().getSubjectRef(), post) + ).switchIfEmpty(Mono.error(() -> + new ServerWebInputException("The snapshot does not belong to the given post.")) + ).filter(snapshot -> { + var snapshotName = snapshot.getMetadata().getName(); + var headSnapshotName = post.getSpec().getHeadSnapshot(); + return Objects.equals(snapshotName, headSnapshotName); + }).switchIfEmpty(Mono.error(() -> + new ServerWebInputException("The snapshot was not the head snapshot of the post."))) + ).then(); + + var setContributor = getCurrentUser().flatMap(username -> + snapshotMono.doOnNext(snapshot -> Snapshot.addContributor(snapshot, username))); + + var getBaseSnapshot = postMono.map(post -> post.getSpec().getBaseSnapshot()) + .flatMap(snapshotService::getBy); + + var updatedSnapshot = getBaseSnapshot.flatMap( + baseSnapshot -> contentMono.flatMap(content -> postMono.flatMap(post -> { + var postName = post.getMetadata().getName(); + var headSnapshotName = post.getSpec().getHeadSnapshot(); + var releaseSnapshotName = post.getSpec().getReleaseSnapshot(); + if (!Objects.equals(headSnapshotName, releaseSnapshotName)) { + // patch and update + return snapshotMono.flatMap( + s -> snapshotService.patchAndUpdate(s, baseSnapshot, content)); + } + // patch and create + return getCurrentUser().map( + username -> { + var metadata = new Metadata(); + metadata.setGenerateName(postName + "-snapshot-"); + var spec = new Snapshot.SnapShotSpec(); + spec.setParentSnapshotName(headSnapshotName); + spec.setOwner(username); + spec.setSubjectRef(Ref.of(post)); + + var snapshot = new Snapshot(); + snapshot.setMetadata(metadata); + snapshot.setSpec(spec); + Snapshot.addContributor(snapshot, username); + return snapshot; + }) + .flatMap(s -> snapshotService.patchAndCreate(s, baseSnapshot, content)) + .flatMap(createdSnapshot -> { + post.getSpec().setHeadSnapshot(createdSnapshot.getMetadata().getName()); + return postService.updateBy(post).thenReturn(createdSnapshot); + }); + }))); + + return ServerResponse.ok() + .body(checkSnapshot.and(setContributor).then(updatedSnapshot), Snapshot.class); + } + + private Mono updateMyPost(ServerRequest request) { + var name = request.pathVariable("name"); + + var postBody = request.bodyToMono(Post.class) + .doOnNext(post -> { + var annotations = post.getMetadata().getAnnotations(); + if (annotations != null) { + // we don't support updating content while updating post. + annotations.remove(CONTENT_JSON_ANNO); + } + }) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))); + + var updatedPost = getMyPost(name).flatMap(oldPost -> + postBody.doOnNext(post -> { + var oldSpec = oldPost.getSpec(); + // restrict fields of post.spec. + var spec = post.getSpec(); + spec.setOwner(oldSpec.getOwner()); + spec.setPublish(oldSpec.getPublish()); + spec.setPublishTime(oldSpec.getPublishTime()); + spec.setHeadSnapshot(oldSpec.getHeadSnapshot()); + spec.setBaseSnapshot(oldSpec.getBaseSnapshot()); + spec.setReleaseSnapshot(oldSpec.getReleaseSnapshot()); + spec.setDeleted(oldSpec.getDeleted()); + post.getMetadata().setName(oldPost.getMetadata().getName()); + })) + .flatMap(postService::updateBy); + return ServerResponse.ok().body(updatedPost, Post.class); + } + + private Mono createMyPost(ServerRequest request) { + var postFromRequest = request.bodyToMono(Post.class) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))); + + var createdPost = getCurrentUser() + .flatMap(username -> postFromRequest + .doOnNext(post -> { + if (post.getSpec() == null) { + post.setSpec(new Post.PostSpec()); + } + post.getSpec().setOwner(username); + })) + .map(post -> new PostRequest(post, getContent(post))) + .flatMap(postService::draftPost); + return ServerResponse.ok().body(createdPost, Post.class); + } + + private Content getContent(Post post) { + Content content = null; + var annotations = post.getMetadata().getAnnotations(); + if (annotations != null && annotations.containsKey(CONTENT_JSON_ANNO)) { + var contentJson = annotations.remove(CONTENT_JSON_ANNO); + content = JsonUtils.jsonToObject(contentJson, Content.class); + } + return content; + } + + private Mono listMyPost(ServerRequest request) { + var posts = getCurrentUser() + .map(username -> new PostQuery(request, username)) + .flatMap(postService::listPost); + return ServerResponse.ok().body(posts, ListedPost.class); + } + + private Mono getMyPost(ServerRequest request) { + var postName = request.pathVariable("name"); + var post = getMyPost(postName); + return ServerResponse.ok().body(post, Post.class); + } + + private Mono getMyPost(String postName) { + return getCurrentUser() + .flatMap(username -> postService.getByUsername(postName, username) + .switchIfEmpty( + Mono.error(() -> new NotFoundException("The post was not found or deleted")) + ) + ); + } + + private Mono getCurrentUser() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); + } + +} diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java b/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java new file mode 100644 index 000000000..1755f4de0 --- /dev/null +++ b/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java @@ -0,0 +1,124 @@ +package run.halo.app.endpoint.uc.content; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +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.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.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.content.PostService; +import run.halo.app.content.SnapshotService; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Snapshot; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.Ref; +import run.halo.app.infra.exception.NotFoundException; + +@Component +public class UcSnapshotEndpoint implements CustomEndpoint { + + private final PostService postService; + + private final SnapshotService snapshotService; + + public UcSnapshotEndpoint(PostService postService, SnapshotService snapshotService) { + this.postService = postService; + this.snapshotService = snapshotService; + } + + @Override + public RouterFunction endpoint() { + var tag = groupVersion() + "/Snapshot"; + + return route().nest(path("/snapshots"), + () -> route() + .GET("/{name}", + this::getSnapshot, + builder -> builder.operationId("GetSnapshotForPost") + .description("Get snapshot for one post.") + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .description("Snapshot name.") + ) + .parameter(parameterBuilder() + .name("postName") + .in(ParameterIn.QUERY) + .required(true) + .description("Post name.") + ) + .parameter(parameterBuilder() + .name("patched") + .in(ParameterIn.QUERY) + .required(false) + .implementation(Boolean.class) + .description("Should include patched content and raw or not.") + ) + .response(responseBuilder().implementation(Snapshot.class)) + .tag(tag)) + .build(), + builder -> { + }) + .build(); + } + + private Mono getSnapshot(ServerRequest request) { + var snapshotName = request.pathVariable("name"); + var postName = request.queryParam("postName") + .orElseThrow(() -> new ServerWebInputException("Query parameter postName is required")); + var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false); + + var postNotFoundError = Mono.error( + () -> new NotFoundException("The post was not found or deleted.") + ); + var snapshotNotFoundError = Mono.error( + () -> new NotFoundException("The snapshot was not found or deleted.") + ); + + var postMono = getCurrentUser().flatMap(username -> + postService.getByUsername(postName, username).switchIfEmpty(postNotFoundError) + ); + + // check the post belongs to the current user. + var snapshotMono = postMono.flatMap(post -> Mono.defer( + () -> { + if (patched) { + var baseSnapshotName = post.getSpec().getBaseSnapshot(); + return snapshotService.getPatchedBy(snapshotName, baseSnapshotName); + } + return snapshotService.getBy(snapshotName); + }) + .filter(snapshot -> { + var subjectRef = snapshot.getSpec().getSubjectRef(); + return Ref.equals(subjectRef, post); + }) + .switchIfEmpty(snapshotNotFoundError) + ); + + return ServerResponse.ok().body(snapshotMono, Snapshot.class); + } + + private Mono getCurrentUser() { + return ReactiveSecurityContextHolder + .getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); + } + +} diff --git a/application/src/main/resources/extensions/role-template-category.yaml b/application/src/main/resources/extensions/role-template-category.yaml index d3166d0d0..d40cef58c 100644 --- a/application/src/main/resources/extensions/role-template-category.yaml +++ b/application/src/main/resources/extensions/role-template-category.yaml @@ -7,6 +7,8 @@ metadata: halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: "[ \"role-template-view-categories\" ]" + rbac.authorization.halo.run/ui-permissions: | + [ "system:categories:manage", "uc:categories:manage" ] rules: - apiGroups: [ "content.halo.run" ] resources: [ "categories" ] @@ -19,6 +21,9 @@ metadata: labels: halo.run/role-template: "true" halo.run/hidden: "true" + annotations: + rbac.authorization.halo.run/ui-permissions: | + [ "system:categories:view", "uc:categories:view" ] rules: - apiGroups: [ "content.halo.run" ] resources: [ "categories" ] diff --git a/application/src/main/resources/extensions/role-template-uc-content.yaml b/application/src/main/resources/extensions/role-template-uc-content.yaml new file mode 100644 index 000000000..3904fdca0 --- /dev/null +++ b/application/src/main/resources/extensions/role-template-uc-content.yaml @@ -0,0 +1,94 @@ +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: post-editor + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Editor" + rbac.authorization.halo.run/dependencies: | + ["role-template-manage-posts", "post-author"] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: post-author + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Author" + rbac.authorization.halo.run/dependencies: | + [ "post-contributor", "post-publisher" ] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: post-contributor + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Contributor" + rbac.authorization.halo.run/dependencies: | + [ "role-template-view-categories", "role-template-view-tags" ] + rbac.authorization.halo.run/ui-permissions: | + [ "uc:posts:manage" ] +rules: + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "posts" ] + verbs: [ "get", "list", "create", "update", "delete" ] + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "posts/draft" ] + verbs: [ "update", "get" ] + +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: post-publisher + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Publisher" + rbac.authorization.halo.run/ui-permissions: | + [ "uc:posts:publish" ] +rules: + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "posts/publish", "posts/unpublish" ] + verbs: [ "update" ] +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: post-attachment-manager + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Attachment Manager" + rbac.authorization.halo.run/dependencies: | + [ "role-template-post-attachment-viewer" ] + rbac.authorization.halo.run/ui-permissions: | + [ "uc:attachments:manage" ] +rules: + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "attachments" ] + verbs: [ "create", "update", "delete" ] + +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: post-attachment-viewer + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Attachment Viewer" + rbac.authorization.halo.run/ui-permissions: | + [ "uc:attachments:view" ] +rules: + - apiGroups: [ "uc.api.content.halo.run" ] + resources: [ "attachments" ] + verbs: [ "get", "list" ] \ No newline at end of file diff --git a/application/src/main/resources/extensions/system-configurable-configmap.yaml b/application/src/main/resources/extensions/system-configurable-configmap.yaml index 808329854..e30b1dca8 100644 --- a/application/src/main/resources/extensions/system-configurable-configmap.yaml +++ b/application/src/main/resources/extensions/system-configurable-configmap.yaml @@ -32,7 +32,8 @@ data: "archivePageSize": 10, "categoryPageSize": 10, "tagPageSize": 10, - "slugGenerationStrategy": "generateByTitle" + "slugGenerationStrategy": "generateByTitle", + "attachmentPolicyName": "default-policy" } comment: | { diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index b7fee0474..0f3a4f0c9 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -68,6 +68,14 @@ spec: value: 'shortUUID' - label: 'UUID' value: 'UUID' + - $formkit: attachmentPolicySelect + name: attachmentPolicyName + label: "附件存储策略" + value: "default-policy" + - $formkit: attachmentGroupSelect + name: attachmentGroupName + label: "附件存储组" + value: "" - group: seo label: SEO 设置 formSchema: diff --git a/application/src/test/java/run/halo/app/content/PostQueryTest.java b/application/src/test/java/run/halo/app/content/PostQueryTest.java index 5c77a103e..54ec0fc94 100644 --- a/application/src/test/java/run/halo/app/content/PostQueryTest.java +++ b/application/src/test/java/run/halo/app/content/PostQueryTest.java @@ -23,6 +23,26 @@ import run.halo.app.core.extension.content.Post; @ExtendWith(MockitoExtension.class) class PostQueryTest { + @Test + void userScopedQueryTest() { + MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); + MockServerRequest request = MockServerRequest.builder() + .queryParams(multiValueMap) + .exchange(mock(ServerWebExchange.class)) + .build(); + + PostQuery postQuery = new PostQuery(request, "faker"); + var spec = new Post.PostSpec(); + var post = new Post(); + post.setSpec(spec); + + spec.setOwner("another-faker"); + assertThat(postQuery.toPredicate().test(post)).isFalse(); + + spec.setOwner("faker"); + assertThat(postQuery.toPredicate().test(post)).isTrue(); + } + @Test void toPredicate() { MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java index 8846b2f76..23e6d6a44 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java @@ -18,6 +18,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; +import run.halo.app.content.Content; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.TestPost; @@ -176,6 +177,6 @@ class PostEndpointTest { } PostRequest postRequest(Post post) { - return new PostRequest(post, new PostRequest.Content("B", "

B

", "MARKDOWN")); + return new PostRequest(post, new Content("B", "

B

", "MARKDOWN")); } } \ No newline at end of file diff --git a/console/console-src/modules/contents/pages/SinglePageEditor.vue b/console/console-src/modules/contents/pages/SinglePageEditor.vue index 65742ea57..ba0806514 100644 --- a/console/console-src/modules/contents/pages/SinglePageEditor.vue +++ b/console/console-src/modules/contents/pages/SinglePageEditor.vue @@ -28,7 +28,7 @@ import cloneDeep from "lodash.clonedeep"; import { useRouter } from "vue-router"; import { randomUUID } from "@/utils/id"; import { useContentCache } from "@console/composables/use-content-cache"; -import { useEditorExtensionPoints } from "@console/composables/use-editor-extension-points"; +import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points"; import type { EditorProvider } from "@halo-dev/console-shared"; import { useLocalStorage } from "@vueuse/core"; import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue"; @@ -372,6 +372,20 @@ const handlePreview = async () => { }; useSaveKeybinding(handleSave); + +// Upload image +async function handleUploadImage(file: File) { + if (!isUpdateMode.value) { + await handleSave(); + } + + const { data } = await apiClient.uc.attachment.createAttachmentForPost({ + file, + singlePageName: formState.value.page.metadata.name, + waitForPermalink: true, + }); + return data; +}