3.0.0原版
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user