3.0.0原版

This commit is contained in:
anian
2026-03-16 00:03:35 +08:00
commit ac569b2a57
105 changed files with 14437 additions and 0 deletions
@@ -0,0 +1,43 @@
package run.halo.comment.widget;
import java.util.Properties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.PropertyPlaceholderHelper;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.PluginContext;
import run.halo.app.theme.dialect.TemplateHeadProcessor;
@Component
@RequiredArgsConstructor
public class CommentWidgetHeadProcessor implements TemplateHeadProcessor {
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper("${", "}");
private final PluginContext pluginContext;
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
final IModelFactory modelFactory = context.getModelFactory();
model.add(modelFactory.createText(commentWidgetScript()));
return Mono.empty();
}
private String commentWidgetScript() {
final Properties properties = new Properties();
properties.setProperty("version", pluginContext.getVersion());
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
<!-- plugin-comment-widget start -->
<link rel="modulepreload" href="/plugins/PluginCommentWidget/assets/static/comment-widget.js?version=${version}">
<link rel="stylesheet" href="/plugins/PluginCommentWidget/assets/static/index.css?version=${version}" />
<!-- plugin-comment-widget end -->
""", properties);
}
}
@@ -0,0 +1,16 @@
package run.halo.comment.widget;
import org.springframework.stereotype.Component;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
/**
* @author ryanwang
* @since 2.0.0
*/
@Component
public class CommentWidgetPlugin extends BasePlugin {
public CommentWidgetPlugin(PluginContext pluginContext) {
super(pluginContext);
}
}
@@ -0,0 +1,61 @@
package run.halo.comment.widget;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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.ConfigMap;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.PluginContext;
@Component
@RequiredArgsConstructor
public class ConfigEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final PluginContext context;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public RouterFunction<ServerResponse> endpoint() {
return RouterFunctions.route()
.GET("config", this::getConfig)
.build();
}
private Mono<ServerResponse> getConfig(ServerRequest request) {
return client.fetch(ConfigMap.class, context.getConfigMapName())
.flatMap(configMap -> {
Map<String, String> data = configMap.getData();
ObjectNode rootNode = objectMapper.createObjectNode();
data.forEach((key, value) -> {
try {
JsonNode jsonNode = objectMapper.readTree(value);
rootNode.set(key, jsonNode);
} catch (Exception e) {
rootNode.put(key, value);
}
});
return ServerResponse.ok().bodyValue(rootNode);
});
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.commentwidget.halo.run/v1alpha1");
}
}
@@ -0,0 +1,95 @@
package run.halo.comment.widget;
import java.util.Properties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.PropertyPlaceholderHelper;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IAttribute;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.processor.element.IElementTagStructureHandler;
import run.halo.app.plugin.PluginContext;
import run.halo.app.theme.dialect.CommentWidget;
/**
* A default implementation of {@link CommentWidget}.
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultCommentWidget implements CommentWidget {
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper("${", "}");
private final PluginContext pluginContext;
@Override
public void render(ITemplateContext context,
IProcessableElementTag tag,
IElementTagStructureHandler structureHandler) {
IAttribute groupAttribute = tag.getAttribute("group");
IAttribute kindAttribute = tag.getAttribute("kind");
IAttribute nameAttribute = tag.getAttribute("name");
structureHandler.replaceWith(commentHtml(groupAttribute, kindAttribute, nameAttribute),
false);
}
private String commentHtml(IAttribute groupAttribute, IAttribute kindAttribute,
IAttribute nameAttribute) {
if (kindAttribute == null || StringUtils.isBlank(kindAttribute.getValue())) {
log.warn("Comment widget tag attributes 'kind' is missing.");
return "<p style=\"color:red\">Comment widget attributes 'kind' is required but missing found.</p>";
}
if (nameAttribute == null || StringUtils.isBlank(nameAttribute.getValue())) {
log.warn("Comment widget tag attributes 'name' is missing.");
return "<p style=\"color:red\">Comment widget attributes 'name' is required but missing found.</p>";
}
String group = getGroup(groupAttribute);
final Properties properties = new Properties();
properties.setProperty("version", pluginContext.getVersion());
properties.setProperty("group", group);
properties.setProperty("kind", kindAttribute.getValue());
properties.setProperty("name", nameAttribute.getValue());
properties.setProperty("domId", domIdFrom(group, kindAttribute.getValue(), nameAttribute.getValue()));
properties.setProperty("version", pluginContext.getVersion());
// placeholderHelper only support string, so we need to convert boolean to string
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
<div id="${domId}"></div>
<script type="module" data-pjax>
import { init } from "/plugins/PluginCommentWidget/assets/static/comment-widget.js?version=${version}";
init(
"#${domId}",
{
group: "${group}",
kind: "${kind}",
name: "${name}",
}
);
</script>
""", properties);
}
private String domIdFrom(String group, String kind, String name) {
Assert.notNull(name, "The name must not be null.");
Assert.notNull(kind, "The kind must not be null.");
String groupKindNameAsDomId = String.join("-", group, kind, name);
return "comment-" + groupKindNameAsDomId.replaceAll("[^\\-_a-zA-Z0-9\\s]", "-")
.replaceAll("(-)+", "-");
}
private String getGroup(IAttribute groupAttribute) {
return groupAttribute.getValue() == null ? ""
: StringUtils.defaultString(groupAttribute.getValue());
}
}
@@ -0,0 +1,88 @@
package run.halo.comment.widget;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.comment.widget.captcha.CaptchaType;
public interface SettingConfigGetter {
/**
* Never {@link Mono#empty()}.
*/
Mono<BasicConfig> getBasicConfig();
/**
* Never {@link Mono#empty()}.
*/
Mono<AvatarConfig> getAvatarConfig();
/**
* Never {@link Mono#empty()}.
*/
Mono<SecurityConfig> getSecurityConfig();
@Data
@Accessors(chain = true)
class SecurityConfig {
public static final String GROUP = "security";
@Getter(onMethod_ = @NonNull)
private CaptchaConfig captcha = CaptchaConfig.empty();
public SecurityConfig setCaptcha(CaptchaConfig captcha) {
this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha);
return this;
}
public static SecurityConfig empty() {
return new SecurityConfig()
.setCaptcha(CaptchaConfig.empty());
}
}
@Data
@Accessors(chain = true)
class CaptchaConfig {
private boolean anonymousCommentCaptcha;
@Getter(onMethod_ = @NonNull)
private CaptchaType type = CaptchaType.ALPHANUMERIC;
private boolean ignoreCase = true;
private int captchaLength = 4;
private int arithmeticRange = 90;
public CaptchaConfig setType(CaptchaType type) {
this.type = (type == null ? CaptchaType.ALPHANUMERIC : type);
return this;
}
public static CaptchaConfig empty() {
return new CaptchaConfig();
}
}
@Data
class BasicConfig {
public static final String GROUP = "basic";
private int size;
private int replySize;
private boolean withReplies;
private int withReplySize;
}
@Data
class AvatarConfig {
public static final String GROUP = "avatar";
private boolean enable;
private String provider;
private String providerMirror;
private String policy;
}
}
@@ -0,0 +1,30 @@
package run.halo.comment.widget;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.ReactiveSettingFetcher;
@Component
@RequiredArgsConstructor
public class SettingConfigGetterImpl implements SettingConfigGetter {
private final ReactiveSettingFetcher settingFetcher;
@Override
public Mono<BasicConfig> getBasicConfig() {
return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class)
.defaultIfEmpty(new BasicConfig());
}
@Override
public Mono<AvatarConfig> getAvatarConfig() {
return settingFetcher.fetch(AvatarConfig.GROUP, AvatarConfig.class)
.defaultIfEmpty(new AvatarConfig());
}
@Override
public Mono<SecurityConfig> getSecurityConfig() {
return settingFetcher.fetch(SecurityConfig.GROUP, SecurityConfig.class)
.defaultIfEmpty(SecurityConfig.empty());
}
}
@@ -0,0 +1,19 @@
package run.halo.comment.widget.captcha;
import java.time.Duration;
import org.springframework.http.HttpCookie;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebExchange;
public interface CaptchaCookieResolver {
@Nullable
HttpCookie resolveCookie(ServerWebExchange exchange);
void setCookie(ServerWebExchange exchange, String value);
void expireCookie(ServerWebExchange exchange);
String getCookieName();
Duration getCookieMaxAge();
}
@@ -0,0 +1,49 @@
package run.halo.comment.widget.captcha;
import java.time.Duration;
import lombok.Getter;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
@Getter
@Component
public class CaptchaCookieResolverImpl implements CaptchaCookieResolver {
public static final String CAPTCHA_COOKIE_KEY = "comment-widget-captcha";
private final String cookieName = CAPTCHA_COOKIE_KEY;
private final Duration cookieMaxAge = Duration.ofHours(1);
@Override
@Nullable
public HttpCookie resolveCookie(ServerWebExchange exchange) {
return exchange.getRequest().getCookies().getFirst(getCookieName());
}
@Override
public void setCookie(ServerWebExchange exchange, String value) {
Assert.notNull(value, "'value' is required");
exchange.getResponse().getCookies()
.set(getCookieName(), initCookie(exchange, value).build());
}
@Override
public void expireCookie(ServerWebExchange exchange) {
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
exchange.getResponse().getCookies().set(this.cookieName, cookie);
}
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
String value) {
return ResponseCookie.from(this.cookieName, value)
.path(exchange.getRequest().getPath().contextPath().value() + "/")
.maxAge(getCookieMaxAge())
.httpOnly(true)
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
.sameSite("Lax");
}
}
@@ -0,0 +1,39 @@
package run.halo.comment.widget.captcha;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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.comment.widget.SettingConfigGetter;
@Component
@RequiredArgsConstructor
public class CaptchaEndpoint implements CustomEndpoint {
private final CaptchaManager captchaManager;
private final SettingConfigGetter settingConfigGetter;
@Override
public RouterFunction<ServerResponse> endpoint() {
return RouterFunctions.route()
.GET("captcha/-/generate", this::generateCaptcha)
.build();
}
private Mono<ServerResponse> generateCaptcha(ServerRequest request) {
return settingConfigGetter.getSecurityConfig()
.map(SettingConfigGetter.SecurityConfig::getCaptcha)
.flatMap(captchaConfig -> captchaManager.generate(request.exchange(), captchaConfig))
.flatMap(captcha -> ServerResponse.ok().bodyValue(captcha.imageBase64()));
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.commentwidget.halo.run/v1alpha1");
}
}
@@ -0,0 +1,145 @@
package run.halo.comment.widget.captcha;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.Random;
import java.util.function.Function;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class CaptchaGenerator {
private static final String CHAR_STRING = "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789";
private static final int WIDTH = 120;
private static final int HEIGHT = 36;
private static final int CHAR_LENGTH = 4;
private static final Font customFont;
static {
customFont = loadArialFont();
}
public static Captcha generateMathCaptcha(int arithmeticRange) {
return generateCaptchaImage((g2d) -> drawMathCaptchaText(g2d, arithmeticRange));
}
public static Captcha generateSimpleCaptcha(int captchaLength) {
return generateCaptchaImage((g2d) -> drawSimpleText(g2d, captchaLength));
}
private static Captcha generateCaptchaImage(Function<Graphics2D, String> drawCaptchaTextFunc) {
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = bufferedImage.createGraphics();
// paint white background
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, WIDTH, HEIGHT);
g2d.setFont(customFont);
var captchaText = drawCaptchaTextFunc.apply(g2d);
// add some noise
Random random = new Random();
for (int i = 0; i < 10; i++) {
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
int x1 = random.nextInt(WIDTH);
int y1 = random.nextInt(HEIGHT);
int x2 = random.nextInt(WIDTH);
int y2 = random.nextInt(HEIGHT);
g2d.drawLine(x1, y1, x2, y2);
}
g2d.dispose();
return new Captcha(captchaText, bufferedImage);
}
private static String drawMathCaptchaText(Graphics2D g2d, int arithmeticRange) {
Random random = new Random();
int num1 = random.nextInt(arithmeticRange) + 1;
int num2 = random.nextInt(arithmeticRange) + 1;
char operator = getRandomOperator();
int result;
String mathText = switch (operator) {
case '+' -> {
result = num1 + num2;
yield num1 + " + " + num2 + " = ?";
}
case '-' -> {
result = num1 - num2;
yield num1 + " - " + num2 + " = ?";
}
case '*' -> {
result = num1 * num2;
yield num1 + " * " + num2 + " = ?";
}
default -> throw new IllegalStateException("Unexpected value: " + operator);
};
g2d.setColor(Color.BLACK);
g2d.drawString(mathText, 20, 30);
return String.valueOf(result);
}
public record Captcha(String code, BufferedImage image) {
}
private static String drawSimpleText(Graphics2D g2d, int captchaLength) {
var captchaText = generateRandomText(captchaLength);
Random random = new Random();
int charSpacing = WIDTH / (captchaLength + 1);
for (int i = 0; i < captchaText.length(); i++) {
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
// 动态计算每个字符的位置
int xPos = charSpacing + i * charSpacing;
g2d.drawString(String.valueOf(captchaText.charAt(i)), xPos, 30);
}
return captchaText;
}
private static Font loadArialFont() {
var fontPath = "/fonts/Arial_Bold.ttf";
try (InputStream is = CaptchaGenerator.class.getResourceAsStream(fontPath)) {
if (is == null) {
throw new RuntimeException("Cannot load font file for " + fontPath + ", please check if it exists.");
}
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.BOLD, 24);
} catch (FontFormatException | IOException e) {
log.warn("Failed to load font file for {}, fallback to default font.", fontPath);
return new Font("Serif", Font.BOLD, 24);
}
}
private static char getRandomOperator() {
char[] operators = {'+', '-', '*'};
Random random = new Random();
return operators[random.nextInt(operators.length)];
}
private static String generateRandomText(int captchaLength) {
StringBuilder sb = new StringBuilder(captchaLength);
Random random = new Random();
for (int i = 0; i < captchaLength; i++) {
sb.append(CHAR_STRING.charAt(random.nextInt(CHAR_STRING.length())));
}
return sb.toString();
}
public static String encodeToBase64(BufferedImage image) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", outputStream);
byte[] imageBytes = outputStream.toByteArray();
return Base64.getEncoder().encodeToString(imageBytes);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,16 @@
package run.halo.comment.widget.captcha;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.comment.widget.SettingConfigGetter;
public interface CaptchaManager {
Mono<Boolean> verify(String id, String captchaCode, boolean ignoreCase);
Mono<Void> invalidate(String id);
Mono<Captcha> generate(ServerWebExchange exchange, SettingConfigGetter.CaptchaConfig captchaConfig);
record Captcha(String id, String code, String imageBase64) {
}
}
@@ -0,0 +1,65 @@
package run.halo.comment.widget.captcha;
import java.awt.image.BufferedImage;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.comment.widget.SettingConfigGetter;
@Component
@RequiredArgsConstructor
public class CaptchaManagerImpl implements CaptchaManager {
public static final long CODE_EXPIRATION_MINUTES = 1;
private final Cache<String, Captcha> captchaCache =
CacheBuilder.newBuilder()
.expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
.maximumSize(100)
.build();
private final CaptchaCookieResolver captchaCookieResolver;
@Override
public Mono<Boolean> verify(String key, String captchaCode, boolean ignoreCase) {
return Mono.justOrEmpty(captchaCache.getIfPresent(key))
.filter(captcha -> ignoreCase ? captcha.code().equalsIgnoreCase(captchaCode) : captcha.code().equals(captchaCode))
.hasElement();
}
@Override
public Mono<Void> invalidate(String id) {
captchaCache.invalidate(id);
return Mono.empty();
}
@Override
public Mono<Captcha> generate(ServerWebExchange exchange, SettingConfigGetter.CaptchaConfig captchaConfig) {
return doGenerate(captchaConfig)
.doOnNext(captcha -> captchaCookieResolver.setCookie(exchange, captcha.id()));
}
private Mono<Captcha> doGenerate(SettingConfigGetter.CaptchaConfig captchaConfig) {
return Mono.fromSupplier(() -> {
var captcha = switch (captchaConfig.getType()) {
case ALPHANUMERIC -> CaptchaGenerator.generateSimpleCaptcha(captchaConfig.getCaptchaLength());
case ARITHMETIC -> CaptchaGenerator.generateMathCaptcha(captchaConfig.getArithmeticRange());
};
var imageBase64 = encodeBufferedImageToDataUri(captcha.image());
var id = UUID.randomUUID().toString();
return new Captcha(id, captcha.code(), imageBase64);
})
.subscribeOn(Schedulers.boundedElastic())
.doOnNext(captcha -> captchaCache.put(captcha.id(), captcha));
}
private static String encodeBufferedImageToDataUri(BufferedImage image) {
var imageBase64 = CaptchaGenerator.encodeToBase64(image);
return "data:image/png;base64," + imageBase64;
}
}
@@ -0,0 +1,6 @@
package run.halo.comment.widget.captcha;
public enum CaptchaType {
ALPHANUMERIC,
ARITHMETIC
}
@@ -0,0 +1,154 @@
package run.halo.comment.widget.captcha;
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
import java.net.URI;
import java.util.Locale;
import java.util.Optional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.ProblemDetailJacksonMixin;
import org.springframework.lang.NonNull;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.security.AdditionalWebFilter;
import run.halo.comment.widget.SettingConfigGetter;
@Component
@RequiredArgsConstructor
public class CommentCaptchaFilter implements AdditionalWebFilter {
static final String CAPTCHA_INVALID_TYPE = "https://www.halo.run/probs/captcha-invalid";
static final String CAPTCHA_REQUIRED_TYPE = "https://www.halo.run/probs/captcha-required";
private final static String CAPTCHA_CODE_HEADER = "X-Captcha-Code";
private final static String CAPTCHA_REQUIRED_HEADER = "X-Require-Captcha";
private static final String CONTENT_TYPE = "application/problem+json";
private final ServerWebExchangeMatcher pathMatcher = createPathMatcher();
private final ObjectMapper objectMapper = createObjectMapper();
private final SettingConfigGetter settingConfigGetter;
private final CaptchaManager captchaManager;
private final CaptchaCookieResolverImpl captchaCookieResolver;
private final ServerSecurityContextRepository contextRepository;
@Override
@NonNull
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return pathMatcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.flatMap(result -> settingConfigGetter.getSecurityConfig())
.map(SettingConfigGetter.SecurityConfig::getCaptcha)
.filterWhen(captchaConfig -> isAnonymousCommenter(exchange))
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
.flatMap(captchaConfig -> {
if (!captchaConfig.isAnonymousCommentCaptcha()) {
return chain.filter(exchange);
}
return validateCaptcha(exchange, chain, captchaConfig);
});
}
private Mono<Void> sendCaptchaRequiredResponse(ServerWebExchange exchange,
SettingConfigGetter.CaptchaConfig captchaConfig,
ResponseStatusException e) {
exchange.getResponse().getHeaders().addIfAbsent(CAPTCHA_REQUIRED_HEADER, "true");
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return captchaManager.generate(exchange, captchaConfig)
.flatMap(captcha -> {
var problemDetail = toProblemDetail(e);
problemDetail.setProperty("captcha", captcha.imageBase64());
var responseData = getResponseData(problemDetail);
exchange.getResponse().getHeaders().addIfAbsent("Content-Type", CONTENT_TYPE);
return exchange.getResponse()
.writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(responseData)));
});
}
private byte[] getResponseData(ProblemDetail problemDetail) {
try {
return objectMapper.writeValueAsBytes(problemDetail);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private Mono<Void> validateCaptcha(ServerWebExchange exchange, WebFilterChain chain,
SettingConfigGetter.CaptchaConfig captchaConfig) {
var captchaCodeOpt = getCaptchaCode(exchange);
var cookie = captchaCookieResolver.resolveCookie(exchange);
if (captchaCodeOpt.isEmpty() || cookie == null) {
return sendCaptchaRequiredResponse(exchange, captchaConfig, new CaptchaCodeMissingException());
}
return captchaManager.verify(cookie.getValue(), captchaCodeOpt.get(), captchaConfig.isIgnoreCase())
.flatMap(valid -> {
if (valid) {
captchaCookieResolver.expireCookie(exchange);
return chain.filter(exchange);
}
return sendCaptchaRequiredResponse(exchange, captchaConfig, new InvalidCaptchaCodeException());
});
}
private static Optional<String> getCaptchaCode(ServerWebExchange exchange) {
var captchaCode = exchange.getRequest().getHeaders().getFirst(CAPTCHA_CODE_HEADER);
return Optional.ofNullable(captchaCode)
.filter(StringUtils::isNotBlank);
}
private OrServerWebExchangeMatcher createPathMatcher() {
var commentMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments");
var replyMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments/{name}/reply");
return new OrServerWebExchangeMatcher(commentMatcher, replyMatcher);
}
static class InvalidCaptchaCodeException extends ResponseStatusException {
public InvalidCaptchaCodeException() {
super(HttpStatus.FORBIDDEN, "验证码错误,请重新输入");
setType(URI.create(CAPTCHA_INVALID_TYPE));
}
}
static class CaptchaCodeMissingException extends ResponseStatusException {
public CaptchaCodeMissingException() {
super(HttpStatus.FORBIDDEN, "请先输入验证码");
setType(URI.create(CAPTCHA_REQUIRED_TYPE));
}
}
ProblemDetail toProblemDetail(ResponseStatusException e) {
var problemDetail = e.updateAndGetBody(null, Locale.getDefault());
problemDetail.setTitle("Captcha Verification");
return problemDetail;
}
static ObjectMapper createObjectMapper() {
return Jackson2ObjectMapperBuilder.json()
.mixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class)
.build();
}
Mono<Boolean> isAnonymousCommenter(ServerWebExchange exchange) {
return contextRepository.load(exchange)
.map(context -> AnonymousUserConst.isAnonymousUser(context.getAuthentication().getName()))
.defaultIfEmpty(true);
}
@Override
public int getOrder() {
return SecurityWebFiltersOrder.AUTHORIZATION.getOrder();
}
}
@@ -0,0 +1,9 @@
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: default-comment-widget
spec:
className: run.halo.comment.widget.DefaultCommentWidget
extensionPointName: comment-widget
displayName: "默认评论组件"
description: "Halo 默认提供的评论组件插件"
@@ -0,0 +1,8 @@
apiVersion: plugin.halo.run/v1alpha1
kind: ReverseProxy
metadata:
name: reverse-proxy-comment-widget
rules:
- path: /static/**
file:
directory: static
@@ -0,0 +1,18 @@
apiVersion: v1alpha1
kind: Role
metadata:
name: comment-widget-public-apis
labels:
halo.run/role-template: "true"
halo.run/hidden: "true"
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
annotations:
rbac.authorization.halo.run/module: "Comment Widget"
rbac.authorization.halo.run/display-name: "Comment Widget Public APIs"
rules:
- apiGroups: [ "api.commentwidget.halo.run" ]
resources: [ "captcha/generate" ]
verbs: [ "get" ]
- apiGroups: [ "api.commentwidget.halo.run" ]
resources: [ "config" ]
verbs: [ "list" ]
+149
View File
@@ -0,0 +1,149 @@
apiVersion: v1alpha1
kind: Setting
metadata:
name: plugin-comment-widget-settings
spec:
forms:
- group: basic
label: 基本设置
formSchema:
- $formkit: number
label: 评论分页条数
name: size
validation: required
value: 20
- $formkit: number
label: 回复分页条数
name: replySize
validation: required
value: 10
- $formkit: checkbox
label: 同时加载评论的回复
name: withReplies
id: withReplies
key: withReplies
value: false
- $formkit: number
label: 同时加载回复的条数
if: "$get(withReplies).value === true"
name: withReplySize
id: withReplySize
key: withReplySize
validation: required
value: 5
- $formkit: checkbox
label: 显示评论者设备信息
name: showCommenterDevice
value: false
- $formkit: checkbox
label: 支持私密评论
id: enablePrivateComment
key: enablePrivateComment
name: enablePrivateComment
help: 开启之后,评论者可选是否私密评论,私密评论仅评论者(已登录)和网站管理员(包含评论查看权限)可见,
value: false
- $formkit: checkbox
if: "$get(enablePrivateComment).value === true"
label: 显示私密评论状态
name: showPrivateCommentBadge
id: showPrivateCommentBadge
key: showPrivateCommentBadge
value: true
help: 开启后,私密评论会显示一个私密状态标识
- group: security
label: 安全设置
formSchema:
- $formkit: group
name: captcha
label: 验证码
value:
enable: false
type: ALPHANUMERIC
children:
- $formkit: checkbox
label: 匿名评论需要验证码
name: anonymousCommentCaptcha
id: anonymousCommentCaptcha
key: anonymousCommentCaptcha
value: false
- $formkit: select
label: 验证码类型
if: "$get(anonymousCommentCaptcha).value === true"
name: type
id: type
value: "ALPHANUMERIC"
options:
- label: 字母数字组合
value: "ALPHANUMERIC"
- label: 算术验证码
value: "ARITHMETIC"
- $formkit: number
if: "$get(type).value === ALPHANUMERIC"
name: captchaLength
key: captchaLength
label: 验证码长度
help: 字母数字组合验证码长度,不宜过长或过短,建议4-6位
max: 8
value: 4
validation: required
- $formkit: radio
if: "$get(type).value === ALPHANUMERIC"
name: ignoreCase
key: ignoreCase
label: 是否忽略验证码大小写验证
help: 忽略大小写验证码,建议开启
value: true
options:
- label:
value: true
- label:
value: false
- $formkit: number
if: "$get(type).value === ARITHMETIC"
name: arithmeticRange
key: arithmeticRange
label: 计算范围
help: 算术验证码计算范围
value: 90
validation: required
- group: avatar
label: 头像设置
formSchema:
- $formkit: checkbox
label: 是否启用第三方头像
name: enable
id: enable
key: enable
value: false
- $formkit: select
label: 头像服务提供者
if: "$get(enable).value === true"
name: provider
options:
- label: "Gravatar"
value: "gravatar"
value: "gravatar"
- $formkit: text
label: 头像服务镜像地址
if: "$get(enable).value === true"
name: providerMirror
value: ""
help: 使用官方源则留空, 示例:https://gravatar.com
- $formkit: select
label: 头像策略
if: "$get(enable).value === true"
name: policy
options:
- label: "仅匿名用户"
value: "anonymousUser"
- label: "所有用户"
value: "allUser"
- label: "匿名&无头像用户"
value: "noAvatarUser"
value: "anonymousUser"
- group: editor
label: 编辑器
formSchema:
- $formkit: text
name: placeholder
label: 自定义评论框占位符
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<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="0" 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(-135)" style="transform-origin: center center;"><stop stop-color="#0D6FD8"></stop><stop offset="1" stop-color="#0A89FC"></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 xmlns="http://www.w3.org/2000/svg" width="352" height="352" viewBox="0 0 24 24" x="80" y="80" alignment-baseline="middle" style="color: rgb(255, 255, 255);"><path fill="currentColor" d="M2 8.994A5.99 5.99 0 0 1 8 3h8c3.313 0 6 2.695 6 5.994V21H8c-3.313 0-6-2.695-6-5.994V8.994ZM20 19V8.994A4.004 4.004 0 0 0 16 5H8a3.99 3.99 0 0 0-4 3.994v6.012A4.004 4.004 0 0 0 8 19h12Zm-6-8h2v2h-2v-2Zm-6 0h2v2H8v-2Z"></path></svg></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+25
View File
@@ -0,0 +1,25 @@
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: PluginCommentWidget
annotations:
# Add supports for Halo App Store
# https://halo.run/store/apps/app-YXyaD
"store.halo.run/app-id": "app-YXyaD"
spec:
enabled: true
requires: ">=2.21.7"
author:
name: Halo
website: https://github.com/halo-dev
logo: logo.svg
homepage: https://www.halo.run/store/apps/app-YXyaD
repo: https://github.com/halo-dev/plugin-comment-widget
issues: https://github.com/halo-dev/plugin-comment-widget/issues
displayName: "评论组件"
description: "为用户前台提供完整的评论解决方案"
configMapName: plugin-comment-widget-configmap
settingName: plugin-comment-widget-settings
license:
- name: "GPL-3.0"
url: "https://github.com/halo-dev/plugin-comment-widget/blob/main/LICENSE"