1.13.1原版

This commit is contained in:
2026-07-19 01:16:28 +08:00
commit c6ba83f5d7
133 changed files with 19136 additions and 0 deletions
@@ -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;
}
+126
View File
@@ -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);
}
}
+29
View File
@@ -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
+2
View File
@@ -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

+25
View File
@@ -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"