1.13.1原版
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.micrometer.common.util.StringUtils;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import lombok.Builder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.notification.Reason;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.notification.NotificationReasonEmitter;
|
||||
import run.halo.app.notification.UserIdentity;
|
||||
import run.halo.moments.event.MomentHasNewCommentEvent;
|
||||
|
||||
/**
|
||||
* Notification reason publisher for {@link Comment}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CommentNotificationReasonPublisher {
|
||||
private static final DateTimeFormatter DEFAULT_DATE_FORMATTER = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
public static final String NEW_COMMENT_ON_MOMENT = "new-comment-on-moment";
|
||||
public static final String NOTIFIED_ANNO = "moment.halo.run/notified";
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final NotificationReasonEmitter notificationReasonEmitter;
|
||||
private final ExternalLinkProcessor externalLinkProcessor;
|
||||
|
||||
/**
|
||||
* On new comment.
|
||||
*/
|
||||
@Async
|
||||
@EventListener(MomentHasNewCommentEvent.class)
|
||||
public void onNewComment(MomentHasNewCommentEvent event) {
|
||||
Comment comment = event.getComment();
|
||||
var annotations = MetadataUtil.nullSafeAnnotations(comment);
|
||||
if (annotations.containsKey(NOTIFIED_ANNO)) {
|
||||
return;
|
||||
}
|
||||
publishReasonBy(comment);
|
||||
markAsNotified(comment.getMetadata().getName());
|
||||
}
|
||||
|
||||
private void markAsNotified(String commentName) {
|
||||
client.fetch(Comment.class, commentName).ifPresent(latestComment -> {
|
||||
MetadataUtil.nullSafeAnnotations(latestComment).put(NOTIFIED_ANNO, "true");
|
||||
client.update(latestComment);
|
||||
});
|
||||
}
|
||||
|
||||
public void publishReasonBy(Comment comment) {
|
||||
Ref subjectRef = comment.getSpec().getSubjectRef();
|
||||
var moment = client.fetch(Moment.class, subjectRef.getName()).orElseThrow();
|
||||
if (doNotEmitReason(comment, moment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String momentUrl =
|
||||
externalLinkProcessor.processLink("/moments/" + moment.getMetadata().getName());
|
||||
var reasonSubject = Reason.Subject.builder()
|
||||
.apiVersion(moment.getApiVersion())
|
||||
.kind(moment.getKind())
|
||||
.title("瞬间:" + moment.getMetadata().getName())
|
||||
.name(subjectRef.getName())
|
||||
.url(momentUrl)
|
||||
.build();
|
||||
|
||||
var momentContent =
|
||||
defaultIfNull(moment.getSpec().getContent(), new Moment.MomentContent());
|
||||
var owner = comment.getSpec().getOwner();
|
||||
notificationReasonEmitter.emit(NEW_COMMENT_ON_MOMENT,
|
||||
builder -> {
|
||||
var attributes = CommentOnMomentReasonData.builder()
|
||||
.momentName(moment.getMetadata().getName())
|
||||
.momentOwner(moment.getSpec().getOwner())
|
||||
.momentCreatedAt(
|
||||
DEFAULT_DATE_FORMATTER.format(moment.getMetadata().getCreationTimestamp()))
|
||||
.momentHtmlContent(cleanHtmlTag(momentContent.getHtml(), Safelist.basic()))
|
||||
.momentRawContent(cleanHtmlTag(momentContent.getRaw(), Safelist.simpleText()))
|
||||
.momentUrl(momentUrl)
|
||||
.commenter(owner.getDisplayName())
|
||||
.content(comment.getSpec().getContent())
|
||||
.commentName(comment.getMetadata().getName())
|
||||
.build();
|
||||
builder.attributes(toAttributeMap(attributes))
|
||||
.author(identityFrom(owner))
|
||||
.subject(reasonSubject);
|
||||
}).block();
|
||||
}
|
||||
|
||||
static String cleanHtmlTag(String html, Safelist safelist) {
|
||||
if (StringUtils.isBlank(html)) {
|
||||
return "";
|
||||
}
|
||||
return Jsoup.clean(html, safelist);
|
||||
}
|
||||
|
||||
static <T> Map<String, Object> toAttributeMap(T data) {
|
||||
Assert.notNull(data, "Reason attributes must not be null");
|
||||
return JsonUtils.mapper().convertValue(data, new TypeReference<>() {
|
||||
});
|
||||
}
|
||||
|
||||
static UserIdentity identityFrom(Comment.CommentOwner owner) {
|
||||
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
|
||||
return UserIdentity.anonymousWithEmail(owner.getName());
|
||||
}
|
||||
return UserIdentity.of(owner.getName());
|
||||
}
|
||||
|
||||
boolean doNotEmitReason(Comment comment, Moment moment) {
|
||||
Comment.CommentOwner commentOwner = comment.getSpec().getOwner();
|
||||
String kind = commentOwner.getKind();
|
||||
String name = commentOwner.getName();
|
||||
var momentOwner = moment.getSpec().getOwner();
|
||||
if (Comment.CommentOwner.KIND_EMAIL.equals(kind)) {
|
||||
return false;
|
||||
}
|
||||
return name.equals(momentOwner);
|
||||
}
|
||||
|
||||
@Builder
|
||||
record CommentOnMomentReasonData(String momentName, String momentOwner, String momentCreatedAt,
|
||||
String momentHtmlContent, String momentRawContent,
|
||||
String momentUrl, String commenter, String content,
|
||||
String commentName) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.moments.event.MomentHasNewCommentEvent;
|
||||
|
||||
/**
|
||||
* Reconciler for comment.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private static final String FINALIZER = "moment.halo.run/finalizer";
|
||||
private final ExtensionClient client;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Comment.class, request.name()).ifPresent(comment -> {
|
||||
if (comment.getMetadata().getDeletionTimestamp() != null) {
|
||||
if (ExtensionUtil.removeFinalizers(comment.getMetadata(), Set.of(FINALIZER))) {
|
||||
client.update(comment);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var forMoment = Ref.groupKindEquals(comment.getSpec().getSubjectRef(),
|
||||
GroupVersionKind.fromExtension(Moment.class));
|
||||
if (!forMoment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ExtensionUtil.addFinalizers(comment.getMetadata(), Set.of(FINALIZER))) {
|
||||
eventPublisher.publishEvent(new MomentHasNewCommentEvent(this, comment));
|
||||
client.update(comment);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Comment())
|
||||
// avoid triggering notification on startup for old comments
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @author LIlGG
|
||||
*/
|
||||
@Data
|
||||
public class Contributor {
|
||||
private String displayName;
|
||||
private String avatar;
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.web.reactive.function.server.RequestPredicates;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
|
||||
public class CustomEndpointsBuilder {
|
||||
|
||||
private final Map<GroupVersion, List<RouterFunction<ServerResponse>>> routerFunctionsMap;
|
||||
|
||||
public CustomEndpointsBuilder() {
|
||||
routerFunctionsMap = new HashMap<>();
|
||||
}
|
||||
|
||||
public CustomEndpointsBuilder add(CustomEndpoint customEndpoint) {
|
||||
routerFunctionsMap
|
||||
.computeIfAbsent(customEndpoint.groupVersion(), gv -> new LinkedList<>())
|
||||
.add(customEndpoint.endpoint());
|
||||
return this;
|
||||
}
|
||||
|
||||
public RouterFunction<ServerResponse> build() {
|
||||
SpringdocRouteBuilder routeBuilder = SpringdocRouteBuilder.route();
|
||||
routerFunctionsMap.forEach((gv, routerFunctions) -> {
|
||||
routeBuilder.nest(RequestPredicates.path("/apis/" + gv),
|
||||
() -> routerFunctions.stream().reduce(RouterFunction::and).orElse(null),
|
||||
builder -> builder.operationId("CustomEndpoints")
|
||||
.description("Custom Endpoint")
|
||||
.tag(gv + "/CustomEndpoint")
|
||||
);
|
||||
});
|
||||
routerFunctionsMap.clear();
|
||||
return routeBuilder.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Listed moment.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class ListedMoment {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Moment moment;
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Contributor owner;
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Stats stats;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package run.halo.moments;
|
||||
|
||||
/**
|
||||
* Static variable keys for view model.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public enum ModelConst {
|
||||
;
|
||||
public static final String TEMPLATE_ID = "_templateId";
|
||||
public static final Integer DEFAULT_PAGE_SIZE = 10;
|
||||
public static final Integer SEARCH_DEFAULT_PAGE_SIZE = 200;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
@GVK(group = "moment.halo.run", version = "v1alpha1", kind = "Moment",
|
||||
plural = "moments", singular = "moment")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
public class Moment extends AbstractExtension {
|
||||
public static final String REQUIRE_SYNC_ON_STARTUP_INDEX_NAME = "requireSyncOnStartup";
|
||||
|
||||
@Schema(requiredMode = REQUIRED)
|
||||
private MomentSpec spec;
|
||||
|
||||
private Status status;
|
||||
|
||||
@Data
|
||||
public static class MomentSpec {
|
||||
|
||||
@Schema(requiredMode = REQUIRED)
|
||||
private MomentContent content;
|
||||
|
||||
@Schema(description = "Release timestamp. This field can be customized by owner")
|
||||
private Instant releaseTime;
|
||||
|
||||
@Schema(description = "Visible indicates when to show publicly. Default is public",
|
||||
defaultValue = "PUBLIC")
|
||||
private MomentVisible visible;
|
||||
|
||||
@Schema(requiredMode = REQUIRED, description = "Owner of the moment")
|
||||
private String owner;
|
||||
|
||||
@Schema(description = "Tags of the moment")
|
||||
private Set<String> tags;
|
||||
|
||||
@Schema(defaultValue = "false")
|
||||
private Boolean approved;
|
||||
|
||||
private Instant approvedTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(name = "MomentStatus")
|
||||
public static class Status {
|
||||
private long observedVersion;
|
||||
|
||||
private String permalink;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MomentContent {
|
||||
|
||||
@Schema(description = "Raw of content")
|
||||
private String raw;
|
||||
|
||||
@Schema(description = "Rendered result with HTML format")
|
||||
private String html;
|
||||
|
||||
@ArraySchema(
|
||||
uniqueItems = true,
|
||||
arraySchema = @Schema(description = "Medium of moment"),
|
||||
schema = @Schema(description = "Media item of moment"))
|
||||
private List<MomentMedia> medium;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MomentMedia {
|
||||
|
||||
@Schema(description = "Type of media")
|
||||
private MomentMediaType type;
|
||||
|
||||
@Schema(description = "External URL of media")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "Origin type of media.")
|
||||
private String originType;
|
||||
}
|
||||
|
||||
public enum MomentMediaType {
|
||||
PHOTO,
|
||||
VIDEO,
|
||||
POST,
|
||||
AUDIO,
|
||||
// TODO Might add more types here in the future
|
||||
}
|
||||
|
||||
public enum MomentVisible {
|
||||
/**
|
||||
* Public is default visible of moment.
|
||||
*/
|
||||
PUBLIC,
|
||||
|
||||
/**
|
||||
* Private visible is only for view for self.
|
||||
*/
|
||||
PRIVATE;
|
||||
// TODO Might add more visibles here in the future.
|
||||
|
||||
/**
|
||||
* Convert value string to {@link MomentVisible}.
|
||||
*
|
||||
* @param value enum value string
|
||||
* @return {@link MomentVisible} if found, otherwise null
|
||||
*/
|
||||
public static MomentVisible from(String value) {
|
||||
for (MomentVisible visible : MomentVisible.values()) {
|
||||
if (visible.name().equalsIgnoreCase(value)) {
|
||||
return visible;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.comment.CommentSubject;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Ref;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
|
||||
/**
|
||||
* <p>Comment subject for moment.</p>
|
||||
* This class helps to get comment subject by name when comment list query.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.0.2
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentCommentSubject implements CommentSubject<Moment> {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ExternalLinkProcessor externalLinkProcessor;
|
||||
|
||||
@Override
|
||||
public Mono<Moment> get(String name) {
|
||||
return client.fetch(Moment.class, name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SubjectDisplay> getSubjectDisplay(String name) {
|
||||
return get(name).map(moment -> {
|
||||
var content = Optional.ofNullable(moment.getSpec().getContent())
|
||||
.map(Moment.MomentContent::getRaw)
|
||||
.map(raw -> Jsoup.clean(raw, Safelist.none()))
|
||||
.map(raw -> raw.length() > 100 ? raw.substring(0, 100) : raw)
|
||||
.orElse(name);
|
||||
var momentUrl = externalLinkProcessor.processLink("/moments/" + name);
|
||||
return new SubjectDisplay(content, momentUrl, "瞬间");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Ref ref) {
|
||||
Assert.notNull(ref, "Subject ref must not be null.");
|
||||
GroupVersionKind groupVersionKind =
|
||||
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
|
||||
return GroupVersionKind.fromExtension(Moment.class).equals(groupVersionKind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package run.halo.moments;
|
||||
|
||||
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 io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import java.time.Instant;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.http.MediaType;
|
||||
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 reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.moments.service.MomentService;
|
||||
|
||||
/**
|
||||
* A custom endpoint for {@link run.halo.moments.Moment}.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class MomentEndpoint implements CustomEndpoint {
|
||||
|
||||
private final MomentService momentService;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "console.api.moment.halo.run/v1alpha1/Moment";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("moments", this::listMoment, builder -> {
|
||||
builder.operationId("ListMoments")
|
||||
.description("List moments.")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListResult.generateGenericClass(ListedMoment.class))
|
||||
);
|
||||
MomentQuery.buildParameters(builder);
|
||||
})
|
||||
.GET("moments/{name}", this::getMoment,
|
||||
builder -> builder.operationId("GetMoment")
|
||||
.description("Get a moment by name.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.description("Moment name")
|
||||
.required(true)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListedMoment.class)
|
||||
))
|
||||
.GET("tags", this::listMyTags,
|
||||
builder -> builder.operationId("ListTags")
|
||||
.description("List all moment tags.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.QUERY)
|
||||
.description("Tag name to query")
|
||||
.required(false)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementationArray(String.class)
|
||||
))
|
||||
.POST("moments", this::createMoment,
|
||||
builder -> builder.operationId("CreateMoment")
|
||||
.description("Create a Moment.")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(Moment.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Moment.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getMoment(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return momentService.findMomentByName(name)
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("console.api.moment.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createMoment(ServerRequest serverRequest) {
|
||||
return serverRequest.bodyToMono(Moment.class)
|
||||
.map(moment -> {
|
||||
moment.getSpec().setApproved(true);
|
||||
moment.getSpec().setApprovedTime(Instant.now());
|
||||
return moment;
|
||||
})
|
||||
.flatMap(momentService::create)
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listMoment(ServerRequest serverRequest) {
|
||||
MomentQuery query = new MomentQuery(serverRequest.exchange());
|
||||
return momentService.listMoment(query)
|
||||
.flatMap(listedMoments -> ServerResponse.ok().bodyValue(listedMoments));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listMyTags(ServerRequest request) {
|
||||
String name = request.queryParam("name").orElse(null);
|
||||
return getCurrentUser()
|
||||
.map(username -> new MomentQuery(request.exchange(), username))
|
||||
.flatMapMany(momentService::listAllTags)
|
||||
.filter(tagName -> StringUtils.isBlank(name) || StringUtils.containsIgnoreCase(tagName,
|
||||
name))
|
||||
.collectList()
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
private Mono<String> getCurrentUser() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static run.halo.app.extension.index.query.QueryFactory.and;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.isNull;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.DefaultExtensionMatcher;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
|
||||
/**
|
||||
* <p>Migration for 1.16.0, populate approved attribute for all moments before 1.16.0.</p>
|
||||
* <p>Note That: This migration is only for the moments that approved attribute is null.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentMigration implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
|
||||
moment.getSpec().setApproved(true);
|
||||
moment.getSpec().setApprovedTime(moment.getMetadata().getCreationTimestamp());
|
||||
client.update(moment);
|
||||
});
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
final var moment = new Moment();
|
||||
return builder
|
||||
.extension(moment)
|
||||
.onAddMatcher(DefaultExtensionMatcher.builder(client, moment.groupVersionKind())
|
||||
.fieldSelector(FieldSelector.of(
|
||||
and(isNull("spec.approved"),
|
||||
isNull("metadata.deletionTimestamp"))
|
||||
))
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.all;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.and;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.contains;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.greaterThanOrEqual;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.lessThanOrEqual;
|
||||
import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter;
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.operation.Builder;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.extension.router.SortableRequest;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
|
||||
/**
|
||||
* A query object for {@link Moment} list.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @author guqing
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class MomentQuery extends SortableRequest {
|
||||
private final MultiValueMap<String, String> queryParams;
|
||||
|
||||
private String username;
|
||||
|
||||
public MomentQuery(ServerWebExchange exchange) {
|
||||
super(exchange);
|
||||
this.queryParams = exchange.getRequest().getQueryParams();
|
||||
}
|
||||
|
||||
public MomentQuery(ServerWebExchange exchange, String username) {
|
||||
this(exchange);
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Schema(description = "Moments filtered by keyword.")
|
||||
public String getKeyword() {
|
||||
return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null);
|
||||
}
|
||||
|
||||
@Schema(description = "Owner name.")
|
||||
public String getOwnerName() {
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
return username;
|
||||
}
|
||||
String ownerName = queryParams.getFirst("ownerName");
|
||||
return StringUtils.isBlank(ownerName) ? null : ownerName;
|
||||
}
|
||||
|
||||
@Schema(description = "Moment tag.")
|
||||
public String getTag() {
|
||||
return StringUtils.defaultIfBlank(queryParams.getFirst("tag"), null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Moment.MomentVisible getVisible() {
|
||||
String visible = queryParams.getFirst("visible");
|
||||
return Moment.MomentVisible.from(visible);
|
||||
}
|
||||
|
||||
@Schema
|
||||
public Instant getStartDate() {
|
||||
String startDate = queryParams.getFirst("startDate");
|
||||
return convertInstantOrNull(startDate);
|
||||
}
|
||||
|
||||
@Schema
|
||||
public Instant getEndDate() {
|
||||
String endDate = queryParams.getFirst("endDate");
|
||||
return convertInstantOrNull(endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build {@link ListOptions} from query params.
|
||||
*
|
||||
* @return a list options.
|
||||
*/
|
||||
public ListOptions toListOptions() {
|
||||
var listOptions =
|
||||
labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector());
|
||||
var query = all();
|
||||
if (StringUtils.isNotBlank(getOwnerName())) {
|
||||
query = and(query, equal("spec.owner", getOwnerName()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(getTag())) {
|
||||
query = and(query, equal("spec.tags", getTag()));
|
||||
}
|
||||
if (getVisible() != null) {
|
||||
query = and(query, equal("spec.visible", getVisible().name()));
|
||||
}
|
||||
|
||||
if (getApproved() != null) {
|
||||
query = and(query, equal("spec.approved", Boolean.toString(getApproved())));
|
||||
}
|
||||
|
||||
if (getStartDate() != null) {
|
||||
query = and(query, greaterThanOrEqual("spec.releaseTime", getStartDate().toString()));
|
||||
}
|
||||
if (getEndDate() != null) {
|
||||
query = and(query, lessThanOrEqual("spec.releaseTime", getEndDate().toString()));
|
||||
}
|
||||
|
||||
if (listOptions.getFieldSelector() != null) {
|
||||
query = and(query, listOptions.getFieldSelector().query());
|
||||
}
|
||||
if (StringUtils.isNotBlank(getKeyword())) {
|
||||
query = and(query, contains("spec.owner", getKeyword()));
|
||||
}
|
||||
listOptions.setFieldSelector(FieldSelector.of(query));
|
||||
return listOptions;
|
||||
}
|
||||
|
||||
public PageRequest toPageRequest() {
|
||||
var sort = getSort();
|
||||
if (sort.isUnsorted()) {
|
||||
sort = Sort.by("spec.releaseTime").descending();
|
||||
}
|
||||
return PageRequestImpl.of(getPage(), getSize(), sort);
|
||||
}
|
||||
|
||||
@Schema(description = "moment approved.")
|
||||
public Boolean getApproved() {
|
||||
return convertBooleanOrNull(queryParams.getFirst("approved"));
|
||||
}
|
||||
|
||||
private Boolean convertBooleanOrNull(String value) {
|
||||
return StringUtils.isBlank(value) ? null : Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
private Instant convertInstantOrNull(String timeStr) {
|
||||
return StringUtils.isBlank(timeStr) ? null : Instant.parse(timeStr);
|
||||
}
|
||||
|
||||
public static void buildParameters(Builder builder) {
|
||||
IListRequest.buildParameters(builder);
|
||||
builder.parameter(sortParameter())
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("keyword")
|
||||
.description("Moments filtered by keyword.")
|
||||
.implementation(String.class)
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("ownerName")
|
||||
.description("Owner name.")
|
||||
.implementation(String.class)
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("tag")
|
||||
.description("Moment tag.")
|
||||
.implementation(String.class)
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("visible")
|
||||
.description("Moment visible.")
|
||||
.implementation(Moment.MomentVisible.class)
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("startDate")
|
||||
.implementation(Instant.class)
|
||||
.description("Moment start date.")
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("endDate")
|
||||
.implementation(Instant.class)
|
||||
.description("Moment end date.")
|
||||
.required(false))
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.QUERY)
|
||||
.name("approved")
|
||||
.description("Moment approved.")
|
||||
.implementation(Boolean.class)
|
||||
.required(false))
|
||||
;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.core.extension.notification.Subscription;
|
||||
import run.halo.app.extension.DefaultExtensionMatcher;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.notification.NotificationCenter;
|
||||
import run.halo.moments.event.MomentDeletedEvent;
|
||||
import run.halo.moments.event.MomentUpdatedEvent;
|
||||
|
||||
/**
|
||||
* {@link Reconciler} for {@link Moment}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private static final String FINALIZER = "moment-protection";
|
||||
private final ExtensionClient client;
|
||||
private final NotificationCenter notificationCenter;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
|
||||
if (ExtensionUtil.isDeleted(moment)) {
|
||||
if (ExtensionUtil.removeFinalizers(moment.getMetadata(), Set.of(FINALIZER))) {
|
||||
client.update(moment);
|
||||
eventPublisher.publishEvent(new MomentDeletedEvent(this, request.name()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ExtensionUtil.addFinalizers(moment.getMetadata(), Set.of(FINALIZER))) {
|
||||
// auto subscribe to new comment on moment
|
||||
createCommentSubscriptionForMoment(moment);
|
||||
}
|
||||
var status = moment.getStatus();
|
||||
if (status == null) {
|
||||
status = new Moment.Status();
|
||||
moment.setStatus(status);
|
||||
}
|
||||
status.setObservedVersion(moment.getMetadata().getVersion() + 1);
|
||||
// add approved marks to the old data by default.
|
||||
if (moment.getSpec().getApproved() == null) {
|
||||
moment.getSpec().setApproved(true);
|
||||
}
|
||||
if (moment.getSpec().getApproved() && moment.getSpec().getApprovedTime() == null) {
|
||||
moment.getSpec().setApprovedTime(Instant.now());
|
||||
}
|
||||
client.update(moment);
|
||||
|
||||
eventPublisher.publishEvent(new MomentUpdatedEvent(this, request.name()));
|
||||
});
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
void createCommentSubscriptionForMoment(Moment moment) {
|
||||
var owner = moment.getSpec().getOwner();
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
interestReason.setReasonType("new-comment-on-moment");
|
||||
interestReason.setExpression("props.momentOwner == '%s'".formatted(owner));
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(owner);
|
||||
notificationCenter.subscribe(subscriber, interestReason).block();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
final var moment = new Moment();
|
||||
return builder
|
||||
.extension(moment)
|
||||
.workerCount(5)
|
||||
.onAddMatcher(DefaultExtensionMatcher.builder(client, moment.groupVersionKind())
|
||||
.fieldSelector(
|
||||
FieldSelector.of(equal(Moment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, "true"))
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.math.NumberUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.HandlerFunction;
|
||||
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.util.UriComponentsBuilder;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import run.halo.app.theme.router.PageUrlUtils;
|
||||
import run.halo.app.theme.router.UrlContextListResult;
|
||||
import run.halo.moments.finders.MomentFinder;
|
||||
import run.halo.moments.vo.MomentVo;
|
||||
|
||||
|
||||
/**
|
||||
* Provides a <code>/moments</code> route for the topic end to handle routing.
|
||||
* Topic should contain a <code>moments.html</code> file.
|
||||
* <p>
|
||||
* In order to handle pagination, an additional /moments/page/{page} route has been adapted.
|
||||
* </p>
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentRouter {
|
||||
private static final String TAG_PARAM = "tag";
|
||||
private final MomentFinder momentFinder;
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> momentRouterFunction() {
|
||||
return route(GET("/moments").or(GET("/moments/page/{page:\\d+}")), handlerFunction())
|
||||
.andRoute(GET("/moments/{momentName:\\S+}"), handlerMomentDefault());
|
||||
}
|
||||
|
||||
private HandlerFunction<ServerResponse> handlerMomentDefault() {
|
||||
return request -> {
|
||||
String momentName = request.pathVariable("momentName");
|
||||
return ServerResponse.ok().render("moment",
|
||||
Map.of("moment", momentFinder.get(momentName),
|
||||
ModelConst.TEMPLATE_ID, "moment",
|
||||
"title", getMomentTitle())
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private HandlerFunction<ServerResponse> handlerFunction() {
|
||||
return request -> ServerResponse.ok().render("moments",
|
||||
Map.of("moments", momentList(request),
|
||||
ModelConst.TEMPLATE_ID, "moments",
|
||||
"tags", momentFinder.listAllTags(),
|
||||
"title", getMomentTitle()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Mono<String> getMomentTitle() {
|
||||
return this.settingFetcher.get("base")
|
||||
.map(setting -> setting.get("title").asText("瞬间"))
|
||||
.defaultIfEmpty("瞬间");
|
||||
}
|
||||
|
||||
private Mono<UrlContextListResult<MomentVo>> momentList(ServerRequest request) {
|
||||
String path = request.path();
|
||||
String tagVal = request.queryParam(TAG_PARAM)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.orElse(null);
|
||||
int pageNum = pageNumInPathVariable(request);
|
||||
String tag = tagPathQueryParam(request);
|
||||
return this.settingFetcher.get("base")
|
||||
.map(item -> item.get("pageSize").asInt(10))
|
||||
.defaultIfEmpty(10)
|
||||
.flatMap(pageSize -> momentFinder.listByTag(pageNum, pageSize, tag)
|
||||
.map(list -> new UrlContextListResult.Builder<MomentVo>()
|
||||
.listResult(list)
|
||||
.nextUrl(appendTagParamIfPresent(
|
||||
PageUrlUtils.nextPageUrl(path, totalPage(list)), tagVal)
|
||||
)
|
||||
.prevUrl(appendTagParamIfPresent(PageUrlUtils.prevPageUrl(path), tagVal))
|
||||
.build()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
String appendTagParamIfPresent(String uriString, String tagValue) {
|
||||
return UriComponentsBuilder.fromUriString(uriString)
|
||||
.queryParamIfPresent(TAG_PARAM, Optional.ofNullable(tagValue))
|
||||
.build()
|
||||
.toString();
|
||||
}
|
||||
|
||||
private int pageNumInPathVariable(ServerRequest request) {
|
||||
String page = request.pathVariables().get("page");
|
||||
return NumberUtils.toInt(page, 1);
|
||||
}
|
||||
|
||||
private String tagPathQueryParam(ServerRequest request) {
|
||||
return request.queryParam(TAG_PARAM).orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static run.halo.app.extension.index.IndexAttributeFactory.multiValueAttribute;
|
||||
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.extension.index.IndexSpec;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
|
||||
@Component
|
||||
public class MomentsPlugin extends BasePlugin {
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public MomentsPlugin(PluginContext pluginContext, SchemeManager schemeManager,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
super(pluginContext);
|
||||
this.schemeManager = schemeManager;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(Moment.class, indexSpecs -> {
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.tags")
|
||||
.setIndexFunc(multiValueAttribute(Moment.class, moment -> {
|
||||
var tags = moment.getSpec().getTags();
|
||||
return tags == null ? Set.of() : tags;
|
||||
}))
|
||||
);
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.owner")
|
||||
.setIndexFunc(
|
||||
simpleAttribute(Moment.class, moment -> moment.getSpec().getOwner())));
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.releaseTime")
|
||||
.setIndexFunc(simpleAttribute(Moment.class, moment -> {
|
||||
var releaseTime = moment.getSpec().getReleaseTime();
|
||||
return releaseTime == null ? null : releaseTime.toString();
|
||||
}))
|
||||
);
|
||||
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.visible")
|
||||
.setIndexFunc(simpleAttribute(Moment.class, moment -> {
|
||||
var visible = moment.getSpec().getVisible();
|
||||
return visible == null ? null : visible.toString();
|
||||
}))
|
||||
);
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.approved")
|
||||
.setIndexFunc(simpleAttribute(Moment.class, moment -> {
|
||||
var approved = moment.getSpec().getApproved();
|
||||
return approved == null ? null : approved.toString();
|
||||
}))
|
||||
);
|
||||
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName(Moment.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME)
|
||||
.setIndexFunc(simpleAttribute(Moment.class, moment -> {
|
||||
var observedVersion = Optional.ofNullable(moment.getStatus())
|
||||
.map(Moment.Status::getObservedVersion)
|
||||
.orElse(-1L);
|
||||
if (observedVersion < moment.getMetadata().getVersion()) {
|
||||
return BooleanUtils.TRUE;
|
||||
}
|
||||
// don't care about the false case
|
||||
return null;
|
||||
})));
|
||||
});
|
||||
eventPublisher.publishEvent(new SchemeRegistered(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(Moment.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
public class SchemeRegistered extends ApplicationEvent {
|
||||
public SchemeRegistered(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* Stats of moment.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class Stats {
|
||||
|
||||
Integer upvote;
|
||||
|
||||
Integer totalComment;
|
||||
|
||||
Integer approvedComment;
|
||||
|
||||
public static Stats empty() {
|
||||
return Stats.builder()
|
||||
.upvote(0)
|
||||
.totalComment(0)
|
||||
.approvedComment(0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package run.halo.moments;
|
||||
|
||||
import static run.halo.app.extension.index.query.QueryFactory.and;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.isNull;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.notification.Subscription;
|
||||
import run.halo.app.extension.Extension;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.notification.NotificationCenter;
|
||||
|
||||
/**
|
||||
* Subscription migration to adapt to the new expression subscribe mechanism.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.7.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SubscriptionMigration implements ApplicationListener<SchemeRegistered> {
|
||||
private final NotificationCenter notificationCenter;
|
||||
private final ReactiveExtensionClient client;
|
||||
static final String NEW_COMMENT_ON_MOMENT = "new-comment-on-moment";
|
||||
|
||||
@Async
|
||||
@Override
|
||||
public void onApplicationEvent(@NonNull SchemeRegistered event) {
|
||||
var listOptions = new ListOptions();
|
||||
var query = isNull("metadata.deletionTimestamp");
|
||||
listOptions.setFieldSelector(FieldSelector.of(query));
|
||||
client.listAll(User.class, listOptions, Sort.unsorted())
|
||||
.flatMap(user -> removeInternalSubscriptionForUser(user.getMetadata().getName()))
|
||||
.then()
|
||||
.doOnSuccess(unused -> log.info("Cleanup user moment subscription completed"))
|
||||
.block();
|
||||
}
|
||||
|
||||
private Mono<Void> removeInternalSubscriptionForUser(String username) {
|
||||
log.debug("Start to collating moment subscription for user: {}", username);
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(username);
|
||||
|
||||
var listOptions = new ListOptions();
|
||||
var fieldQuery = and(isNull("metadata.deletionTimestamp"),
|
||||
equal("spec.subscriber", subscriber.toString()),
|
||||
equal("spec.reason.reasonType", NEW_COMMENT_ON_MOMENT)
|
||||
);
|
||||
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
|
||||
|
||||
return deleteInitialBatch(Subscription.class, listOptions)
|
||||
.map(subscription -> subscription.getSpec().getSubscriber().getName())
|
||||
.distinct()
|
||||
.flatMap(this::createMomentCommentSubscription)
|
||||
.then()
|
||||
.doOnSuccess(unused ->
|
||||
log.debug("Collating moment subscription for user: {} completed", username));
|
||||
}
|
||||
|
||||
Mono<Void> createMomentCommentSubscription(String name) {
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
interestReason.setReasonType(NEW_COMMENT_ON_MOMENT);
|
||||
interestReason.setExpression("props.momentOwner == '%s'".formatted(name));
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(name);
|
||||
log.debug("Create subscription for user: {} with reasonType: {}", name,
|
||||
NEW_COMMENT_ON_MOMENT);
|
||||
return notificationCenter.subscribe(subscriber, interestReason).then();
|
||||
}
|
||||
|
||||
public <E extends Extension> Flux<E> deleteInitialBatch(Class<E> type,
|
||||
ListOptions listOptions) {
|
||||
var pageRequest = createPageRequest();
|
||||
var newFieldQuery = listOptions.getFieldSelector()
|
||||
.andQuery(isNull("metadata.deletionTimestamp"));
|
||||
listOptions.setFieldSelector(newFieldQuery);
|
||||
final Instant now = Instant.now();
|
||||
|
||||
return client.listBy(type, listOptions, pageRequest)
|
||||
// forever loop first page until no more to delete
|
||||
.expand(result -> result.hasNext()
|
||||
? client.listBy(type, listOptions, pageRequest) : Mono.empty())
|
||||
.flatMap(result -> Flux.fromIterable(result.getItems()))
|
||||
.takeWhile(item -> shouldTakeNext(item, now))
|
||||
.flatMap(this::deleteWithRetry);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
<E extends Extension> Mono<E> deleteWithRetry(E item) {
|
||||
return client.delete(item)
|
||||
.onErrorResume(OptimisticLockingFailureException.class,
|
||||
e -> attemptToDelete((Class<E>) item.getClass(), item.getMetadata().getName()));
|
||||
}
|
||||
|
||||
private <E extends Extension> Mono<E> attemptToDelete(Class<E> type, String name) {
|
||||
return Mono.defer(() -> client.fetch(type, name)
|
||||
.flatMap(client::delete)
|
||||
)
|
||||
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
|
||||
.filter(OptimisticLockingFailureException.class::isInstance));
|
||||
}
|
||||
|
||||
static <E extends Extension> boolean shouldTakeNext(E item, Instant now) {
|
||||
var creationTimestamp = item.getMetadata().getCreationTimestamp();
|
||||
return creationTimestamp.isBefore(now)
|
||||
|| creationTimestamp.equals(now);
|
||||
}
|
||||
|
||||
private PageRequest createPageRequest() {
|
||||
return PageRequestImpl.of(1, 200,
|
||||
Sort.by("metadata.creationTimestamp", "metadata.name"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package run.halo.moments;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
|
||||
/**
|
||||
* Compatible with the {@link TagReconciler#TAG_FINALIZER} added in old data to avoid the
|
||||
* problem of data not being able to be deleted due to uncleared
|
||||
* {@link TagReconciler#TAG_FINALIZER}.
|
||||
*/
|
||||
@Deprecated(forRemoval = true, since = "1.5.1")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TagReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private static final String TAG_FINALIZER = "tag-moment-protection";
|
||||
private final ExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
|
||||
if (ExtensionUtil.isDeleted(moment)) {
|
||||
if (ExtensionUtil.removeFinalizers(moment.getMetadata(), Set.of(TAG_FINALIZER))) {
|
||||
client.update(moment);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
final var moment = new Moment();
|
||||
return builder
|
||||
.extension(moment)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package run.halo.moments.event;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
@Getter
|
||||
public class MomentDeletedEvent extends ApplicationEvent {
|
||||
private final String momentName;
|
||||
|
||||
public MomentDeletedEvent(Object source, String momentName) {
|
||||
super(source);
|
||||
this.momentName = momentName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package run.halo.moments.event;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
|
||||
/**
|
||||
* Event for moment has new comment.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 1.1.0
|
||||
*/
|
||||
@Getter
|
||||
public class MomentHasNewCommentEvent extends ApplicationEvent {
|
||||
|
||||
private final Comment comment;
|
||||
|
||||
public MomentHasNewCommentEvent(Object source, Comment comment) {
|
||||
super(source);
|
||||
this.comment = comment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package run.halo.moments.event;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
@Getter
|
||||
public class MomentUpdatedEvent extends ApplicationEvent {
|
||||
private final String momentName;
|
||||
|
||||
public MomentUpdatedEvent(Object source, String momentName) {
|
||||
super(source);
|
||||
this.momentName = momentName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package run.halo.moments.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* NotFoundException.
|
||||
*
|
||||
* @author LIlGG
|
||||
*/
|
||||
public class NotFoundException extends ResponseStatusException {
|
||||
public NotFoundException(String reason) {
|
||||
super(HttpStatus.NOT_FOUND, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package run.halo.moments.finders;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.moments.vo.MomentTagVo;
|
||||
import run.halo.moments.vo.MomentVo;
|
||||
|
||||
|
||||
/**
|
||||
* A finder for {@link run.halo.moments.Moment}.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public interface MomentFinder {
|
||||
|
||||
/**
|
||||
* List all moments.
|
||||
*
|
||||
* @return a flux of moment vo.
|
||||
*/
|
||||
Flux<MomentVo> listAll();
|
||||
|
||||
/**
|
||||
* List moments by page.
|
||||
*
|
||||
* @param page page number.
|
||||
* @param size page size.
|
||||
* @return a mono of list result.
|
||||
*/
|
||||
Mono<ListResult<MomentVo>> list(Integer page, Integer size);
|
||||
|
||||
/**
|
||||
* List moments by tag.
|
||||
*
|
||||
* @param tag tag name.
|
||||
* @return a flux of moment vo.
|
||||
*/
|
||||
Flux<MomentVo> listBy(String tag);
|
||||
|
||||
Mono<MomentVo> get(String momentName);
|
||||
|
||||
Flux<MomentTagVo> listAllTags();
|
||||
|
||||
Mono<ListResult<MomentVo>> listByTag(int pageNum, Integer pageSize, String tagName);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package run.halo.moments.finders.impl;
|
||||
|
||||
import static run.halo.app.extension.index.query.QueryFactory.all;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.and;
|
||||
import static run.halo.app.extension.index.query.QueryFactory.equal;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.Query;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.Stats;
|
||||
import run.halo.moments.finders.MomentFinder;
|
||||
import run.halo.moments.util.MeterUtils;
|
||||
import run.halo.moments.vo.ContributorVo;
|
||||
import run.halo.moments.vo.MomentTagVo;
|
||||
import run.halo.moments.vo.MomentVo;
|
||||
|
||||
/**
|
||||
* A default implementation for {@link MomentFinder}.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Finder("momentFinder")
|
||||
@RequiredArgsConstructor
|
||||
public class MomentFinderImpl implements MomentFinder {
|
||||
|
||||
public static final Predicate<Moment> FIXED_PREDICATE = moment -> Objects.equals(
|
||||
moment.getSpec().getVisible(), Moment.MomentVisible.PUBLIC)
|
||||
&& moment.getSpec().getApproved() == Boolean.TRUE;
|
||||
|
||||
public static final Query FIXED_QUERY = and(
|
||||
equal("spec.visible", Moment.MomentVisible.PUBLIC.name()),
|
||||
equal("spec.approved", Boolean.TRUE.toString())
|
||||
);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Flux<MomentVo> listAll() {
|
||||
var listOptions = new ListOptions();
|
||||
listOptions.setFieldSelector(
|
||||
FieldSelector.of(FIXED_QUERY));
|
||||
return client.listAll(Moment.class, listOptions, defaultSort())
|
||||
.concatMap(this::getMomentVo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ListResult<MomentVo>> list(Integer page, Integer size) {
|
||||
var pageRequest = PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort());
|
||||
return pageMoment(null, pageRequest);
|
||||
}
|
||||
|
||||
static Sort defaultSort() {
|
||||
return Sort.by("spec.releaseTime").descending()
|
||||
.and(ExtensionUtil.defaultSort());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<MomentVo> listBy(String tag) {
|
||||
var listOptions = new ListOptions();
|
||||
var query = and(FIXED_QUERY, equal("spec.tags", tag));
|
||||
listOptions.setFieldSelector(FieldSelector.of(query));
|
||||
return client.listAll(Moment.class, listOptions, defaultSort())
|
||||
.concatMap(this::getMomentVo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MomentVo> get(String momentName) {
|
||||
return client.get(Moment.class, momentName)
|
||||
.filter(FIXED_PREDICATE)
|
||||
.flatMap(this::getMomentVo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<MomentTagVo> listAllTags() {
|
||||
var listOptions = new ListOptions();
|
||||
var query = and(all("spec.tags"), FIXED_QUERY);
|
||||
listOptions.setFieldSelector(FieldSelector.of(query));
|
||||
return client.listAll(Moment.class, listOptions, defaultSort())
|
||||
.flatMapIterable(moment -> {
|
||||
var tags = moment.getSpec().getTags();
|
||||
if (tags == null) {
|
||||
return List.of();
|
||||
}
|
||||
return tags.stream()
|
||||
.map(tag -> new MomentTagPair(tag, moment.getMetadata().getName()))
|
||||
.toList();
|
||||
})
|
||||
.groupBy(MomentTagPair::tagName)
|
||||
.concatMap(groupedFlux -> groupedFlux.count()
|
||||
.defaultIfEmpty(0L)
|
||||
.map(count -> MomentTagVo.builder()
|
||||
.name(groupedFlux.key())
|
||||
.momentCount(count.intValue())
|
||||
.permalink("/moments?tag=" + UriUtils.encode(groupedFlux.key(),
|
||||
StandardCharsets.UTF_8))
|
||||
.build()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
record MomentTagPair(String tagName, String momentName) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ListResult<MomentVo>> listByTag(int pageNum, Integer pageSize, String tagName) {
|
||||
var query = all();
|
||||
if (StringUtils.isNoneBlank(tagName)) {
|
||||
query = and(query, equal("spec.tags", tagName));
|
||||
}
|
||||
var pageRequest =
|
||||
PageRequestImpl.of(pageNullSafe(pageNum), sizeNullSafe(pageSize), defaultSort());
|
||||
return pageMoment(FieldSelector.of(query), pageRequest);
|
||||
}
|
||||
|
||||
private Mono<ListResult<MomentVo>> pageMoment(FieldSelector fieldSelector, PageRequest page) {
|
||||
var listOptions = new ListOptions();
|
||||
var query = FIXED_QUERY;
|
||||
if (fieldSelector != null) {
|
||||
query = and(query, fieldSelector.query());
|
||||
}
|
||||
listOptions.setFieldSelector(FieldSelector.of(query));
|
||||
return client.listBy(Moment.class, listOptions, page)
|
||||
.flatMap(list -> Flux.fromStream(list.get())
|
||||
.concatMap(this::getMomentVo)
|
||||
.collectList()
|
||||
.map(momentVos -> new ListResult<>(list.getPage(), list.getSize(),
|
||||
list.getTotal(), momentVos)
|
||||
)
|
||||
)
|
||||
.defaultIfEmpty(
|
||||
new ListResult<>(page.getPageNumber(), page.getPageSize(), 0L, List.of()));
|
||||
}
|
||||
|
||||
private Mono<MomentVo> getMomentVo(@Nonnull Moment moment) {
|
||||
MomentVo momentVo = MomentVo.from(moment);
|
||||
return Mono.just(momentVo)
|
||||
.flatMap(mv -> populateStats(momentVo)
|
||||
.doOnNext(mv::setStats)
|
||||
.thenReturn(mv)
|
||||
)
|
||||
.flatMap(mv -> {
|
||||
String owner = mv.getSpec().getOwner();
|
||||
return client.fetch(User.class, owner)
|
||||
.map(ContributorVo::from)
|
||||
.doOnNext(mv::setOwner)
|
||||
.thenReturn(mv);
|
||||
})
|
||||
.defaultIfEmpty(momentVo);
|
||||
}
|
||||
|
||||
private Mono<Stats> populateStats(MomentVo momentVo) {
|
||||
String name = momentVo.getMetadata().getName();
|
||||
return client.fetch(Counter.class, MeterUtils.nameOf(Moment.class, name))
|
||||
.map(counter -> Stats.builder()
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build())
|
||||
.defaultIfEmpty(Stats.empty());
|
||||
}
|
||||
|
||||
int pageNullSafe(Integer page) {
|
||||
return ObjectUtils.defaultIfNull(page, 1);
|
||||
}
|
||||
|
||||
int sizeNullSafe(Integer size) {
|
||||
return ObjectUtils.defaultIfNull(size, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package run.halo.moments.rss;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.attachment.ThumbnailSize;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.app.infra.SystemInfoGetter;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import run.halo.feed.RSS2;
|
||||
import run.halo.feed.RssRouteItem;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.finders.MomentFinder;
|
||||
import run.halo.moments.vo.MomentVo;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class MomentRssProvider implements RssRouteItem {
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
private final ExternalLinkProcessor externalLinkProcessor;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
private final MomentFinder momentFinder;
|
||||
private final SystemInfoGetter systemInfoGetter;
|
||||
|
||||
@Override
|
||||
public Mono<String> pathPattern() {
|
||||
return Mono.fromSupplier(() -> "/moments/rss.xml");
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String displayName() {
|
||||
return "瞬间";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "瞬间 RSS";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String example() {
|
||||
return "https://example.com/feed/moments/rss.xml";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<RSS2> handler(ServerRequest request) {
|
||||
return buildRss(request);
|
||||
}
|
||||
|
||||
private Mono<RSS2> buildRss(ServerRequest request) {
|
||||
var externalUrl = externalUrlSupplier.getURL(request.exchange().getRequest());
|
||||
|
||||
var builder = RSS2.builder();
|
||||
var rssMono = systemInfoGetter.get()
|
||||
.flatMap(info -> getMomentPageTitle()
|
||||
.doOnNext(momentTitle -> info.setTitle(
|
||||
String.join(" | ", info.getTitle(), momentTitle))
|
||||
)
|
||||
.thenReturn(info)
|
||||
)
|
||||
.map(basic -> builder
|
||||
.title(basic.getTitle())
|
||||
.image(externalLinkProcessor.processLink(basic.getLogo()))
|
||||
.description(StringUtils.defaultIfBlank(basic.getSubtitle(),
|
||||
basic.getTitle()))
|
||||
.link(externalUrl.toString())
|
||||
)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
|
||||
var rssItemMono = momentFinder.listAll()
|
||||
.map(moment -> {
|
||||
var permalink = getMomentPermalink(moment);
|
||||
var medium = moment.getSpec().getContent().getMedium();
|
||||
var mediumHtml = generateMediaHtmlList(medium);
|
||||
var htmlContent = processHtml(moment.getSpec().getContent().getHtml());
|
||||
return RSS2.Item.builder()
|
||||
.title(buildMomentTitle(moment))
|
||||
.link(externalLinkProcessor.processLink(permalink))
|
||||
.pubDate(moment.getSpec().getReleaseTime())
|
||||
.guid(permalink)
|
||||
.description(htmlContent + mediumHtml)
|
||||
.build();
|
||||
})
|
||||
.collectList()
|
||||
.doOnNext(builder::items)
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
return Mono.when(rssMono, rssItemMono)
|
||||
.then(Mono.fromSupplier(builder::build));
|
||||
}
|
||||
|
||||
private String processHtml(String html) {
|
||||
var document = Jsoup.parse(html);
|
||||
|
||||
// Process all links
|
||||
var links = document.select("a[href]");
|
||||
for (Element link : links) {
|
||||
var isTag = link.hasClass("tag");
|
||||
String href = link.attr("href");
|
||||
if (isTag && href.startsWith("?")) {
|
||||
// 兼容旧版标签链接
|
||||
href = "/moments" + href;
|
||||
}
|
||||
var absoluteUrl = externalLinkProcessor.processLink(href);
|
||||
link.attr("href", absoluteUrl);
|
||||
}
|
||||
// process all images
|
||||
var images = document.select("img[src]");
|
||||
for (Element image : images) {
|
||||
String src = image.attr("src");
|
||||
var thumb = genThumbUrl(src, ThumbnailSize.M);
|
||||
var absoluteUrl = externalLinkProcessor.processLink(thumb);
|
||||
image.attr("src", absoluteUrl);
|
||||
}
|
||||
return document.body().html();
|
||||
}
|
||||
|
||||
private String genThumbUrl(String url, ThumbnailSize size) {
|
||||
return externalLinkProcessor.processLink(
|
||||
"/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=" + url + "&size="
|
||||
+ size.name().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
private String generateMediaHtmlList(List<Moment.MomentMedia> medium) {
|
||||
if (CollectionUtils.isEmpty(medium)) {
|
||||
return "";
|
||||
}
|
||||
return medium.stream()
|
||||
.map(this::generateSingleMediaHtml)
|
||||
.collect(Collectors.joining());
|
||||
}
|
||||
|
||||
private String generateSingleMediaHtml(Moment.MomentMedia media) {
|
||||
var url = media.getUrl();
|
||||
return switch (media.getType()) {
|
||||
case PHOTO -> generatePhotoHtml(media);
|
||||
case VIDEO ->
|
||||
String.format("<video controls><source src=\"%s\" type=\"video/mp4\" /></video>",
|
||||
url);
|
||||
case AUDIO ->
|
||||
String.format("<audio controls><source src=\"%s\" type=\"audio/mpeg\" /></audio>",
|
||||
url);
|
||||
case POST -> String.format("<a href=\"%s\">%s</a>", url, url);
|
||||
};
|
||||
}
|
||||
|
||||
private String generatePhotoHtml(Moment.MomentMedia media) {
|
||||
// the best practice is to use the thumbnail for src
|
||||
var mSrc = genThumbUrl(media.getUrl(), ThumbnailSize.M);
|
||||
// If the reader does not support srcset, then only src,
|
||||
var srcSet = """
|
||||
%s 400w,
|
||||
%s 800w,
|
||||
%s 1200w,
|
||||
""".formatted(
|
||||
genThumbUrl(media.getUrl(), ThumbnailSize.S),
|
||||
mSrc,
|
||||
genThumbUrl(media.getUrl(), ThumbnailSize.L)
|
||||
);
|
||||
return String.format(
|
||||
"<img src=\"%s\"%s alt=\"moment photo\" />",
|
||||
mSrc,
|
||||
srcSet
|
||||
);
|
||||
}
|
||||
|
||||
private static <T> List<T> nullSafeList(List<T> list) {
|
||||
return list == null ? List.of() : list;
|
||||
}
|
||||
|
||||
private static String getMomentPermalink(MomentVo moment) {
|
||||
return "moments/" + moment.getMetadata().getName();
|
||||
}
|
||||
|
||||
private static String buildMomentTitle(MomentVo momentVo) {
|
||||
return momentVo.getOwner().getDisplayName() + " published on "
|
||||
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(momentVo.getSpec().getReleaseTime());
|
||||
}
|
||||
|
||||
Mono<String> getMomentPageTitle() {
|
||||
return this.settingFetcher.get("base")
|
||||
.map(setting -> setting.get("title").asText("瞬间"))
|
||||
.defaultIfEmpty("瞬间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package run.halo.moments.rss;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
|
||||
/**
|
||||
* <p>The detail page address of the moment is also <code>/moments/{name}</code>, so you need to
|
||||
* use a filter for redirection instead of directly using the route.</p>
|
||||
*/
|
||||
public class OldRssRouteRedirectionFilter implements AdditionalWebFilter {
|
||||
private final DefaultServerRedirectStrategy redirectStrategy =
|
||||
new DefaultServerRedirectStrategy();
|
||||
private final ServerWebExchangeMatcher requestMatcher = ServerWebExchangeMatchers.pathMatchers(
|
||||
HttpMethod.GET, "/moments/rss.xml");
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||
return requestMatcher.matches(exchange)
|
||||
.flatMap(matchResult -> {
|
||||
if (matchResult.isMatch()) {
|
||||
redirectStrategy.setHttpStatus(HttpStatus.PERMANENT_REDIRECT);
|
||||
return redirectStrategy
|
||||
.sendRedirect(exchange, URI.create("/feed/moments/rss.xml"));
|
||||
}
|
||||
return chain.filter(exchange);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package run.halo.moments.rss;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalLinkProcessor;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.app.infra.SystemInfoGetter;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
import run.halo.feed.CacheClearRule;
|
||||
import run.halo.feed.RssCacheClearRequested;
|
||||
import run.halo.moments.event.MomentDeletedEvent;
|
||||
import run.halo.moments.event.MomentUpdatedEvent;
|
||||
import run.halo.moments.finders.MomentFinder;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "run.halo.feed.RssRouteItem")
|
||||
@RequiredArgsConstructor
|
||||
public class RssAutoConfiguration {
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
private final ExternalLinkProcessor externalLinkProcessor;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
private final MomentFinder momentFinder;
|
||||
private final SystemInfoGetter systemInfoGetter;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Bean
|
||||
MomentRssProvider momentRssProvider() {
|
||||
return new MomentRssProvider(externalUrlSupplier, externalLinkProcessor, client,
|
||||
settingFetcher, momentFinder, systemInfoGetter);
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener({MomentUpdatedEvent.class, MomentDeletedEvent.class})
|
||||
public void onMomentUpdatedOrDeleted() {
|
||||
var rule = CacheClearRule.forExact("/feed/moments/rss.xml");
|
||||
var event = RssCacheClearRequested.forRule(this, rule);
|
||||
eventPublisher.publishEvent(event);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AdditionalWebFilter oldRssRedirectWebFilter() {
|
||||
return new OldRssRouteRedirectionFilter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package run.halo.moments.search;
|
||||
|
||||
import static run.halo.moments.search.MomentHaloDocumentsProvider.MOMENT_DOCUMENT_TYPE;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.app.search.HaloDocument;
|
||||
import run.halo.moments.Moment;
|
||||
|
||||
/**
|
||||
* @author LIlGG
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DocumentConverter implements Converter<Moment, Mono<HaloDocument>> {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
|
||||
private final DateTimeFormatter dateFormat =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<HaloDocument> convert(Moment moment) {
|
||||
var haloDoc = new HaloDocument();
|
||||
var momentContent = moment.getSpec().getContent();
|
||||
haloDoc.setMetadataName(moment.getMetadata().getName());
|
||||
haloDoc.setType(MOMENT_DOCUMENT_TYPE);
|
||||
haloDoc.setId(haloDocId(moment));
|
||||
haloDoc.setDescription(momentContent.getHtml());
|
||||
haloDoc.setExposed(isExposed(moment));
|
||||
haloDoc.setContent(momentContent.getHtml());
|
||||
var tags = moment.getSpec().getTags();
|
||||
Optional.ofNullable(tags).ifPresent((tag) -> haloDoc.setTags(tag.stream().toList()));
|
||||
haloDoc.setOwnerName(moment.getSpec().getOwner());
|
||||
haloDoc.setUpdateTimestamp(moment.getSpec().getReleaseTime());
|
||||
haloDoc.setCreationTimestamp(moment.getMetadata().getCreationTimestamp());
|
||||
haloDoc.setPermalink(getPermalink(moment));
|
||||
haloDoc.setPublished(true);
|
||||
|
||||
return Mono.when(getTitle(moment).doOnNext(haloDoc::setTitle))
|
||||
.then(Mono.fromSupplier(() -> haloDoc));
|
||||
}
|
||||
|
||||
String haloDocId(Moment moment) {
|
||||
return MOMENT_DOCUMENT_TYPE + '-' + moment.getMetadata().getName();
|
||||
}
|
||||
|
||||
private Mono<String> getTitle(Moment moment) {
|
||||
return client.fetch(User.class, moment.getSpec().getOwner())
|
||||
.map(user -> user.getSpec().getDisplayName())
|
||||
.map(displayName -> {
|
||||
ZonedDateTime zonedDateTime =
|
||||
moment.getSpec().getReleaseTime().atZone(ZoneId.systemDefault());
|
||||
return "发表于:" + dateFormat.format(zonedDateTime)
|
||||
+ " by " + displayName;
|
||||
});
|
||||
}
|
||||
|
||||
private String getPermalink(Moment moment) {
|
||||
var externalUrl = externalUrlSupplier.get();
|
||||
return externalUrl.resolve("moments/" + moment.getMetadata().getName()).toString();
|
||||
}
|
||||
|
||||
private static boolean isExposed(Moment moment) {
|
||||
var visible = moment.getSpec().getVisible();
|
||||
return Moment.MomentVisible.PUBLIC.equals(visible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package run.halo.moments.search;
|
||||
|
||||
import static run.halo.moments.ModelConst.SEARCH_DEFAULT_PAGE_SIZE;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.PageRequest;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.app.extension.router.selector.FieldSelector;
|
||||
import run.halo.app.search.HaloDocument;
|
||||
import run.halo.app.search.HaloDocumentsProvider;
|
||||
import run.halo.moments.Moment;
|
||||
|
||||
/**
|
||||
* @author LIlGG
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentHaloDocumentsProvider implements HaloDocumentsProvider {
|
||||
|
||||
public static final String MOMENT_DOCUMENT_TYPE = "moment.moment.halo.run";
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final DocumentConverter converter;
|
||||
|
||||
@Override
|
||||
public Flux<HaloDocument> fetchAll() {
|
||||
var options = new ListOptions();
|
||||
var notDeleted = QueryFactory.isNull("metadata.deletionTimestamp");
|
||||
var approved = QueryFactory.equal("spec.approved", "true");
|
||||
options.setFieldSelector(FieldSelector.of(notDeleted).andQuery(approved));
|
||||
var pageRequest = createPageRequest();
|
||||
// make sure the moments are approved and not deleted.
|
||||
return client.listBy(Moment.class, options, pageRequest)
|
||||
.map(ListResult::getItems)
|
||||
.flatMapMany(Flux::fromIterable)
|
||||
.flatMap(converter::convert);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType() {
|
||||
return MOMENT_DOCUMENT_TYPE;
|
||||
}
|
||||
|
||||
private PageRequest createPageRequest() {
|
||||
return PageRequestImpl.of(1, SEARCH_DEFAULT_PAGE_SIZE,
|
||||
Sort.by("metadata.creationTimestamp", "metadata.name"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package run.halo.moments.search;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.ExtensionUtil;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.search.event.HaloDocumentAddRequestEvent;
|
||||
import run.halo.app.search.event.HaloDocumentDeleteRequestEvent;
|
||||
import run.halo.moments.Moment;
|
||||
|
||||
/**
|
||||
* @author LIlGG
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentSearchReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private static final String FINALIZER = "moment-search-protection";
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
private final DocumentConverter converter;
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
|
||||
if (ExtensionUtil.isDeleted(moment)) {
|
||||
if (ExtensionUtil.removeFinalizers(moment.getMetadata(), Set.of(FINALIZER))) {
|
||||
eventPublisher.publishEvent(
|
||||
new HaloDocumentDeleteRequestEvent(this,
|
||||
List.of(converter.haloDocId(moment)))
|
||||
);
|
||||
client.update(moment);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ExtensionUtil.addFinalizers(moment.getMetadata(), Set.of(FINALIZER));
|
||||
|
||||
var haloDoc = converter.convert(moment)
|
||||
.blockOptional().orElseThrow();
|
||||
eventPublisher.publishEvent(
|
||||
new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
|
||||
|
||||
client.update(moment);
|
||||
});
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Moment())
|
||||
.workerCount(1)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package run.halo.moments.service;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.moments.ListedMoment;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.MomentQuery;
|
||||
|
||||
/**
|
||||
* Service for {@link run.halo.moments.Moment}.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public interface MomentService {
|
||||
Mono<ListResult<ListedMoment>> listMoment(MomentQuery query);
|
||||
|
||||
Mono<Moment> create(Moment moment);
|
||||
|
||||
Flux<String> listAllTags(MomentQuery query);
|
||||
|
||||
Mono<ListedMoment> findMomentByName(String name);
|
||||
|
||||
Mono<Moment> getByUsername(String momentName, String username);
|
||||
|
||||
Mono<Moment> updateBy(Moment moment);
|
||||
|
||||
Mono<Moment> deleteBy(Moment moment);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package run.halo.moments.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface RoleService {
|
||||
/**
|
||||
* verify whether the source role contains any role in the candidates.
|
||||
*
|
||||
* @param source the role to be verified
|
||||
* @param candidates the roles to be verified
|
||||
* @return <p>true if the source role contains any role in the candidates, otherwise false</p>
|
||||
*/
|
||||
Mono<Boolean> joint(Collection<String> source, Collection<String> candidates);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package run.halo.moments.service.impl;
|
||||
|
||||
import static run.halo.app.extension.Comparators.compareCreationTimestamp;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.moments.service.RoleService;
|
||||
import run.halo.moments.util.AuthorityUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultRoleService implements RoleService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> joint(Collection<String> source, Collection<String> candidates) {
|
||||
if (source.contains(AuthorityUtils.SUPER_ROLE_NAME)) {
|
||||
return Mono.just(true);
|
||||
}
|
||||
return listDependencies(new HashSet<>(source))
|
||||
.map(role -> role.getMetadata().getName())
|
||||
.collect(Collectors.toSet())
|
||||
.map(roleNames -> !Collections.disjoint(roleNames, candidates));
|
||||
}
|
||||
|
||||
private Flux<Role> listDependencies(Set<String> names) {
|
||||
var visited = new HashSet<String>();
|
||||
return listRoles(names)
|
||||
.expand(role -> {
|
||||
var name = role.getMetadata().getName();
|
||||
if (visited.contains(name)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace("Expand role: {}", role.getMetadata().getName());
|
||||
}
|
||||
visited.add(name);
|
||||
var annotations = MetadataUtil.nullSafeAnnotations(role);
|
||||
var dependenciesJson = annotations.get(Role.ROLE_DEPENDENCIES_ANNO);
|
||||
var dependencies = stringToList(dependenciesJson);
|
||||
|
||||
return Flux.fromIterable(dependencies)
|
||||
.filter(dep -> !visited.contains(dep))
|
||||
.collect(Collectors.<String>toSet())
|
||||
.flatMapMany(this::listRoles);
|
||||
})
|
||||
.concatWith(Flux.defer(() -> listAggregatedRoles(visited)));
|
||||
}
|
||||
|
||||
private Flux<Role> listAggregatedRoles(Set<String> roleNames) {
|
||||
var aggregatedLabelNames = roleNames.stream()
|
||||
.map(roleName -> Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName)
|
||||
.collect(Collectors.toSet());
|
||||
Predicate<Role> predicate = role -> {
|
||||
var labels = role.getMetadata().getLabels();
|
||||
if (labels == null) {
|
||||
return false;
|
||||
}
|
||||
return aggregatedLabelNames.stream()
|
||||
.anyMatch(aggregatedLabel -> Boolean.parseBoolean(labels.get(aggregatedLabel)));
|
||||
};
|
||||
return client.list(Role.class, predicate, compareCreationTimestamp(true));
|
||||
}
|
||||
|
||||
private Flux<Role> listRoles(Set<String> names) {
|
||||
if (CollectionUtils.isEmpty(names)) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
Predicate<Role> predicate = role -> names.contains(role.getMetadata().getName());
|
||||
return client.list(Role.class, predicate, compareCreationTimestamp(true));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private List<String> stringToList(String str) {
|
||||
if (StringUtils.isBlank(str)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return JsonUtils.jsonToObject(str,
|
||||
new TypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package run.halo.moments.service.impl;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Counter;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.moments.Contributor;
|
||||
import run.halo.moments.ListedMoment;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.MomentQuery;
|
||||
import run.halo.moments.Stats;
|
||||
import run.halo.moments.exception.NotFoundException;
|
||||
import run.halo.moments.service.MomentService;
|
||||
import run.halo.moments.util.MeterUtils;
|
||||
|
||||
/**
|
||||
* Listed moment.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @author guqing
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MomentServiceImpl implements MomentService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
@Override
|
||||
public Mono<ListResult<ListedMoment>> listMoment(MomentQuery query) {
|
||||
return client.listBy(Moment.class, query.toListOptions(), query.toPageRequest())
|
||||
.flatMap(listResult -> Flux.fromStream(listResult.get())
|
||||
.concatMap(this::toListedMoment)
|
||||
.collectList()
|
||||
.map(list -> new ListResult<>(listResult.getPage(), listResult.getSize(),
|
||||
listResult.getTotal(), list)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Moment> create(Moment moment) {
|
||||
if (Objects.isNull(moment.getSpec().getReleaseTime())) {
|
||||
moment.getSpec().setReleaseTime(Instant.now());
|
||||
}
|
||||
|
||||
if (Objects.isNull(moment.getSpec().getVisible())) {
|
||||
moment.getSpec().setVisible(Moment.MomentVisible.PUBLIC);
|
||||
}
|
||||
|
||||
return getContextUser()
|
||||
.flatMap(user -> {
|
||||
moment.getSpec().setOwner(user.getMetadata().getName());
|
||||
return client.create(moment);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<String> listAllTags(MomentQuery query) {
|
||||
return client.listAll(Moment.class, query.toListOptions(),
|
||||
Sort.by("metadata.name").descending())
|
||||
.flatMapIterable(moment -> {
|
||||
var tags = moment.getSpec().getTags();
|
||||
return Objects.requireNonNullElseGet(tags, List::of);
|
||||
})
|
||||
.distinct();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ListedMoment> findMomentByName(String name) {
|
||||
return client.fetch(Moment.class, name)
|
||||
.switchIfEmpty(Mono.error(new NotFoundException("Moment not found.")))
|
||||
.flatMap(this::toListedMoment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Moment> getByUsername(String momentName, String username) {
|
||||
return client.get(Moment.class, momentName)
|
||||
.filter(post -> post.getSpec() != null)
|
||||
.filter(post -> Objects.equals(username, post.getSpec().getOwner()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Moment> deleteBy(Moment moment) {
|
||||
return client.delete(moment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Moment> updateBy(Moment moment) {
|
||||
return client.update(moment);
|
||||
}
|
||||
|
||||
private Mono<ListedMoment> toListedMoment(Moment moment) {
|
||||
ListedMoment.ListedMomentBuilder momentBuilder = ListedMoment.builder()
|
||||
.moment(moment);
|
||||
return Mono.just(momentBuilder)
|
||||
.map(ListedMoment.ListedMomentBuilder::build)
|
||||
.flatMap(lm -> fetchStats(moment)
|
||||
.doOnNext(lm::setStats)
|
||||
.thenReturn(lm))
|
||||
.flatMap(lm -> setOwner(moment.getSpec().getOwner(), lm));
|
||||
}
|
||||
|
||||
private Mono<ListedMoment> setOwner(String owner, ListedMoment moment) {
|
||||
return client.fetch(User.class, owner)
|
||||
.map(user -> {
|
||||
Contributor contributor = new Contributor();
|
||||
contributor.setName(user.getMetadata().getName());
|
||||
contributor.setDisplayName(user.getSpec().getDisplayName());
|
||||
contributor.setAvatar(user.getSpec().getAvatar());
|
||||
return contributor;
|
||||
})
|
||||
.doOnNext(moment::setOwner)
|
||||
.thenReturn(moment);
|
||||
}
|
||||
|
||||
private Mono<Stats> fetchStats(Moment moment) {
|
||||
Assert.notNull(moment, "The moment must not be null.");
|
||||
String name = moment.getMetadata().getName();
|
||||
return client.fetch(Counter.class, MeterUtils.nameOf(Moment.class, name))
|
||||
.map(counter -> Stats.builder()
|
||||
.upvote(counter.getUpvote())
|
||||
.totalComment(counter.getTotalComment())
|
||||
.approvedComment(counter.getApprovedComment())
|
||||
.build())
|
||||
.defaultIfEmpty(Stats.empty());
|
||||
|
||||
}
|
||||
|
||||
protected Mono<User> getContextUser() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.flatMap(ctx -> {
|
||||
var name = ctx.getAuthentication().getName();
|
||||
return client.fetch(User.class, name);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package run.halo.moments.uc;
|
||||
|
||||
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 java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
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.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import lombok.AllArgsConstructor;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.moments.ListedMoment;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.MomentQuery;
|
||||
import run.halo.moments.service.MomentService;
|
||||
import run.halo.moments.service.RoleService;
|
||||
import run.halo.moments.util.AuthorityUtils;
|
||||
|
||||
/**
|
||||
* A custom endpoint for {@link run.halo.moments.Moment}.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class UcMomentEndpoint implements CustomEndpoint {
|
||||
|
||||
private final MomentService momentService;
|
||||
|
||||
private final RoleService roleService;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = groupVersion() + "/moment";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("moments", this::listMyMoment, builder -> {
|
||||
builder.operationId("ListMyMoments")
|
||||
.description("List My moments.")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListResult.generateGenericClass(ListedMoment.class))
|
||||
);
|
||||
MomentQuery.buildParameters(builder);
|
||||
})
|
||||
.GET("moments/{name}", this::getMyMoment,
|
||||
builder -> builder.operationId("GetMyMoment")
|
||||
.description("Get a My Moment.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementation(Moment.class))
|
||||
)
|
||||
.POST("moments", this::createMyMoment,
|
||||
builder -> builder.operationId("CreateMyMoment")
|
||||
.description("Create a My Moment.")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(Moment.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Moment.class))
|
||||
)
|
||||
.PUT("moments/{name}", this::updateMyMoment,
|
||||
builder -> builder.operationId("UpdateMyMoment")
|
||||
.description("Update a My Moment.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.APPLICATION_JSON_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(Moment.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Moment.class))
|
||||
)
|
||||
.DELETE("moments/{name}", this::deleteMyMoment,
|
||||
builder -> builder.operationId("DeleteMyMoment")
|
||||
.description("Delete a My Moment.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementation(Moment.class))
|
||||
)
|
||||
.GET("tags", this::listMyTags,
|
||||
builder -> builder.operationId("ListTags")
|
||||
.description("List all moment tags.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.QUERY)
|
||||
.description("Tag name to query")
|
||||
.required(false)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementationArray(String.class)
|
||||
))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteMyMoment(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return getMyMoment(name)
|
||||
.flatMap(momentService::deleteBy)
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> updateMyMoment(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return getMyMoment(name)
|
||||
.flatMap(oldMoment -> {
|
||||
Moment.MomentSpec oldSpec = oldMoment.getSpec();
|
||||
|
||||
return request.bodyToMono(Moment.class)
|
||||
.doOnNext(newMoment -> {
|
||||
Moment.MomentSpec newSpec = newMoment.getSpec();
|
||||
newSpec.setOwner(oldSpec.getOwner());
|
||||
newSpec.setReleaseTime(oldSpec.getReleaseTime());
|
||||
// Every update needs to be re-reviewed.
|
||||
newSpec.setApproved(false);
|
||||
})
|
||||
.flatMap(momentService::updateBy);
|
||||
})
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getMyMoment(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return getMyMoment(name)
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
private Mono<Moment> getMyMoment(String momentName) {
|
||||
return getCurrentUser()
|
||||
.flatMap(user -> momentService.getByUsername(momentName, user.getName())
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"The moment was not found or deleted"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createMyMoment(ServerRequest request) {
|
||||
return getCurrentUser()
|
||||
.flatMap(user -> request.bodyToMono(Moment.class)
|
||||
.flatMap(post -> {
|
||||
post.getSpec().setApproved(false);
|
||||
post.getSpec().setOwner(user.getName());
|
||||
var roles = AuthorityUtils.authoritiesToRoles(user.getAuthorities());
|
||||
return roleService.joint(roles,
|
||||
Set.of(AuthorityUtils.MOMENT_PUBLISH_APPROVAL_ROLE_NAME,
|
||||
AuthorityUtils.SUPER_ROLE_NAME))
|
||||
.doOnNext(result -> {
|
||||
if (result) {
|
||||
// If it is a user with audit authority, there is no need to review.
|
||||
post.getSpec().setApproved(true);
|
||||
post.getSpec().setApprovedTime(Instant.now());
|
||||
}
|
||||
})
|
||||
.thenReturn(post);
|
||||
})
|
||||
)
|
||||
.flatMap(momentService::create)
|
||||
.flatMap(moment -> ServerResponse.ok().bodyValue(moment));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listMyMoment(ServerRequest request) {
|
||||
return getCurrentUser()
|
||||
.map(user -> new MomentQuery(request.exchange(), user.getName()))
|
||||
.flatMap(momentService::listMoment)
|
||||
.flatMap(listedMoments -> ServerResponse.ok().bodyValue(listedMoments));
|
||||
}
|
||||
|
||||
private Mono<Authentication> getCurrentUser() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listMyTags(ServerRequest request) {
|
||||
String name = request.queryParam("name").orElse(null);
|
||||
return getCurrentUser()
|
||||
.map(user -> new MomentQuery(request.exchange(), user.getName()))
|
||||
.flatMapMany(momentService::listAllTags)
|
||||
.filter(tagName -> StringUtils.isBlank(name) || StringUtils.containsIgnoreCase(tagName,
|
||||
name))
|
||||
.collectList()
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("uc.api.moment.halo.run/v1alpha1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package run.halo.moments.util;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
/**
|
||||
* Utility methods for manipulating GrantedAuthority collection.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public enum AuthorityUtils {
|
||||
;
|
||||
public static final String ROLE_PREFIX = "ROLE_";
|
||||
public static final String SUPER_ROLE_NAME = "super-role";
|
||||
|
||||
public static final String MOMENT_PUBLISH_APPROVAL_ROLE_NAME =
|
||||
"role-template-uc-moments-approved";
|
||||
|
||||
public static final String MOMENT_MANAGEMENT_ROLE_NAME = "role-template-moments-manage";
|
||||
|
||||
/**
|
||||
* Converts an array of GrantedAuthority objects to a role set.
|
||||
*
|
||||
* @return a Set of the Strings obtained from each call to
|
||||
* GrantedAuthority.getAuthority() and filtered by prefix "ROLE_".
|
||||
*/
|
||||
public static Set<String> authoritiesToRoles(
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
return authorities.stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX))
|
||||
.map(authority -> StringUtils.removeStart(authority, ROLE_PREFIX))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public static boolean containsSuperRole(Collection<String> roles) {
|
||||
return roles.contains(SUPER_ROLE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package run.halo.moments.util;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* Meter utils.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class MeterUtils {
|
||||
|
||||
public static final Tag METRICS_COMMON_TAG = Tag.of("metrics.halo.run", "true");
|
||||
|
||||
/**
|
||||
* Build a counter name.
|
||||
*
|
||||
* @param group extension group
|
||||
* @param plural extension plural
|
||||
* @param name extension name
|
||||
* @return counter name
|
||||
*/
|
||||
public static String nameOf(String group, String plural, String name) {
|
||||
if (StringUtils.isBlank(group)) {
|
||||
return String.join("/", plural, name);
|
||||
}
|
||||
return String.join(".", plural, group) + "/" + name;
|
||||
}
|
||||
|
||||
public static <T extends AbstractExtension> String nameOf(Class<T> clazz, String name) {
|
||||
GVK annotation = clazz.getAnnotation(GVK.class);
|
||||
return nameOf(annotation.group(), annotation.plural(), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link Counter} for halo extension.
|
||||
*
|
||||
* @param registry meter registry
|
||||
* @param name counter name,build by {@link #nameOf(String, String, String)}
|
||||
* @return counter find by name from registry if exists, otherwise create a new one.
|
||||
*/
|
||||
private static Counter counter(MeterRegistry registry, String name, Tag... tags) {
|
||||
Tags withTags = Tags.of(METRICS_COMMON_TAG).and(tags);
|
||||
Counter counter = registry.find(name)
|
||||
.tags(withTags)
|
||||
.counter();
|
||||
if (counter == null) {
|
||||
return registry.counter(name, withTags);
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package run.halo.moments.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import run.halo.app.core.extension.User;
|
||||
|
||||
/**
|
||||
* Listed comment.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
public class ContributorVo {
|
||||
private String name;
|
||||
|
||||
private String avatar;
|
||||
|
||||
private String bio;
|
||||
|
||||
private String displayName;
|
||||
|
||||
public static ContributorVo from(User user) {
|
||||
return builder().name(user.getMetadata().getName())
|
||||
.displayName(user.getSpec().getDisplayName())
|
||||
.avatar(user.getSpec().getAvatar())
|
||||
.bio(user.getSpec().getBio())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package run.halo.moments.vo;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
public class MomentTagVo {
|
||||
|
||||
String name;
|
||||
|
||||
String permalink;
|
||||
|
||||
Integer momentCount;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package run.halo.moments.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.extension.MetadataOperator;
|
||||
import run.halo.moments.Moment;
|
||||
import run.halo.moments.Stats;
|
||||
|
||||
/**
|
||||
* Listed moment.
|
||||
*
|
||||
* @author LIlGG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
public class MomentVo {
|
||||
|
||||
private MetadataOperator metadata;
|
||||
|
||||
private Moment.MomentSpec spec;
|
||||
|
||||
private ContributorVo owner;
|
||||
|
||||
private Stats stats;
|
||||
|
||||
public static MomentVo from(Moment moment) {
|
||||
Assert.notNull(moment, "The moment must not be null.");
|
||||
return MomentVo.builder()
|
||||
.metadata(moment.getMetadata())
|
||||
.spec(moment.getSpec())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: ExtensionDefinition
|
||||
metadata:
|
||||
name: moment-rss-provider
|
||||
spec:
|
||||
className: run.halo.moments.rss.MomentRssProvider
|
||||
extensionPointName: feed-rss-route-item
|
||||
displayName: "瞬间订阅"
|
||||
description: "用于生成瞬间的 RSS 订阅源"
|
||||
---
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: ExtensionDefinition
|
||||
metadata:
|
||||
name: moment-old-rss-redirection-filter
|
||||
spec:
|
||||
className: run.halo.moments.rss.OldRssRouteRedirectionFilter
|
||||
extensionPointName: additional-webfilter
|
||||
displayName: "瞬间旧 RSS 重定向"
|
||||
description: "用于重定向旧的 RSS 订阅源到新的订阅路径"
|
||||
@@ -0,0 +1,68 @@
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: ReasonType
|
||||
metadata:
|
||||
name: new-comment-on-moment
|
||||
spec:
|
||||
displayName: "我发布的瞬间收到新评论"
|
||||
description: "当你发布的瞬间收到新评论时,你将会收到一条通知,告诉你有新的评论。"
|
||||
properties:
|
||||
- name: momentName
|
||||
type: string
|
||||
description: "The name of the moment."
|
||||
- name: momentCreatedAt
|
||||
type: string
|
||||
- name: momentRawContent
|
||||
type: string
|
||||
- name: momentHtmlContent
|
||||
type: string
|
||||
- name: momentUrl
|
||||
type: string
|
||||
- name: commenter
|
||||
type: string
|
||||
description: "The display name of the commenter."
|
||||
- name: commentName
|
||||
type: string
|
||||
description: "The name of the comment."
|
||||
- name: content
|
||||
type: string
|
||||
description: "The content of the comment."
|
||||
---
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: NotificationTemplate
|
||||
metadata:
|
||||
name: template-new-comment-on-moment
|
||||
spec:
|
||||
reasonSelector:
|
||||
reasonType: new-comment-on-moment
|
||||
language: default
|
||||
template:
|
||||
title: "[(${commenter})] 评论了你在 [(${momentCreatedAt})] 时刻发布的瞬间"
|
||||
rawBody: |
|
||||
[(${subscriber.displayName})] 你好:
|
||||
|
||||
[(${commenter})] 评论了你的瞬间“[(${momentRawContent})]”, 以下是评论的具体内容:
|
||||
|
||||
[(${content})]
|
||||
htmlBody: |
|
||||
<div class="notification-content">
|
||||
<div class="head">
|
||||
<p class="honorific" th:text="|${subscriber.displayName} 你好:|"></p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div>
|
||||
<div th:text="|${commenter} 评论了你的瞬间|"></div>
|
||||
<div>
|
||||
<a
|
||||
th:href="${momentUrl}"
|
||||
target="_blank"
|
||||
style="display: block;font-style: italic;"
|
||||
th:utext="${momentHtmlContent}"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div>以下是评论的具体内容:</div>
|
||||
</div>
|
||||
<div class="content" th:utext="${content}"></div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-moments-view
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "瞬间"
|
||||
rbac.authorization.halo.run/display-name: "瞬间查看"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:moments:view"]
|
||||
rules:
|
||||
- apiGroups: ["moment.halo.run"]
|
||||
resources: ["moments"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["console.api.moment.halo.run"]
|
||||
resources: ["moments", "tags"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-moments-manage
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "瞬间"
|
||||
rbac.authorization.halo.run/display-name: "瞬间管理"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:moments:manage"]
|
||||
rbac.authorization.halo.run/dependencies: |
|
||||
["role-template-moments-view"]
|
||||
rules:
|
||||
- apiGroups: ["moment.halo.run"]
|
||||
resources: ["moments"]
|
||||
verbs: ["create", "patch", "update", "delete", "deletecollection"]
|
||||
- apiGroups: ["console.api.moment.halo.run"]
|
||||
resources: ["moments", "tags"]
|
||||
verbs: ["create", "patch", "update", "delete", "deletecollection"]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-uc-moments-publish
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "瞬间"
|
||||
rbac.authorization.halo.run/display-name: "允许发布自己的瞬间"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["uc:plugin:moments:publish"]
|
||||
rules:
|
||||
- apiGroups: ["uc.api.moment.halo.run"]
|
||||
resources: ["moments", "tags"]
|
||||
verbs: ["get", "list", "create", "update"]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-uc-moments-approved
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "瞬间"
|
||||
rbac.authorization.halo.run/display-name: "发布瞬间无需审核"
|
||||
rbac.authorization.halo.run/dependencies: |
|
||||
["role-template-uc-moments-publish"]
|
||||
rules:
|
||||
- nonResourceURLs: ["*"]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-uc-moments-delete
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "瞬间"
|
||||
rbac.authorization.halo.run/display-name: "允许删除自己的瞬间"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["uc:plugin:moments:delete"]
|
||||
rules:
|
||||
- apiGroups: ["uc.api.moment.halo.run"]
|
||||
resources: ["moments", "tags"]
|
||||
verbs: ["delete", "deletecollection"]
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: plugin-moments-settings
|
||||
spec:
|
||||
forms:
|
||||
- group: base
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
label: 页面标题
|
||||
name: title
|
||||
validation: required
|
||||
value: '瞬间'
|
||||
- $formkit: text
|
||||
label: 瞬间列表显示条数
|
||||
name: pageSize
|
||||
validation: required|Number
|
||||
value: 10
|
||||
@@ -0,0 +1,2 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class=""><rect id="r4" width="512" height="512" x="0" y="0" rx="" fill="url(#r5)" stroke="#FFFFFF" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><clipPath id="clip"><use xlink:href="#r4"></use></clipPath><defs><linearGradient id="r5" gradientUnits="userSpaceOnUse" gradientTransform="rotate(45)" style="transform-origin: center center;"><stop stop-color="#FF7DB4"></stop><stop offset="1" stop-color="#654EA3"></stop></linearGradient><radialGradient id="r6" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256) rotate(90) scale(512)"><stop stop-color="white"></stop><stop offset="1" stop-color="white" stop-opacity="0"></stop></radialGradient></defs><svg viewBox="0 0 24 24" width="352" height="352" x="80" y="80" alignment-baseline="middle" style="color: rgb(255, 255, 255);"><g fill="none" fill-rule="evenodd"><path d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z"></path><path fill="currentColor" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2Zm-2 13.464v4.284A8 8 0 0 0 12 20c1.458 0 2.824-.39 4.001-1.071l-6-3.465ZM8 12l-3.71 2.142a8.016 8.016 0 0 0 3.457 4.635L8 18.93V12Zm12 0l-6 3.464l3.708 2.141a7.973 7.973 0 0 0 2.286-5.295L20 12Zm-8.01-2.316l-2 1.168l.01 2.315l2.01 1.149l2-1.168l-.01-2.315l-2.01-1.149ZM6.291 6.395A7.974 7.974 0 0 0 4 12l6-3.464l-3.708-2.141ZM16 5.07V12l3.71-2.142A8.018 8.018 0 0 0 16 5.07ZM12 4c-1.458 0-2.824.39-4.001 1.071L14 8.536V4.252A8.015 8.015 0 0 0 12 4Z"></path></g></svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,25 @@
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: PluginMoments
|
||||
annotations:
|
||||
"store.halo.run/app-id": "app-SnwWD"
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.21.0"
|
||||
author:
|
||||
name: Halo
|
||||
website: https://github.com/halo-dev
|
||||
logo: logo.svg
|
||||
configMapName: plugin-moments-configmap
|
||||
settingName: plugin-moments-settings
|
||||
homepage: https://www.halo.run/store/apps/app-SnwWD
|
||||
repo: https://github.com/halo-sigs/plugin-moments
|
||||
issues: https://github.com/halo-sigs/plugin-moments/issues
|
||||
displayName: "瞬间"
|
||||
description: "Halo 2.0 的瞬间管理插件,提供一个轻量级的内容发布功能,支持发布图文、视频、音频等内容。"
|
||||
pluginDependencies:
|
||||
"PluginFeed?": ">=1.4.0"
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
url: "https://github.com/halo-sigs/plugin-moments/blob/main/LICENSE"
|
||||
Reference in New Issue
Block a user