diff --git a/README.md b/README.md index c2041ffc6f..3b18c4e88a 100755 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## 快速开始 ```bash -docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.10 +docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.11 ``` 以上仅作为体验使用,详细部署文档请查阅: diff --git a/api/src/main/java/run/halo/app/extension/JsonExtension.java b/api/src/main/java/run/halo/app/extension/JsonExtension.java index 67ef64fc89..2bcfec3cd7 100644 --- a/api/src/main/java/run/halo/app/extension/JsonExtension.java +++ b/api/src/main/java/run/halo/app/extension/JsonExtension.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.time.Instant; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -117,6 +118,23 @@ public MetadataOperator getMetadataOrCreate() { return new ObjectNodeMetadata(metadataNode); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JsonExtension that = (JsonExtension) o; + return Objects.equals(objectNode, that.objectNode); + } + + @Override + public int hashCode() { + return Objects.hash(objectNode); + } + class ObjectNodeMetadata implements MetadataOperator { private final ObjectNode objectNode; diff --git a/api/src/main/java/run/halo/app/extension/router/selector/Operator.java b/api/src/main/java/run/halo/app/extension/router/selector/Operator.java index 04f7480949..cab3dc1275 100644 --- a/api/src/main/java/run/halo/app/extension/router/selector/Operator.java +++ b/api/src/main/java/run/halo/app/extension/router/selector/Operator.java @@ -12,7 +12,7 @@ public enum Operator implements Converter { public SelectorCriteria convert(@Nullable String selector) { if (preFlightCheck(selector, 3)) { var i = selector.indexOf(getOperator()); - if (i > 0 && (i + getOperator().length()) < selector.length() - 1) { + if (i > 0 && (i + getOperator().length()) <= selector.length() - 1) { String key = selector.substring(0, i); String value = selector.substring(i + getOperator().length()); return new SelectorCriteria(key, this, Set.of(value)); diff --git a/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java b/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java index ee349b4d19..ae426277c7 100644 --- a/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java +++ b/api/src/test/java/run/halo/app/extension/router/selector/OperatorTest.java @@ -27,6 +27,8 @@ record TestCase(String source, Operator converter, SelectorCriteria expected) { new TestCase("name=", Equals, null), new TestCase("name=value", Equals, new SelectorCriteria("name", Equals, Set.of("value"))), + new TestCase("name=v", Equals, + new SelectorCriteria("name", Equals, Set.of("v"))), new TestCase("", NotEquals, null), new TestCase("=", NotEquals, null), @@ -59,4 +61,4 @@ record TestCase(String source, Operator converter, SelectorCriteria expected) { assertEquals(testCase.expected(), testCase.converter().convert(testCase.source())); }); } -} \ No newline at end of file +} diff --git a/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 5eb0c2e9e1..b9133cd9c0 100644 --- a/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -5,8 +5,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; -import run.halo.app.extension.DefaultSchemeManager; -import run.halo.app.extension.DefaultSchemeWatcherManager; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.SchemeManager; @@ -19,18 +17,8 @@ public class ExtensionConfiguration { @Bean RouterFunction extensionsRouterFunction(ReactiveExtensionClient client, - SchemeWatcherManager watcherManager) { - return new ExtensionCompositeRouterFunction(client, watcherManager); - } - - @Bean - SchemeManager schemeManager(SchemeWatcherManager watcherManager) { - return new DefaultSchemeManager(watcherManager); - } - - @Bean - SchemeWatcherManager schemeWatcherManager() { - return new DefaultSchemeWatcherManager(); + SchemeWatcherManager watcherManager, SchemeManager schemeManager) { + return new ExtensionCompositeRouterFunction(client, watcherManager, schemeManager); } @Configuration(proxyBeanMethods = false) diff --git a/application/src/main/java/run/halo/app/content/PostIndexInformer.java b/application/src/main/java/run/halo/app/content/PostIndexInformer.java index 7d4f1f87f3..0ede05bdfb 100644 --- a/application/src/main/java/run/halo/app/content/PostIndexInformer.java +++ b/application/src/main/java/run/halo/app/content/PostIndexInformer.java @@ -7,6 +7,7 @@ import java.util.concurrent.locks.StampedLock; import java.util.function.BiConsumer; import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; @@ -19,7 +20,6 @@ import run.halo.app.extension.Unstructured; import run.halo.app.extension.Watcher; import run.halo.app.extension.controller.RequestSynchronizer; -import run.halo.app.infra.SchemeInitializedEvent; /** *

Monitor changes to {@link Post} resources and establish a local, in-memory cache in an @@ -33,7 +33,7 @@ * @since 2.0.0 */ @Component -public class PostIndexInformer implements ApplicationListener, +public class PostIndexInformer implements ApplicationListener, DisposableBean { public static final String TAG_POST_INDEXER = "tag-post-indexer"; public static final String LABEL_INDEXER_NAME = "post-label-indexer"; @@ -71,10 +71,6 @@ private DefaultIndexer.IndexFunc labelIndexFunc() { }; } - public Set getByIndex(String indexName, String indexKey) { - return postIndexer.getByIndex(indexName, indexKey); - } - public Set getByTagName(String tagName) { return postIndexer.getByIndex(TAG_POST_INDEXER, tagName); } @@ -104,10 +100,6 @@ String labelKey(String labelName, String labelValue) { return labelName + "=" + labelValue; } - public Set getByLabel(String labelName, String labelValue) { - return postIndexer.getByIndex(LABEL_INDEXER_NAME, labelKey(labelName, labelValue)); - } - @Override public void destroy() throws Exception { if (postWatcher != null) { @@ -119,7 +111,7 @@ public void destroy() throws Exception { } @Override - public void onApplicationEvent(@NonNull SchemeInitializedEvent event) { + public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { if (!synchronizer.isStarted()) { synchronizer.start(); } diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java new file mode 100644 index 0000000000..3e1a97723a --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java @@ -0,0 +1,38 @@ +package run.halo.app.core.extension.service; + +import reactor.core.publisher.Mono; +import run.halo.app.infra.exception.AccessDeniedException; + +/** + * An interface for email password recovery. + * + * @author guqing + * @since 2.11.0 + */ +public interface EmailPasswordRecoveryService { + + /** + *

Send password reset email.

+ * if the user does not exist, it will return {@link Mono#empty()} + * if the user exists, but the email is not the same, it will return {@link Mono#empty()} + * + * @param username username to request password reset + * @param email email to match the user with the username + * @return {@link Mono#empty()} if the user does not exist, or the email is not the same. + */ + Mono sendPasswordResetEmail(String username, String email); + + /** + *

Reset password by token.

+ * if the token is invalid, it will return {@link Mono#error(Throwable)}} + * if the token is valid, but the username is not the same, it will return + * {@link Mono#error(Throwable)} + * + * @param username username to reset password + * @param newPassword new password + * @param token token to validate the user + * @return {@link Mono#empty()} if the token is invalid or the username is not the same. + * @throws AccessDeniedException if the token is invalid + */ + Mono changePassword(String username, String newPassword, String token); +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java new file mode 100644 index 0000000000..4fbe8c859e --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -0,0 +1,208 @@ +package run.halo.app.core.extension.service.impl; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.notification.Reason; +import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.core.extension.service.EmailPasswordRecoveryService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.notification.NotificationCenter; +import run.halo.app.notification.NotificationReasonEmitter; +import run.halo.app.notification.UserIdentity; + +/** + * A default implementation for {@link EmailPasswordRecoveryService}. + * + * @author guqing + * @since 2.11.0 + */ +@Component +@RequiredArgsConstructor +public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService { + public static final int MAX_ATTEMPTS = 5; + public static final long LINK_EXPIRATION_MINUTES = 30; + static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; + + private final ResetPasswordVerificationManager resetPasswordVerificationManager = + new ResetPasswordVerificationManager(); + private final ExternalLinkProcessor externalLinkProcessor; + private final ReactiveExtensionClient client; + private final NotificationReasonEmitter reasonEmitter; + private final NotificationCenter notificationCenter; + private final UserService userService; + + @Override + public Mono sendPasswordResetEmail(String username, String email) { + return client.fetch(User.class, username) + .flatMap(user -> { + var userEmail = user.getSpec().getEmail(); + if (!StringUtils.equals(userEmail, email)) { + return Mono.empty(); + } + if (!user.getSpec().isEmailVerified()) { + return Mono.empty(); + } + return sendResetPasswordNotification(username, email); + }); + } + + @Override + public Mono changePassword(String username, String newPassword, String token) { + Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); + Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); + var verified = resetPasswordVerificationManager.verifyToken(username, token); + if (!verified) { + return Mono.error(AccessDeniedException::new); + } + return userService.updateWithRawPassword(username, newPassword) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + .flatMap(user -> { + resetPasswordVerificationManager.removeToken(username); + return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail()); + }) + .then(); + } + + Mono unSubscribeResetPasswordEmailNotification(String email) { + if (StringUtils.isBlank(email)) { + return Mono.empty(); + } + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); + return notificationCenter.unsubscribe(subscriber, createInterestReason(email)) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); + } + + Mono sendResetPasswordNotification(String username, String email) { + var token = resetPasswordVerificationManager.generateToken(username); + var link = getResetPasswordLink(username, token); + + var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email); + var interestReasonSubject = createInterestReason(email).getSubject(); + var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, + builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) + .attribute("username", username) + .attribute("link", link) + .author(UserIdentity.of(username)) + .subject(Reason.Subject.builder() + .apiVersion(interestReasonSubject.getApiVersion()) + .kind(interestReasonSubject.getKind()) + .name(interestReasonSubject.getName()) + .title("使用邮箱地址重置密码:" + email) + .build() + ) + ); + return Mono.when(subscribeNotification).then(emitReasonMono); + } + + Mono autoSubscribeResetPasswordEmailNotification(String email) { + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); + var interestReason = createInterestReason(email); + return notificationCenter.subscribe(subscriber, interestReason) + .then(); + } + + Subscription.InterestReason createInterestReason(String email) { + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE); + interestReason.setSubject(Subscription.ReasonSubject.builder() + .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) + .kind(User.KIND) + .name(UserIdentity.anonymousWithEmail(email).name()) + .build()); + return interestReason; + } + + private String getResetPasswordLink(String username, String token) { + return externalLinkProcessor.processLink( + "/uc/reset-password/" + username + "?reset_password_token=" + token); + } + + static class ResetPasswordVerificationManager { + private final Cache userTokenCache = + CacheBuilder.newBuilder() + .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(10000) + .build(); + + private final Cache + blackListCache = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofHours(2)) + .maximumSize(1000) + .build(); + + public boolean verifyToken(String username, String token) { + var verification = userTokenCache.getIfPresent(username); + if (verification == null) { + // expired or not generated + return false; + } + if (blackListCache.getIfPresent(username) != null) { + // in blacklist + throw new RateLimitExceededException(null); + } + synchronized (verification) { + if (verification.getAttempts().get() >= MAX_ATTEMPTS) { + // add to blacklist to prevent brute force attack + blackListCache.put(username, true); + return false; + } + if (!verification.getToken().equals(token)) { + verification.getAttempts().incrementAndGet(); + return false; + } + } + return true; + } + + public void removeToken(String username) { + userTokenCache.invalidate(username); + } + + public String generateToken(String username) { + Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); + var verification = new Verification(); + verification.setToken(RandomStringUtils.randomAlphanumeric(20)); + verification.setAttempts(new AtomicInteger(0)); + userTokenCache.put(username, verification); + return verification.getToken(); + } + + /** + * Only for test. + */ + boolean contains(String username) { + return userTokenCache.getIfPresent(username) != null; + } + + @Data + @Accessors(chain = true) + static class Verification { + private String token; + private AtomicInteger attempts; + } + } +} diff --git a/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java index a495356809..48eda2f221 100644 --- a/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java +++ b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java @@ -4,12 +4,13 @@ import static org.springframework.util.StringUtils.hasText; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Duration; import java.time.Instant; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.util.Predicates; @@ -137,42 +138,31 @@ public Mono create(E extension) { @SuppressWarnings("unchecked") public Mono update(E extension) { // Refactor the atomic reference if we have a better solution. - final var statusChangeOnly = new AtomicBoolean(false); - return getLatest(extension) - .map(old -> new JsonExtension(objectMapper, old)) - .flatMap(oldJsonExt -> { - var newJsonExt = new JsonExtension(objectMapper, extension); - // reset some mandatory fields - var oldMetadata = oldJsonExt.getMetadata(); - var newMetadata = newJsonExt.getMetadata(); - newMetadata.setCreationTimestamp(oldMetadata.getCreationTimestamp()); - newMetadata.setGenerateName(oldMetadata.getGenerateName()); - - var oldObjectNode = oldJsonExt.getInternal().deepCopy(); - var newObjectNode = newJsonExt.getInternal().deepCopy(); - if (Objects.equals(oldObjectNode, newObjectNode)) { - // if no data were changed, just skip updating. - return Mono.empty(); - } - // check status is changed - oldObjectNode.remove("status"); - newObjectNode.remove("status"); - if (Objects.equals(oldObjectNode, newObjectNode)) { - statusChangeOnly.set(true); - } - return Mono.just(newJsonExt); - }) - .map(converter::convertTo) - .flatMap(extensionStore -> client.update(extensionStore.getName(), - extensionStore.getVersion(), - extensionStore.getData())) - .map(updated -> converter.convertFrom((Class) extension.getClass(), updated)) - .doOnNext(updated -> { - if (!statusChangeOnly.get()) { - watchers.onUpdate(extension, updated); - } - }) - .switchIfEmpty(Mono.defer(() -> Mono.just(extension))); + return getLatest(extension).flatMap(old -> { + var oldJsonExt = new JsonExtension(objectMapper, old); + var newJsonExt = new JsonExtension(objectMapper, extension); + // reset some mandatory fields + var oldMetadata = oldJsonExt.getMetadata(); + var newMetadata = newJsonExt.getMetadata(); + newMetadata.setCreationTimestamp(oldMetadata.getCreationTimestamp()); + newMetadata.setGenerateName(oldMetadata.getGenerateName()); + + if (Objects.equals(oldJsonExt, newJsonExt)) { + // skip updating if not data changed. + return Mono.just(extension); + } + + var onlyStatusChanged = + isOnlyStatusChanged(oldJsonExt.getInternal(), newJsonExt.getInternal()); + + var store = this.converter.convertTo(newJsonExt); + var updated = client.update(store.getName(), store.getVersion(), store.getData()) + .map(ext -> converter.convertFrom((Class) extension.getClass(), ext)); + if (!onlyStatusChanged) { + updated = updated.doOnNext(ext -> watchers.onUpdate(old, ext)); + } + return updated; + }); } private Mono getLatest(Extension extension) { @@ -199,4 +189,26 @@ public void watch(Watcher watcher) { this.watchers.addWatcher(watcher); } + private static boolean isOnlyStatusChanged(ObjectNode oldNode, ObjectNode newNode) { + if (Objects.equals(oldNode, newNode)) { + return false; + } + // WARNING!!! + // Do not edit the ObjectNode + var oldFields = new HashSet(); + var newFields = new HashSet(); + oldNode.fieldNames().forEachRemaining(oldFields::add); + newNode.fieldNames().forEachRemaining(newFields::add); + oldFields.remove("status"); + newFields.remove("status"); + if (!Objects.equals(oldFields, newFields)) { + return false; + } + for (var field : oldFields) { + if (!Objects.equals(oldNode.get(field), newNode.get(field))) { + return false; + } + } + return true; + } } diff --git a/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java b/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java index 769e3cd034..bf5e9814cb 100644 --- a/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java +++ b/application/src/main/java/run/halo/app/extension/router/ExtensionCompositeRouterFunction.java @@ -3,6 +3,9 @@ import java.util.Collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; @@ -13,23 +16,31 @@ import reactor.core.publisher.Mono; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; import run.halo.app.extension.SchemeWatcherManager; import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher; public class ExtensionCompositeRouterFunction implements - RouterFunction, SchemeWatcher { + RouterFunction, + SchemeWatcher, + InitializingBean, + ApplicationListener { private final Map> schemeRouterFuncMapper; private final ReactiveExtensionClient client; + private final SchemeManager schemeManager; + + private final SchemeWatcherManager watcherManager; + public ExtensionCompositeRouterFunction(ReactiveExtensionClient client, - SchemeWatcherManager watcherManager) { + SchemeWatcherManager watcherManager, + SchemeManager schemeManager) { this.client = client; + this.schemeManager = schemeManager; + this.watcherManager = watcherManager; schemeRouterFuncMapper = new ConcurrentHashMap<>(); - if (watcherManager != null) { - watcherManager.register(this); - } } @Override @@ -60,4 +71,17 @@ public void onChange(SchemeWatcherManager.ChangeEvent event) { this.schemeRouterFuncMapper.remove(unregisteredEvent.getDeletedScheme()); } } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + schemeManager.schemes().forEach(scheme -> { + var factory = new ExtensionRouterFunctionFactory(scheme, client); + this.schemeRouterFuncMapper.put(scheme, factory.create()); + }); + } + + @Override + public void afterPropertiesSet() { + watcherManager.register(this); + } } diff --git a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java index d8d74d1cbd..b186923665 100644 --- a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java @@ -2,6 +2,7 @@ import java.io.IOException; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; import org.springframework.core.io.UrlResource; import org.springframework.core.io.buffer.DataBufferUtils; @@ -16,7 +17,7 @@ @Slf4j @Component -public class DefaultThemeInitializer implements ApplicationListener { +public class DefaultThemeInitializer implements ApplicationListener { private final ThemeService themeService; @@ -32,7 +33,7 @@ public DefaultThemeInitializer(ThemeService themeService, ThemeRootGetter themeR } @Override - public void onApplicationEvent(SchemeInitializedEvent event) { + public void onApplicationEvent(ApplicationStartedEvent event) { if (themeProps.getInitializer().isDisabled()) { log.debug("Skipped initializing default theme due to disabled"); return; diff --git a/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java b/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java index d26120d1de..716ef8157c 100644 --- a/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java +++ b/application/src/main/java/run/halo/app/infra/ExtensionResourceInitializer.java @@ -1,12 +1,14 @@ package run.halo.app.infra; import java.io.IOException; +import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; +import org.springframework.context.ApplicationListener; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; @@ -28,7 +30,7 @@ */ @Slf4j @Component -public class ExtensionResourceInitializer { +public class ExtensionResourceInitializer implements ApplicationListener { public static final Set REQUIRED_EXTENSION_LOCATIONS = Set.of("classpath:/extensions/*.yaml", "classpath:/extensions/*.yml"); @@ -45,8 +47,7 @@ public ExtensionResourceInitializer(HaloProperties haloProperties, this.eventPublisher = eventPublisher; } - @EventListener(SchemeInitializedEvent.class) - public Mono initialize(SchemeInitializedEvent initializedEvent) { + public void onApplicationEvent(ApplicationStartedEvent initializedEvent) { var locations = new HashSet(); if (!haloProperties.isRequiredExtensionDisabled()) { locations.addAll(REQUIRED_EXTENSION_LOCATIONS); @@ -55,10 +56,10 @@ public Mono initialize(SchemeInitializedEvent initializedEvent) { locations.addAll(haloProperties.getInitialExtensionLocations()); } if (CollectionUtils.isEmpty(locations)) { - return Mono.empty(); + return; } - return Flux.fromIterable(locations) + Flux.fromIterable(locations) .doOnNext(location -> log.debug("Trying to initialize extension resources from location: {}", location)) .map(this::listResources) @@ -82,7 +83,8 @@ public Mono initialize(SchemeInitializedEvent initializedEvent) { } }) .then(Mono.fromRunnable( - () -> eventPublisher.publishEvent(new ExtensionInitializedEvent(this)))); + () -> eventPublisher.publishEvent(new ExtensionInitializedEvent(this)))) + .block(Duration.ofMinutes(1)); } private Mono createOrUpdate(Unstructured extension) { diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializedEvent.java b/application/src/main/java/run/halo/app/infra/SchemeInitializedEvent.java deleted file mode 100644 index 647aa518ce..0000000000 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializedEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package run.halo.app.infra; - -import org.springframework.context.ApplicationEvent; - -public class SchemeInitializedEvent extends ApplicationEvent { - - public SchemeInitializedEvent(Object source) { - super(source); - } - -} diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index ff2bb8b82f..e7e9bb7d30 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -1,7 +1,6 @@ package run.halo.app.infra; -import org.springframework.boot.context.event.ApplicationStartedEvent; -import org.springframework.context.ApplicationEventPublisher; +import org.springframework.boot.context.event.ApplicationContextInitializedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; @@ -36,7 +35,8 @@ import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.DefaultSchemeManager; +import run.halo.app.extension.DefaultSchemeWatcherManager; import run.halo.app.extension.Secret; import run.halo.app.migration.Backup; import run.halo.app.plugin.extensionpoint.ExtensionDefinition; @@ -45,20 +45,17 @@ import run.halo.app.security.PersonalAccessToken; @Component -public class SchemeInitializer implements ApplicationListener { +public class SchemeInitializer implements ApplicationListener { - private final SchemeManager schemeManager; - - private final ApplicationEventPublisher eventPublisher; + @Override + public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event) { + var watcherManager = new DefaultSchemeWatcherManager(); + var schemeManager = new DefaultSchemeManager(watcherManager); - public SchemeInitializer(SchemeManager schemeManager, - ApplicationEventPublisher eventPublisher) { - this.schemeManager = schemeManager; - this.eventPublisher = eventPublisher; - } + var beanFactory = event.getApplicationContext().getBeanFactory(); + beanFactory.registerSingleton("schemeWatcherManager", watcherManager); + beanFactory.registerSingleton("schemeManager", schemeManager); - @Override - public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { schemeManager.register(Role.class); // plugin.halo.run @@ -108,7 +105,5 @@ public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { schemeManager.register(Subscription.class); schemeManager.register(NotifierDescriptor.class); schemeManager.register(Notification.class); - - eventPublisher.publishEvent(new SchemeInitializedEvent(this)); } } diff --git a/application/src/main/java/run/halo/app/search/IndicesInitializer.java b/application/src/main/java/run/halo/app/search/IndicesInitializer.java index 89e6dd6a49..cbc54a498e 100644 --- a/application/src/main/java/run/halo/app/search/IndicesInitializer.java +++ b/application/src/main/java/run/halo/app/search/IndicesInitializer.java @@ -1,12 +1,12 @@ package run.halo.app.search; -import java.util.concurrent.CountDownLatch; +import java.time.Duration; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; -import run.halo.app.infra.SchemeInitializedEvent; @Slf4j @Component @@ -19,17 +19,12 @@ public IndicesInitializer(IndicesService indicesService) { } @Async - @EventListener(SchemeInitializedEvent.class) - public void whenSchemeInitialized(SchemeInitializedEvent event) throws InterruptedException { - var latch = new CountDownLatch(1); + @EventListener + public void whenSchemeInitialized(ApplicationStartedEvent event) { log.info("Initialize post indices..."); var watch = new StopWatch("PostIndicesWatch"); watch.start("rebuild"); - indicesService.rebuildPostIndices() - .doFinally(signalType -> latch.countDown()) - .subscribe(); - latch.await(); - watch.stop(); + indicesService.rebuildPostIndices().block(Duration.ofMinutes(5)); log.info("Initialized post indices. Usage: {}", watch); } diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java index fa8f78700a..eb7ffd8634 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java +++ b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java @@ -2,14 +2,18 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.github.resilience4j.ratelimiter.RequestNotPermitted; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextImpl; @@ -20,9 +24,11 @@ import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.service.EmailPasswordRecoveryService; import run.halo.app.core.extension.service.UserService; import run.halo.app.extension.GroupVersion; import run.halo.app.infra.exception.RateLimitExceededException; @@ -40,6 +46,7 @@ public class PublicUserEndpoint implements CustomEndpoint { private final UserService userService; private final ServerSecurityContextRepository securityContextRepository; private final ReactiveUserDetailsService reactiveUserDetailsService; + private final EmailPasswordRecoveryService emailPasswordRecoveryService; private final RateLimiterRegistry rateLimiterRegistry; @Override @@ -55,9 +62,91 @@ public RouterFunction endpoint() { ) .response(responseBuilder().implementation(User.class)) ) + .POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail, + builder -> builder.operationId("SendPasswordResetEmail") + .description("Send password reset email when forgot password") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(PasswordResetEmailRequest.class) + ) + .response(responseBuilder() + .responseCode(HttpStatus.NO_CONTENT.toString()) + .implementation(Void.class)) + ) + .PUT("/users/{name}/reset-password", this::resetPasswordByToken, + builder -> builder.operationId("ResetPasswordByToken") + .description("Reset password by token") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .description("The name of the user") + .required(true) + .in(ParameterIn.PATH) + ) + .requestBody(requestBodyBuilder() + .required(true) + .implementation(ResetPasswordRequest.class) + ) + .response(responseBuilder() + .responseCode(HttpStatus.NO_CONTENT.toString()) + .implementation(Void.class) + ) + ) .build(); } + private Mono resetPasswordByToken(ServerRequest request) { + var username = request.pathVariable("name"); + return request.bodyToMono(ResetPasswordRequest.class) + .doOnNext(resetReq -> { + if (StringUtils.isBlank(resetReq.token())) { + throw new ServerWebInputException("Token must not be blank"); + } + if (StringUtils.isBlank(resetReq.newPassword())) { + throw new ServerWebInputException("New password must not be blank"); + } + }) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Request body must not be empty")) + ) + .flatMap(resetReq -> { + var token = resetReq.token(); + var newPassword = resetReq.newPassword(); + return emailPasswordRecoveryService.changePassword(username, newPassword, token); + }) + .then(ServerResponse.noContent().build()); + } + + record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username, + @Schema(requiredMode = REQUIRED) String email) { + } + + record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword, + @Schema(requiredMode = REQUIRED) String token) { + } + + private Mono sendPasswordResetEmail(ServerRequest request) { + return request.bodyToMono(PasswordResetEmailRequest.class) + .flatMap(passwordResetRequest -> { + var username = passwordResetRequest.username(); + var email = passwordResetRequest.email(); + return Mono.just(passwordResetRequest) + .transformDeferred(sendResetPasswordEmailRateLimiter(username, email)) + .flatMap( + r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email)) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.noContent().build()); + } + + RateLimiterOperator sendResetPasswordEmailRateLimiter(String username, String email) { + String rateLimiterKey = "send-reset-password-email-" + username + ":" + email; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email"); + return RateLimiterOperator.of(rateLimiter); + } + @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); diff --git a/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java index 9f95646a5b..f8023fb6c0 100644 --- a/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/CategoryFinder.java @@ -28,4 +28,6 @@ public interface CategoryFinder { Flux listAsTree(); Flux listAsTree(String name); + + Mono getParentByName(String name); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java index 07a89f9c25..43502d798f 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CategoryFinderImpl.java @@ -81,6 +81,23 @@ public Flux listAsTree(String name) { return this.toCategoryTreeVoFlux(name); } + @Override + public Mono getParentByName(String name) { + if (StringUtils.isBlank(name)) { + return Mono.empty(); + } + return client.list(Category.class, + category -> { + List children = category.getSpec().getChildren(); + if (children == null) { + return false; + } + return children.contains(name); + }, + defaultComparator()) + .next().map(CategoryVo::from); + } + Flux toCategoryTreeVoFlux(String name) { return listAll() .collectList() diff --git a/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java index 996ee0c418..f1b39af505 100644 --- a/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java +++ b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.event.EventListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; @@ -15,7 +15,6 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import run.halo.app.infra.SchemeInitializedEvent; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.theme.DefaultTemplateEnum; @@ -90,10 +89,15 @@ private RouterFunction createRouterFunction(RoutePattern routePa /** * Refresh the {@link #cachedRouters} when the permalink rule is changed. * - * @param event {@link SchemeInitializedEvent} or {@link PermalinkRuleChangedEvent} + * @param event {@link PermalinkRuleChangedEvent} */ - @EventListener({SchemeInitializedEvent.class, PermalinkRuleChangedEvent.class}) - public void onSchemeInitializedEvent(@NonNull ApplicationEvent event) { + @EventListener + public void onPermalinkRuleChanged(PermalinkRuleChangedEvent event) { + this.cachedRouters = routerFunctions(); + } + + @EventListener + public void onApplicationStarted(ApplicationStartedEvent event) { this.cachedRouters = routerFunctions(); } diff --git a/application/src/main/resources/META-INF/spring.factories b/application/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..e63b88094d --- /dev/null +++ b/application/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.context.ApplicationListener=run.halo.app.infra.SchemeInitializer diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 3233542d2e..d289d8cf24 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -99,3 +99,7 @@ resilience4j.ratelimiter: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s + send-reset-password-email: + limitForPeriod: 2 + limitRefreshPeriod: 1m + timeoutDuration: 0s diff --git a/application/src/main/resources/extensions/notification-templates.yaml b/application/src/main/resources/extensions/notification-templates.yaml index cb80998493..3661e8cc69 100644 --- a/application/src/main/resources/extensions/notification-templates.yaml +++ b/application/src/main/resources/extensions/notification-templates.yaml @@ -126,3 +126,30 @@ spec:

如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。

+--- +apiVersion: notification.halo.run/v1alpha1 +kind: NotificationTemplate +metadata: + name: template-reset-password-by-email +spec: + reasonSelector: + reasonType: reset-password-by-email + language: default + template: + title: "重置密码-[(${site.title})]" + rawBody: | + 【[(${site.title})]】你已经请求了重置密码,可以链接来重置密码:[(${link})],请在 [(${expirationAtMinutes})] 分钟内完成重置。 + htmlBody: | +
+
+

+
+
+

你已经请求了重置密码,可以点击下面的链接来重置密码:

+ +

+

如果您没有请求重置密码,请忽略此电子邮件。

+
+
diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml index b3d8cd655f..23429a95ad 100644 --- a/application/src/main/resources/extensions/notification.yaml +++ b/application/src/main/resources/extensions/notification.yaml @@ -163,3 +163,23 @@ spec: - name: expirationAtMinutes type: string description: "The expiration minutes of the verification code, such as 5 minutes." +--- +apiVersion: notification.halo.run/v1alpha1 +kind: ReasonType +metadata: + name: reset-password-by-email + labels: + halo.run/hide: "true" +spec: + displayName: "根据邮件地址重置密码" + description: "当你通过邮件地址找回密码时,会收到一条带密码重置链接的邮件,你需要点击邮件中的链接来重置密码。" + properties: + - name: username + type: string + description: "The username of the user." + - name: link + type: string + description: "The reset link." + - name: expirationAtMinutes + type: string + description: "The expiration minutes of the reset link, such as 30 minutes." diff --git a/application/src/main/resources/extensions/role-template-uc-content.yaml b/application/src/main/resources/extensions/role-template-uc-content.yaml index 3904fdca03..7b9b42a2e0 100644 --- a/application/src/main/resources/extensions/role-template-uc-content.yaml +++ b/application/src/main/resources/extensions/role-template-uc-content.yaml @@ -2,11 +2,28 @@ apiVersion: v1alpha1 kind: "Role" metadata: name: post-editor + labels: + rbac.authorization.halo.run/system-reserved: "true" + annotations: + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "编辑者" + rbac.authorization.halo.run/dependencies: | + ["role-template-post-editor"] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-editor + labels: + halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Editor" rbac.authorization.halo.run/dependencies: | - ["role-template-manage-posts", "post-author"] + ["role-template-manage-posts", "role-template-post-author"] rules: [ ] --- @@ -14,11 +31,30 @@ apiVersion: v1alpha1 kind: "Role" metadata: name: post-author + labels: + rbac.authorization.halo.run/system-reserved: "true" + annotations: + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "作者" + rbac.authorization.halo.run/disallow-access-console: "true" + rbac.authorization.halo.run/redirect-on-login: "/uc" + rbac.authorization.halo.run/dependencies: | + [ "role-template-post-author" ] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-author + labels: + halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Author" rbac.authorization.halo.run/dependencies: | - [ "post-contributor", "post-publisher" ] + [ "role-template-post-contributor", "role-template-post-publisher", "role-template-post-attachment-manager" ] rules: [ ] --- @@ -26,8 +62,27 @@ apiVersion: v1alpha1 kind: "Role" metadata: name: post-contributor + labels: + rbac.authorization.halo.run/system-reserved: "true" + annotations: + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 + rbac.authorization.halo.run/display-name: "投稿者" + rbac.authorization.halo.run/disallow-access-console: "true" + rbac.authorization.halo.run/redirect-on-login: "/uc" + rbac.authorization.halo.run/dependencies: | + [ "role-template-post-contributor" ] +rules: [ ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-post-contributor + labels: + halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" + # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Contributor" rbac.authorization.halo.run/dependencies: | [ "role-template-view-categories", "role-template-view-tags" ] @@ -45,7 +100,7 @@ rules: apiVersion: v1alpha1 kind: Role metadata: - name: post-publisher + name: role-template-post-publisher labels: halo.run/role-template: "true" annotations: @@ -57,38 +112,20 @@ rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "posts/publish", "posts/unpublish" ] verbs: [ "update" ] + --- apiVersion: v1alpha1 kind: Role metadata: - name: post-attachment-manager + name: role-template-post-attachment-manager labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" rbac.authorization.halo.run/display-name: "Post Attachment Manager" - rbac.authorization.halo.run/dependencies: | - [ "role-template-post-attachment-viewer" ] rbac.authorization.halo.run/ui-permissions: | [ "uc:attachments:manage" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] resources: [ "attachments" ] verbs: [ "create", "update", "delete" ] - ---- -apiVersion: v1alpha1 -kind: Role -metadata: - name: post-attachment-viewer - labels: - halo.run/role-template: "true" - annotations: - rbac.authorization.halo.run/module: "Posts Management" - rbac.authorization.halo.run/display-name: "Post Attachment Viewer" - rbac.authorization.halo.run/ui-permissions: | - [ "uc:attachments:view" ] -rules: - - apiGroups: [ "uc.api.content.halo.run" ] - resources: [ "attachments" ] - verbs: [ "get", "list" ] \ No newline at end of file diff --git a/application/src/main/resources/extensions/system-default-role.yaml b/application/src/main/resources/extensions/system-default-role.yaml index 50adc6bafe..dee595d516 100644 --- a/application/src/main/resources/extensions/system-default-role.yaml +++ b/application/src/main/resources/extensions/system-default-role.yaml @@ -6,7 +6,9 @@ metadata: rbac.authorization.halo.run/system-reserved: "true" annotations: rbac.authorization.halo.run/display-name: "访客" -rules: [ ] + rbac.authorization.halo.run/disallow-access-console: "true" + rbac.authorization.halo.run/redirect-on-login: "/uc" +rules: [] --- apiVersion: v1alpha1 diff --git a/application/src/main/resources/themes/theme-earth.zip b/application/src/main/resources/themes/theme-earth.zip index f9182b72ad..8a1cd4f9e7 100644 Binary files a/application/src/main/resources/themes/theme-earth.zip and b/application/src/main/resources/themes/theme-earth.zip differ diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java new file mode 100644 index 0000000000..1d95fced07 --- /dev/null +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java @@ -0,0 +1,83 @@ +package run.halo.app.core.extension.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.infra.exception.RateLimitExceededException; + +/** + * Tests for {@link EmailPasswordRecoveryServiceImpl}. + * + * @author guqing + * @since 2.11.0 + */ +@ExtendWith(MockitoExtension.class) +class EmailPasswordRecoveryServiceImplTest { + + @Nested + class ResetPasswordVerificationManagerTest { + @Test + public void generateTokenTest() { + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + verificationManager.generateToken("fake-user"); + var result = verificationManager.contains("fake-user"); + assertThat(result).isTrue(); + + verificationManager.generateToken("guqing"); + result = verificationManager.contains("guqing"); + assertThat(result).isTrue(); + + result = verificationManager.contains("123"); + assertThat(result).isFalse(); + } + } + + @Test + public void removeTest() { + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + verificationManager.generateToken("fake-user"); + var result = verificationManager.contains("fake-user"); + + verificationManager.removeToken("fake-user"); + result = verificationManager.contains("fake-user"); + assertThat(result).isFalse(); + } + + @Test + void verifyTokenTestNormal() { + String username = "guqing"; + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + var result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + + var token = verificationManager.generateToken(username); + result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + + result = verificationManager.verifyToken(username, token); + assertThat(result).isTrue(); + } + + @Test + void verifyTokenFailedAfterMaxAttempts() { + String username = "guqing"; + var verificationManager = + new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); + var token = verificationManager.generateToken(username); + for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) { + var result = verificationManager.verifyToken(username, "fake-code"); + assertThat(result).isFalse(); + } + + assertThatThrownBy(() -> verificationManager.verifyToken(username, token)) + .isInstanceOf(RateLimitExceededException.class) + .hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\""); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/extension/FakeExtension.java b/application/src/test/java/run/halo/app/extension/FakeExtension.java index d0e5cb03bf..5933c37a3c 100644 --- a/application/src/test/java/run/halo/app/extension/FakeExtension.java +++ b/application/src/test/java/run/halo/app/extension/FakeExtension.java @@ -1,12 +1,21 @@ package run.halo.app.extension; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + @GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Fake", plural = "fakes", singular = "fake") +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) public class FakeExtension extends AbstractExtension { + private FakeStatus status = new FakeStatus(); + public static FakeExtension createFake(String name) { var metadata = new Metadata(); metadata.setName(name); @@ -15,4 +24,8 @@ public static FakeExtension createFake(String name) { return fake; } + @Data + public static class FakeStatus { + private String state; + } } diff --git a/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java b/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java index 7e60e74f6d..0f83fbe092 100644 --- a/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java +++ b/application/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java @@ -455,6 +455,37 @@ void shouldNotUpdateIfExtensionNotChange() { verify(storeClient, never()).update(any(), any(), any()); } + @Test + void shouldUpdateIfExtensionStatusChangedOnly() { + var fake = createFakeExtension("fake", 2L); + fake.getStatus().setState("new-state"); + var storeName = "/registry/fake.halo.run/fakes/fake"; + when(converter.convertTo(any())).thenReturn( + createExtensionStore(storeName, 2L)); + when(storeClient.update(any(), any(), any())).thenReturn( + Mono.just(createExtensionStore(storeName, 2L))); + when(storeClient.fetchByName(storeName)).thenReturn( + Mono.just(createExtensionStore(storeName, 1L))); + + var oldFake = createFakeExtension("fake", 2L); + oldFake.getStatus().setState("old-state"); + + var updatedFake = createFakeExtension("fake", 3L); + when(converter.convertFrom(same(FakeExtension.class), any())) + .thenReturn(oldFake) + .thenReturn(updatedFake); + + StepVerifier.create(client.update(fake)) + .expectNext(updatedFake) + .verifyComplete(); + + verify(storeClient).fetchByName(storeName); + verify(converter).convertTo(isA(JsonExtension.class)); + verify(converter, times(2)).convertFrom(same(FakeExtension.class), any()); + verify(storeClient) + .update(eq("/registry/fake.halo.run/fakes/fake"), eq(2L), any()); + } + @Test void shouldUpdateUnstructuredSuccessfully() throws JsonProcessingException { var fake = createUnstructured(); @@ -539,6 +570,13 @@ void shouldNotWatchOnUpdateIfExtensionNotChange() { verify(watcher, never()).onUpdate(any(), any()); } + @Test + void shouldNotWatchOnUpdateIfExtensionStatusChangeOnly() { + shouldUpdateIfExtensionStatusChangedOnly(); + + verify(watcher, never()).onUpdate(any(), any()); + } + @Test void shouldWatchOnDeleteSuccessfully() { doNothing().when(watcher).onDelete(any()); diff --git a/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java b/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java index cd4599478c..d5f9884bdd 100644 --- a/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java +++ b/application/src/test/java/run/halo/app/extension/router/ExtensionCompositeRouterFunctionTest.java @@ -4,13 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.reactive.function.server.HandlerStrategies; @@ -18,6 +19,7 @@ import run.halo.app.extension.FakeExtension; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; import run.halo.app.extension.SchemeWatcherManager; import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered; import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered; @@ -28,10 +30,17 @@ class ExtensionCompositeRouterFunctionTest { @Mock ReactiveExtensionClient client; + @Mock + SchemeManager schemeManager; + + @Mock + SchemeWatcherManager watcherManager; + + @InjectMocks + ExtensionCompositeRouterFunction extensionRouterFunc; + @Test void shouldRouteWhenSchemeRegistered() { - var extensionRouterFunc = new ExtensionCompositeRouterFunction(client, null); - var exchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build()); @@ -51,8 +60,6 @@ void shouldRouteWhenSchemeRegistered() { @Test void shouldNotRouteWhenSchemeUnregistered() { - var extensionRouterFunc = new ExtensionCompositeRouterFunction(client, null); - var exchange = MockServerWebExchange.from( MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build()); @@ -74,10 +81,16 @@ void shouldNotRouteWhenSchemeUnregistered() { } @Test - void shouldRegisterWatcherIfWatcherManagerIsNotNull() { - var watcherManager = mock(SchemeWatcherManager.class); - var routerFunction = new ExtensionCompositeRouterFunction(client, watcherManager); - verify(watcherManager, times(1)).register(eq(routerFunction)); + void shouldRegisterWatcherAfterPropertiesSet() { + extensionRouterFunc.afterPropertiesSet(); + verify(watcherManager).register(eq(extensionRouterFunc)); + } + + @Test + void shouldBuildRouterFunctionsOnApplicationStarted() { + var applicationStartedEvent = mock(ApplicationStartedEvent.class); + extensionRouterFunc.onApplicationEvent(applicationStartedEvent); + verify(schemeManager).schemes(); } } diff --git a/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java b/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java index 4d80e8813c..b082a18816 100644 --- a/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java +++ b/application/src/test/java/run/halo/app/infra/ExtensionResourceInitializerTest.java @@ -23,10 +23,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.util.FileSystemUtils; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; @@ -47,7 +47,7 @@ class ExtensionResourceInitializerTest { @Mock HaloProperties haloProperties; @Mock - SchemeInitializedEvent applicationReadyEvent; + ApplicationStartedEvent applicationStartedEvent; @Mock ApplicationEventPublisher eventPublisher; @@ -128,10 +128,7 @@ void onApplicationEvent() throws JSONException { .thenReturn(Mono.empty()); when(extensionClient.create(any())).thenReturn(Mono.empty()); - var initializeMono = extensionResourceInitializer.initialize(applicationReadyEvent); - StepVerifier.create(initializeMono) - .verifyComplete(); - + extensionResourceInitializer.onApplicationEvent(applicationStartedEvent); verify(extensionClient, times(3)).create(argumentCaptor.capture()); diff --git a/console/.eslintrc.cjs b/console/.eslintrc.cjs index 821e927b8c..6cda978fcb 100644 --- a/console/.eslintrc.cjs +++ b/console/.eslintrc.cjs @@ -24,6 +24,7 @@ module.exports = { extends: ["plugin:cypress/recommended"], }, ], + ignorePatterns: ["!.storybook"], parserOptions: { ecmaVersion: "latest", }, diff --git a/console/.gitignore b/console/.gitignore index 36bbbe4664..b6c6ca7cd4 100644 --- a/console/.gitignore +++ b/console/.gitignore @@ -29,3 +29,4 @@ coverage *.sw? !src/build +storybook-static diff --git a/console/console-src/composables/use-content-cache.ts b/console/console-src/composables/use-content-cache.ts index 0f4d17d619..158afea8ad 100644 --- a/console/console-src/composables/use-content-cache.ts +++ b/console/console-src/composables/use-content-cache.ts @@ -6,7 +6,7 @@ export interface ContentCache { content?: string; version: number; } -import debounce from "lodash.debounce"; +import { debounce } from "lodash-es"; import { useI18n } from "vue-i18n"; interface useContentCacheReturn { diff --git a/console/console-src/composables/use-setting-form.ts b/console/console-src/composables/use-setting-form.ts index 94c3c6e92a..9e5e4c9e2c 100644 --- a/console/console-src/composables/use-setting-form.ts +++ b/console/console-src/composables/use-setting-form.ts @@ -5,8 +5,7 @@ import { ref } from "vue"; import { apiClient } from "@/utils/api-client"; // libs -import cloneDeep from "lodash.clonedeep"; -import merge from "lodash.merge"; +import { cloneDeep, merge } from "lodash-es"; import type { ConfigMap, Setting, SettingForm } from "@halo-dev/api-client"; import type { FormKitSchemaCondition, FormKitSchemaNode } from "@formkit/core"; import { Toast } from "@halo-dev/components"; diff --git a/console/console-src/layouts/BasicLayout.vue b/console/console-src/layouts/BasicLayout.vue index 60244afec4..31090de702 100644 --- a/console/console-src/layouts/BasicLayout.vue +++ b/console/console-src/layouts/BasicLayout.vue @@ -179,7 +179,7 @@ onMounted(() => {
@@ -188,6 +188,7 @@ onMounted(() => { />
diff --git a/console/console-src/modules/contents/attachments/AttachmentList.vue b/console/console-src/modules/contents/attachments/AttachmentList.vue index 226e1e45ef..54153f31ff 100644 --- a/console/console-src/modules/contents/attachments/AttachmentList.vue +++ b/console/console-src/modules/contents/attachments/AttachmentList.vue @@ -30,7 +30,7 @@ import type { Attachment, Group } from "@halo-dev/api-client"; import { useFetchAttachmentPolicy } from "./composables/use-attachment-policy"; import { useAttachmentControl } from "./composables/use-attachment"; import { apiClient } from "@/utils/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { isImage } from "@/utils/image"; import { useRouteQuery } from "@vueuse/router"; import { useFetchAttachmentGroup } from "./composables/use-attachment-group"; diff --git a/console/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue b/console/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue index 883dbce0b8..e41b10095c 100644 --- a/console/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue +++ b/console/console-src/modules/contents/attachments/components/AttachmentGroupEditingModal.vue @@ -3,7 +3,7 @@ import { Toast, VButton, VModal, VSpace } from "@halo-dev/components"; import SubmitButton from "@/components/button/SubmitButton.vue"; import type { Group } from "@halo-dev/api-client"; import { computed, ref, watch } from "vue"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; diff --git a/console/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue b/console/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue index 9cf73da936..30ef48324e 100644 --- a/console/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue +++ b/console/console-src/modules/contents/attachments/components/AttachmentPolicyEditingModal.vue @@ -2,7 +2,7 @@ import { Toast, VButton, VModal, VSpace } from "@halo-dev/components"; import SubmitButton from "@/components/button/SubmitButton.vue"; import type { Policy, PolicyTemplate } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { computed, ref, toRaw, watch, watchEffect } from "vue"; import { useSettingForm } from "@console/composables/use-setting-form"; import { apiClient } from "@/utils/api-client"; diff --git a/console/console-src/modules/contents/comments/components/CommentListItem.vue b/console/console-src/modules/contents/comments/components/CommentListItem.vue index 7dfddd3a51..073b03a6f3 100644 --- a/console/console-src/modules/contents/comments/components/CommentListItem.vue +++ b/console/console-src/modules/contents/comments/components/CommentListItem.vue @@ -27,7 +27,7 @@ import { formatDatetime } from "@/utils/date"; import { computed, provide, ref, onMounted, type Ref } from "vue"; import ReplyListItem from "./ReplyListItem.vue"; import { apiClient } from "@/utils/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { usePermission } from "@/utils/permission"; import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/contents/comments/components/ReplyCreationModal.vue b/console/console-src/modules/contents/comments/components/ReplyCreationModal.vue index a5ab859448..d20b84befa 100644 --- a/console/console-src/modules/contents/comments/components/ReplyCreationModal.vue +++ b/console/console-src/modules/contents/comments/components/ReplyCreationModal.vue @@ -18,7 +18,7 @@ import { Picker } from "emoji-mart"; import i18n from "@emoji-mart/data/i18n/zh.json"; import { computed, nextTick, ref, watch, watchEffect } from "vue"; import { reset } from "@formkit/core"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { setFocus } from "@/formkit/utils/focus"; import { apiClient } from "@/utils/api-client"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/contents/comments/components/ReplyListItem.vue b/console/console-src/modules/contents/comments/components/ReplyListItem.vue index 03d08ff086..d7ace17555 100644 --- a/console/console-src/modules/contents/comments/components/ReplyListItem.vue +++ b/console/console-src/modules/contents/comments/components/ReplyListItem.vue @@ -14,7 +14,7 @@ import type { ListedReply } from "@halo-dev/api-client"; import { formatDatetime } from "@/utils/date"; import { apiClient } from "@/utils/api-client"; import { computed, inject, type Ref } from "vue"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { useI18n } from "vue-i18n"; import { useQueryClient } from "@tanstack/vue-query"; diff --git a/console/console-src/modules/contents/pages/DeletedSinglePageList.vue b/console/console-src/modules/contents/pages/DeletedSinglePageList.vue index ec37d3d16b..b7490a6617 100644 --- a/console/console-src/modules/contents/pages/DeletedSinglePageList.vue +++ b/console/console-src/modules/contents/pages/DeletedSinglePageList.vue @@ -21,7 +21,7 @@ import { ref, watch } from "vue"; import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client"; import { apiClient } from "@/utils/api-client"; import { formatDatetime } from "@/utils/date"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { usePermission } from "@/utils/permission"; import { useQuery } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/contents/pages/SinglePageEditor.vue b/console/console-src/modules/contents/pages/SinglePageEditor.vue index 1fd356fc8e..1d2d7cccbf 100644 --- a/console/console-src/modules/contents/pages/SinglePageEditor.vue +++ b/console/console-src/modules/contents/pages/SinglePageEditor.vue @@ -24,7 +24,7 @@ import { } from "vue"; import { apiClient } from "@/utils/api-client"; import { useRouteQuery } from "@vueuse/router"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { useRouter } from "vue-router"; import { randomUUID } from "@/utils/id"; import { useContentCache } from "@console/composables/use-content-cache"; @@ -40,10 +40,12 @@ import { useAutoSaveContent } from "@console/composables/use-auto-save-content"; import { useContentSnapshot } from "@console/composables/use-content-snapshot"; import { useSaveKeybinding } from "@console/composables/use-save-keybinding"; import { useSessionKeepAlive } from "@/composables/use-session-keep-alive"; +import { usePermission } from "@/utils/permission"; const router = useRouter(); const { t } = useI18n(); const { mutateAsync: singlePageUpdateMutate } = usePageUpdateMutate(); +const { currentUserHasPermission } = usePermission(); // Editor providers const { editorProviders } = useEditorExtensionPoints(); @@ -379,6 +381,9 @@ useSessionKeepAlive(); // Upload image async function handleUploadImage(file: File) { + if (!currentUserHasPermission(["uc:attachments:manage"])) { + return; + } if (!isUpdateMode.value) { await handleSave(); } diff --git a/console/console-src/modules/contents/pages/components/SinglePageListItem.vue b/console/console-src/modules/contents/pages/components/SinglePageListItem.vue index 60be79293d..a15f82879b 100644 --- a/console/console-src/modules/contents/pages/components/SinglePageListItem.vue +++ b/console/console-src/modules/contents/pages/components/SinglePageListItem.vue @@ -17,7 +17,7 @@ import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client"; import { apiClient } from "@/utils/api-client"; import { formatDatetime } from "@/utils/date"; import { RouterLink } from "vue-router"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { usePermission } from "@/utils/permission"; import { singlePageLabels } from "@/constants/labels"; import { useMutation, useQueryClient } from "@tanstack/vue-query"; diff --git a/console/console-src/modules/contents/pages/components/SinglePageSettingModal.vue b/console/console-src/modules/contents/pages/components/SinglePageSettingModal.vue index a1f2bd0404..972162b6fd 100644 --- a/console/console-src/modules/contents/pages/components/SinglePageSettingModal.vue +++ b/console/console-src/modules/contents/pages/components/SinglePageSettingModal.vue @@ -8,7 +8,7 @@ import { } from "@halo-dev/components"; import { computed, nextTick, ref, watchEffect } from "vue"; import type { SinglePage } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme"; import { singlePageLabels } from "@/constants/labels"; diff --git a/console/console-src/modules/contents/posts/DeletedPostList.vue b/console/console-src/modules/contents/posts/DeletedPostList.vue index 6837127c66..3df872d771 100644 --- a/console/console-src/modules/contents/posts/DeletedPostList.vue +++ b/console/console-src/modules/contents/posts/DeletedPostList.vue @@ -23,7 +23,7 @@ import type { ListedPost, Post } from "@halo-dev/api-client"; import { apiClient } from "@/utils/api-client"; import { formatDatetime } from "@/utils/date"; import { usePermission } from "@/utils/permission"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { useQuery } from "@tanstack/vue-query"; import { useI18n } from "vue-i18n"; import ContributorList from "../_components/ContributorList.vue"; diff --git a/console/console-src/modules/contents/posts/PostEditor.vue b/console/console-src/modules/contents/posts/PostEditor.vue index dbf9e4824a..8c970d53c4 100644 --- a/console/console-src/modules/contents/posts/PostEditor.vue +++ b/console/console-src/modules/contents/posts/PostEditor.vue @@ -22,7 +22,7 @@ import { toRef, type ComputedRef, } from "vue"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import { useRouteQuery } from "@vueuse/router"; import { useRouter } from "vue-router"; @@ -40,10 +40,12 @@ import { useAutoSaveContent } from "@console/composables/use-auto-save-content"; import { useContentSnapshot } from "@console/composables/use-content-snapshot"; import { useSaveKeybinding } from "@console/composables/use-save-keybinding"; import { useSessionKeepAlive } from "@/composables/use-session-keep-alive"; +import { usePermission } from "@/utils/permission"; const router = useRouter(); const { t } = useI18n(); const { mutateAsync: postUpdateMutate } = usePostUpdateMutate(); +const { currentUserHasPermission } = usePermission(); // Editor providers const { editorProviders } = useEditorExtensionPoints(); @@ -404,6 +406,10 @@ useSessionKeepAlive(); // Upload image async function handleUploadImage(file: File) { + if (!currentUserHasPermission(["uc:attachments:manage"])) { + return; + } + if (!isUpdateMode.value) { await handleSave(); } diff --git a/console/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue b/console/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue index cec6a5167f..206422443d 100644 --- a/console/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue +++ b/console/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue @@ -17,7 +17,7 @@ import SubmitButton from "@/components/button/SubmitButton.vue"; import type { Category } from "@halo-dev/api-client"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme"; diff --git a/console/console-src/modules/contents/posts/categories/utils/index.ts b/console/console-src/modules/contents/posts/categories/utils/index.ts index 0169e97e4c..dc5863d1f2 100644 --- a/console/console-src/modules/contents/posts/categories/utils/index.ts +++ b/console/console-src/modules/contents/posts/categories/utils/index.ts @@ -1,5 +1,5 @@ import type { Category, CategorySpec } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; export interface CategoryTreeSpec extends Omit { children: CategoryTree[]; diff --git a/console/console-src/modules/contents/posts/components/PostSettingModal.vue b/console/console-src/modules/contents/posts/components/PostSettingModal.vue index 1af6215cb6..e64985e1be 100644 --- a/console/console-src/modules/contents/posts/components/PostSettingModal.vue +++ b/console/console-src/modules/contents/posts/components/PostSettingModal.vue @@ -8,7 +8,7 @@ import { } from "@halo-dev/components"; import { computed, nextTick, ref, watchEffect } from "vue"; import type { Post } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme"; import { postLabels } from "@/constants/labels"; diff --git a/console/console-src/modules/contents/posts/tags/components/TagEditingModal.vue b/console/console-src/modules/contents/posts/tags/components/TagEditingModal.vue index d406a5ee55..34dac32417 100644 --- a/console/console-src/modules/contents/posts/tags/components/TagEditingModal.vue +++ b/console/console-src/modules/contents/posts/tags/components/TagEditingModal.vue @@ -19,7 +19,7 @@ import SubmitButton from "@/components/button/SubmitButton.vue"; import type { Tag } from "@halo-dev/api-client"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; import AnnotationsForm from "@/components/form/AnnotationsForm.vue"; diff --git a/console/console-src/modules/dashboard/Dashboard.vue b/console/console-src/modules/dashboard/Dashboard.vue index 727b483691..dd4b615bd2 100644 --- a/console/console-src/modules/dashboard/Dashboard.vue +++ b/console/console-src/modules/dashboard/Dashboard.vue @@ -123,7 +123,7 @@ import { } from "@halo-dev/components"; import { onMounted, provide, ref, type Ref } from "vue"; import { useStorage } from "@vueuse/core"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import type { DashboardStats } from "@halo-dev/api-client"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/interface/menus/Menus.vue b/console/console-src/modules/interface/menus/Menus.vue index f44df6d488..002e173ca5 100644 --- a/console/console-src/modules/interface/menus/Menus.vue +++ b/console/console-src/modules/interface/menus/Menus.vue @@ -17,7 +17,7 @@ import MenuList from "./components/MenuList.vue"; import { computed, ref } from "vue"; import { apiClient } from "@/utils/api-client"; import type { Menu, MenuItem } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import type { MenuTreeItem } from "./utils"; import { buildMenuItemsTree, diff --git a/console/console-src/modules/interface/menus/components/MenuEditingModal.vue b/console/console-src/modules/interface/menus/components/MenuEditingModal.vue index 75e7710810..d9ef0a45f7 100644 --- a/console/console-src/modules/interface/menus/components/MenuEditingModal.vue +++ b/console/console-src/modules/interface/menus/components/MenuEditingModal.vue @@ -5,7 +5,7 @@ import type { Menu } from "@halo-dev/api-client"; import { computed, ref, watch } from "vue"; import { apiClient } from "@/utils/api-client"; import { reset } from "@formkit/core"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { setFocus } from "@/formkit/utils/focus"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/interface/menus/components/MenuItemEditingModal.vue b/console/console-src/modules/interface/menus/components/MenuItemEditingModal.vue index 64729efe0b..7d6bf2486c 100644 --- a/console/console-src/modules/interface/menus/components/MenuItemEditingModal.vue +++ b/console/console-src/modules/interface/menus/components/MenuItemEditingModal.vue @@ -5,7 +5,7 @@ import { computed, nextTick, ref, watch } from "vue"; import type { Menu, MenuItem, Ref } from "@halo-dev/api-client"; import { apiClient } from "@/utils/api-client"; import { reset } from "@formkit/core"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { setFocus } from "@/formkit/utils/focus"; import AnnotationsForm from "@/components/form/AnnotationsForm.vue"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/interface/menus/utils/index.ts b/console/console-src/modules/interface/menus/utils/index.ts index 1d59e9681f..01b963dca3 100644 --- a/console/console-src/modules/interface/menus/utils/index.ts +++ b/console/console-src/modules/interface/menus/utils/index.ts @@ -1,5 +1,5 @@ import type { MenuItem, MenuItemSpec } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; export interface MenuTreeItemSpec extends Omit { children: MenuTreeItem[]; diff --git a/console/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue b/console/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue index 308d449e3e..11b21c3081 100644 --- a/console/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue +++ b/console/console-src/modules/interface/themes/components/list-tabs/LocalUpload.vue @@ -30,7 +30,7 @@ const onUploaded = () => { activeTabId.value = "installed"; }; -const onError = (file: UppyFile, response: ErrorResponse) => { +const onError = (file: UppyFile, response: ErrorResponse) => { const body = response.body as ThemeInstallationErrorResponse; if (body.type === THEME_ALREADY_EXISTS_TYPE) { diff --git a/console/console-src/modules/interface/themes/layouts/ThemeLayout.vue b/console/console-src/modules/interface/themes/layouts/ThemeLayout.vue index fd5c1bc290..d4417bfdb0 100644 --- a/console/console-src/modules/interface/themes/layouts/ThemeLayout.vue +++ b/console/console-src/modules/interface/themes/layouts/ThemeLayout.vue @@ -5,7 +5,7 @@ import { provide, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; // hooks import { useThemeLifeCycle } from "../composables/use-theme"; diff --git a/console/console-src/modules/system/actuator/Actuator.vue b/console/console-src/modules/system/actuator/Actuator.vue index 28415cedc8..4f0b898720 100644 --- a/console/console-src/modules/system/actuator/Actuator.vue +++ b/console/console-src/modules/system/actuator/Actuator.vue @@ -369,10 +369,22 @@ const handleDownloadLogfile = () => { [info.java.runtime.name, info.java.runtime.version].join(' / ') " /> - + + + {{ [info.database.name, info.database.version].join(" / ") }} + + + + + {{ info.os.name }} {{ info.os.version }} / {{ info.os.arch }} diff --git a/console/console-src/modules/system/plugins/PluginDetail.vue b/console/console-src/modules/system/plugins/PluginDetail.vue index 5d18cb0e57..459ac02c24 100644 --- a/console/console-src/modules/system/plugins/PluginDetail.vue +++ b/console/console-src/modules/system/plugins/PluginDetail.vue @@ -5,7 +5,7 @@ import { useRoute } from "vue-router"; import { apiClient } from "@/utils/api-client"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; // components import { VCard, VPageHeader, VTabbar, VAvatar } from "@halo-dev/components"; diff --git a/console/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue b/console/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue index 9963bb9b84..d1ab245e1e 100644 --- a/console/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue +++ b/console/console-src/modules/system/plugins/components/installation-tabs/LocalUpload.vue @@ -46,7 +46,7 @@ const onUploaded = async (response: SuccessResponse) => { handleShowActiveModalAfterInstall(response.body as Plugin); }; -const onError = (file: UppyFile, response: ErrorResponse) => { +const onError = (file: UppyFile, response: ErrorResponse) => { const body = response.body as PluginInstallationErrorResponse; if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) { diff --git a/console/console-src/modules/system/plugins/composables/use-plugin.ts b/console/console-src/modules/system/plugins/composables/use-plugin.ts index 6bb856d096..5ab42f72cb 100644 --- a/console/console-src/modules/system/plugins/composables/use-plugin.ts +++ b/console/console-src/modules/system/plugins/composables/use-plugin.ts @@ -1,7 +1,7 @@ import type { ComputedRef, Ref } from "vue"; import { computed } from "vue"; import { type Plugin } from "@halo-dev/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { apiClient } from "@/utils/api-client"; import { Dialog, Toast } from "@halo-dev/components"; import { useI18n } from "vue-i18n"; diff --git a/console/console-src/modules/system/roles/components/RoleEditingModal.vue b/console/console-src/modules/system/roles/components/RoleEditingModal.vue index c093e5754c..421ffb0259 100644 --- a/console/console-src/modules/system/roles/components/RoleEditingModal.vue +++ b/console/console-src/modules/system/roles/components/RoleEditingModal.vue @@ -5,7 +5,7 @@ import { computed, watch } from "vue"; import { rbacAnnotations } from "@/constants/annotations"; import type { Role } from "@halo-dev/api-client"; import { useRoleForm, useRoleTemplateSelection } from "@/composables/use-role"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; import { pluginLabels, roleLabels } from "@/constants/labels"; @@ -161,7 +161,27 @@ const handleResetForm = () => { ] " type="text" - label="登录之后默认跳转位置" + :label="$t('core.role.editing_modal.fields.redirect_on_login')" + > +
diff --git a/console/console-src/modules/system/settings/SystemSettings.vue b/console/console-src/modules/system/settings/SystemSettings.vue index 706872de6c..04f5c6b0f7 100644 --- a/console/console-src/modules/system/settings/SystemSettings.vue +++ b/console/console-src/modules/system/settings/SystemSettings.vue @@ -19,8 +19,10 @@ import { markRaw } from "vue"; import SettingTab from "./tabs/Setting.vue"; import { useRouteQuery } from "@vueuse/router"; import NotificationsTab from "./tabs/Notifications.vue"; +import { usePermission } from "@/utils/permission"; const { t } = useI18n(); +const { currentUserHasPermission } = usePermission(); interface Tab { id: string; @@ -63,11 +65,13 @@ const { data: setting } = useQuery({ } // TODO: use integrations center to refactor this - tabs.value.push({ - id: "notification", - label: "通知设置", - component: markRaw(NotificationsTab), - }); + if (currentUserHasPermission(["system:notifier:configuration"])) { + tabs.value.push({ + id: "notification", + label: "通知设置", + component: markRaw(NotificationsTab), + }); + } } }, }); diff --git a/console/console-src/modules/system/users/Login.vue b/console/console-src/modules/system/users/Login.vue index f10eca8fb0..5c85a21103 100644 --- a/console/console-src/modules/system/users/Login.vue +++ b/console/console-src/modules/system/users/Login.vue @@ -52,27 +52,32 @@ watch( -
- - {{ - isLoginType - ? $t("core.login.operations.signup.label") - : $t("core.login.operations.return_login.label") - }} - - +
+ + {{ + isLoginType + ? $t("core.login.operations.signup.label") + : $t("core.login.operations.return_login.label") + }}, + + + {{ + isLoginType + ? $t("core.login.operations.signup.button") + : $t("core.login.operations.return_login.button") + }} + +
+ - {{ - isLoginType - ? $t("core.login.operations.signup.button") - : $t("core.login.operations.return_login.button") - }} -
+ {{ $t("core.login.operations.reset_password.button") }} +
- 个人中心 + {{ $t("core.user.detail.actions.profile.title") }} diff --git a/console/console-src/modules/system/users/UserList.vue b/console/console-src/modules/system/users/UserList.vue index ef98a1bdff..8bcda7a79b 100644 --- a/console/console-src/modules/system/users/UserList.vue +++ b/console/console-src/modules/system/users/UserList.vue @@ -48,7 +48,7 @@ const grantPermissionModal = ref(false); const selectedUserNames = ref([]); const selectedUser = ref(); -const keyword = ref(""); +const keyword = useRouteQuery("keyword", ""); const userStore = useUserStore(); @@ -57,8 +57,14 @@ const DELETEDUSER_NAME = "ghost"; // Filters const { roles } = useFetchRole(); -const selectedRoleValue = ref(); -const selectedSortValue = ref(); +const page = useRouteQuery("page", 1, { + transform: Number, +}); +const size = useRouteQuery("size", 20, { + transform: Number, +}); +const selectedRoleValue = useRouteQuery("role"); +const selectedSortValue = useRouteQuery("sort"); function handleClearFilters() { selectedRoleValue.value = undefined; @@ -76,8 +82,6 @@ watch( } ); -const page = ref(1); -const size = ref(20); const total = ref(0); const { diff --git a/console/console-src/modules/system/users/components/UserCreationModal.vue b/console/console-src/modules/system/users/components/UserCreationModal.vue index 1884c45786..c0207465aa 100644 --- a/console/console-src/modules/system/users/components/UserCreationModal.vue +++ b/console/console-src/modules/system/users/components/UserCreationModal.vue @@ -9,7 +9,7 @@ import { Toast, VButton, VModal, VSpace } from "@halo-dev/components"; import SubmitButton from "@/components/button/SubmitButton.vue"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; // hooks diff --git a/console/console-src/modules/system/users/components/UserEditingModal.vue b/console/console-src/modules/system/users/components/UserEditingModal.vue index 79ba52cbf5..0727a71ae6 100644 --- a/console/console-src/modules/system/users/components/UserEditingModal.vue +++ b/console/console-src/modules/system/users/components/UserEditingModal.vue @@ -9,7 +9,7 @@ import { Toast, VButton, VModal, VSpace } from "@halo-dev/components"; import SubmitButton from "@/components/button/SubmitButton.vue"; // libs -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; // hooks diff --git a/console/console-src/modules/system/users/components/UserPasswordChangeModal.vue b/console/console-src/modules/system/users/components/UserPasswordChangeModal.vue index 344581ce56..daeb188f05 100644 --- a/console/console-src/modules/system/users/components/UserPasswordChangeModal.vue +++ b/console/console-src/modules/system/users/components/UserPasswordChangeModal.vue @@ -4,7 +4,7 @@ import SubmitButton from "@/components/button/SubmitButton.vue"; import { ref, watch } from "vue"; import type { User } from "@halo-dev/api-client"; import { apiClient } from "@/utils/api-client"; -import cloneDeep from "lodash.clonedeep"; +import { cloneDeep } from "lodash-es"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; diff --git a/console/console-src/modules/system/users/widgets/NotificationWidget.vue b/console/console-src/modules/system/users/widgets/NotificationWidget.vue index b9239e58a6..14d2880fce 100644 --- a/console/console-src/modules/system/users/widgets/NotificationWidget.vue +++ b/console/console-src/modules/system/users/widgets/NotificationWidget.vue @@ -44,7 +44,7 @@ function handleRouteToNotification(notification: Notification) {