First Version Haiti Captcha

This commit is contained in:
Struchkov Mark 2022-07-10 23:25:06 +03:00
commit 96a32b2e65
13 changed files with 836 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

17
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,17 @@
image: maven:3.8.5-openjdk-17
variables:
MAVEN_OPTS: "-Dmaven.repo.local=./.m2/repository"
stages:
- deploy
deploy:
stage: deploy
only:
- /^v.*$/
except:
- branches
before_script:
- gpg --pinentry-mode loopback --passphrase $GPG_PASSPHRASE --import $GPG_PRIVATE_KEY
script:
- 'mvn --settings $MAVEN_SETTINGS -U -P ossrh,release clean deploy'

169
pom.xml Normal file
View File

@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.struchkov.haiti.utils</groupId>
<artifactId>haiti-utils-captcha</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Haiti Captcha</name>
<description>A quick and clean way to add a simple captcha to your app</description>
<url>https://github.com/haiti-projects/haiti-utils-captcha</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<issueManagement>
<system>GitHub</system>
<url>https://github.com/haiti-projects/haiti-utils-captcha/issues</url>
</issueManagement>
<properties>
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<plugin.maven.compiler.ver>3.10.1</plugin.maven.compiler.ver>
<plugin.nexus.staging.ver>1.6.13</plugin.nexus.staging.ver>
<plugin.maven.source.ver>3.2.1</plugin.maven.source.ver>
<plugin.maven.javadoc.ver>3.4.0</plugin.maven.javadoc.ver>
<plugin.maven.gpg.ver>3.0.1</plugin.maven.gpg.ver>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>${plugin.nexus.staging.ver}</version>
<extensions>true</extensions>
<configuration>
<serverId>ossrh</serverId>
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>${plugin.maven.source.ver}</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>${plugin.maven.javadoc.ver}</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>${plugin.maven.gpg.ver}</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${plugin.maven.compiler.ver}</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<scm>
<connection>scm:git:https://github.com/haiti-projects/haiti-utils-captcha.git</connection>
<url>https://github.com/haiti-projects/haiti-utils-captcha</url>
<developerConnection>scm:git:https://github.com/haiti-projects/haiti-utils-captcha.git</developerConnection>
</scm>
<distributionManagement>
<snapshotRepository>
<id>ossrh</id>
<url>https://s01.oss.sonatype.org/content/repositories/snapshots</url>
</snapshotRepository>
</distributionManagement>
<developers>
<developer>
<id>uPagge</id>
<name>Struchkov Mark</name>
<email>mark@struchkov.dev</email>
<url>https://struchkov.dev</url>
</developer>
</developers>
</project>

View File

@ -0,0 +1,209 @@
package dev.struchkov.haiti.util.captcha;
import dev.struchkov.haiti.util.captcha.background.BackgroundProducer;
import dev.struchkov.haiti.util.captcha.background.TransparentBackgroundProducer;
import dev.struchkov.haiti.util.captcha.noise.CurvedLineNoiseProducer;
import dev.struchkov.haiti.util.captcha.noise.NoiseProducer;
import dev.struchkov.haiti.util.captcha.text.producer.DefaultTextProducer;
import dev.struchkov.haiti.util.captcha.text.producer.TextProducer;
import dev.struchkov.haiti.util.captcha.text.renderer.DefaultWordRenderer;
import dev.struchkov.haiti.util.captcha.text.renderer.WordRenderer;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.time.LocalDateTime;
/**
* A builder for generating a CAPTCHA image/answer pair.
*
* <p>
* Example for generating a new CAPTCHA:
* </p>
* <pre>Captcha captcha = new Captcha.Builder(200, 50)
* .addText()
* .addBackground()
* .build();</pre>
* <p>Note that the <code>build()</code> must always be called last. Other methods are optional,
* and can sometimes be repeated. For example:</p>
* <pre>Captcha captcha = new Captcha.Builder(200, 50)
* .addText()
* .addNoise()
* .addNoise()
* .addNoise()
* .addBackground()
* .build();</pre>
* <p>Adding multiple backgrounds has no affect; the last background added will simply be the
* one that is eventually rendered.</p>
* <p>To validate that <code>answerStr</code> is a correct answer to the CAPTCHA:</p>
*
* <code>captcha.isCorrect(answerStr);</code>
*/
public final class Captcha {
private final String answer;
private final BufferedImage img;
private final LocalDateTime timeStamp;
private Captcha(Builder builder) {
img = builder.img;
answer = builder.answer;
timeStamp = builder.timeStamp;
}
public static Builder builder(int width, int height) {
return new Builder(width, height);
}
public boolean isCorrect(String answer) {
return this.answer.equals(answer);
}
public String getAnswer() {
return answer;
}
/**
* @return A png captcha image.
*/
public BufferedImage getImage() {
return img;
}
public LocalDateTime getTimeStamp() {
return timeStamp;
}
@Override
public String toString() {
return "[Answer: " +
answer +
"][Timestamp: " +
timeStamp +
"][Image: " +
img +
"]";
}
public static class Builder {
private String answer = "";
private BufferedImage img;
private BufferedImage backGround;
private LocalDateTime timeStamp;
private boolean addBorder = false;
public Builder(int width, int height) {
img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
}
/**
* Add a background using the default {@link BackgroundProducer} (a {@link TransparentBackgroundProducer}).
*/
public Builder addBackground() {
return addBackground(new TransparentBackgroundProducer());
}
/**
* Add a background using the given {@link BackgroundProducer}.
*/
public Builder addBackground(BackgroundProducer bgProd) {
backGround = bgProd.getBackground(img.getWidth(), img.getHeight());
return this;
}
/**
* Generate the answer to the CAPTCHA using the {@link DefaultTextProducer}.
*/
public Builder addText() {
return addText(new DefaultTextProducer());
}
/**
* Generate the answer to the CAPTCHA using the given
* {@link TextProducer}.
*/
public Builder addText(TextProducer txtProd) {
return addText(txtProd, new DefaultWordRenderer());
}
/**
* Generate the answer to the CAPTCHA using the default
* {@link TextProducer}, and render it to the image using the given
* {@link WordRenderer}.
*/
public Builder addText(WordRenderer wRenderer) {
return addText(new DefaultTextProducer(), wRenderer);
}
/**
* Generate the answer to the CAPTCHA using the given
* {@link TextProducer}, and render it to the image using the given
* {@link WordRenderer}.
*/
public Builder addText(TextProducer txtProd, WordRenderer wRenderer) {
answer += txtProd.getText();
wRenderer.render(answer, img);
return this;
}
/**
* Add noise using the default {@link NoiseProducer} (a {@link CurvedLineNoiseProducer}).
*/
public Builder addNoise() {
return this.addNoise(new CurvedLineNoiseProducer());
}
/**
* Add noise using the given NoiseProducer.
*/
public Builder addNoise(NoiseProducer nProd) {
nProd.makeNoise(img);
return this;
}
/**
* Draw a single-pixel wide black border around the image.
*/
public Builder addBorder() {
addBorder = true;
return this;
}
/**
* Build the CAPTCHA. This method should always be called, and should always
* be called last.
*
* @return The constructed CAPTCHA.
*/
public Captcha build() {
if (backGround == null) {
backGround = new TransparentBackgroundProducer().getBackground(img.getWidth(), img.getHeight());
}
// Paint the main image over the background
final Graphics2D g = backGround.createGraphics();
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
g.drawImage(img, null, null);
if (addBorder) {
int width = img.getWidth();
int height = img.getHeight();
g.setColor(Color.BLACK);
g.drawLine(0, 0, 0, width);
g.drawLine(0, 0, width, 0);
g.drawLine(0, height - 1, width, height - 1);
g.drawLine(width - 1, height - 1, width - 1, 0);
}
img = backGround;
timeStamp = LocalDateTime.now();
return new Captcha(this);
}
}
}

View File

@ -0,0 +1,25 @@
package dev.struchkov.haiti.util.captcha.background;
import java.awt.image.BufferedImage;
/**
* Used to add a captcha background.
*
* @author upagge 10.07.2022
*/
public interface BackgroundProducer {
/**
* Add the background to the given image.
*
* @param image The image onto which the background will be rendered.
* @return The image with the background rendered.
*/
BufferedImage addBackground(BufferedImage image);
/**
* Returns a gradient background.
*/
BufferedImage getBackground(int width, int height);
}

View File

@ -0,0 +1,71 @@
package dev.struchkov.haiti.util.captcha.background;
import java.awt.Color;
import java.awt.GradientPaint;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import static java.awt.Color.DARK_GRAY;
import static java.awt.Color.WHITE;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON;
/**
* Creates a gradiated background with the given <i>from</i> and <i>to</i>
* Color values. If none are specified they default to light gray and white
* respectively.
*/
public class GradiatedBackgroundProducer implements BackgroundProducer {
private Color fromColor;
private Color toColor;
public GradiatedBackgroundProducer() {
this(DARK_GRAY, WHITE);
}
public GradiatedBackgroundProducer(Color from, Color to) {
fromColor = from;
toColor = to;
}
public void setFromColor(Color fromColor) {
this.fromColor = fromColor;
}
public void setToColor(Color toColor) {
this.toColor = toColor;
}
@Override
public BufferedImage getBackground(int width, int height) {
// create an opaque image
final BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
final Graphics2D g = img.createGraphics();
final RenderingHints hints = new RenderingHints(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g.setRenderingHints(hints);
// create the gradient color
final GradientPaint ytow = new GradientPaint(0, 0, fromColor, width, height, toColor);
g.setPaint(ytow);
// draw gradient color
g.fill(new Rectangle2D.Double(0, 0, width, height));
// draw the transparent image over the background
g.drawImage(img, 0, 0, null);
g.dispose();
return img;
}
@Override
public BufferedImage addBackground(BufferedImage image) {
return getBackground(image.getWidth(), image.getHeight());
}
}

View File

@ -0,0 +1,29 @@
package dev.struchkov.haiti.util.captcha.background;
import java.awt.AlphaComposite;
import java.awt.Graphics2D;
import java.awt.Transparency;
import java.awt.image.BufferedImage;
/**
* Generates a transparent background.
*/
public class TransparentBackgroundProducer implements BackgroundProducer {
@Override
public BufferedImage addBackground(BufferedImage image) {
return getBackground(image.getWidth(), image.getHeight());
}
@Override
public BufferedImage getBackground(int width, int height) {
final BufferedImage bg = new BufferedImage(width, height, Transparency.TRANSLUCENT);
final Graphics2D g = bg.createGraphics();
g.setComposite(AlphaComposite.getInstance(AlphaComposite.CLEAR, 0.0f));
g.fillRect(0, 0, width, height);
return bg;
}
}

View File

@ -0,0 +1,91 @@
package dev.struchkov.haiti.util.captcha.noise;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.CubicCurve2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.security.SecureRandom;
import java.util.Random;
import static java.awt.geom.PathIterator.SEG_LINETO;
import static java.awt.geom.PathIterator.SEG_MOVETO;
/**
* Adds a randomly curved line to the image.
*/
public class CurvedLineNoiseProducer implements NoiseProducer {
private static final Random RAND = new SecureRandom();
private final Color color;
private final float width;
public CurvedLineNoiseProducer() {
this(Color.BLACK, 3.0f);
}
public CurvedLineNoiseProducer(Color color, float width) {
this.color = color;
this.width = width;
}
@Override
public void makeNoise(BufferedImage image) {
final int imgWidth = image.getWidth();
final int imgHeight = image.getHeight();
// the curve from where the points are taken
final CubicCurve2D cc = new CubicCurve2D.Float(
imgWidth * .1f, imgHeight * RAND.nextFloat(),
imgWidth * .1f, imgHeight * RAND.nextFloat(),
imgWidth * .25f, imgHeight * RAND.nextFloat(),
imgWidth * .9f, imgHeight * RAND.nextFloat()
);
// creates an iterator to define the boundary of the flattened curve
final PathIterator pi = cc.getPathIterator(null, 2);
final Point2D[] tmp = new Point2D[200];
int i = 0;
// while pi is iterating the curve, adds points to tmp array
while (!pi.isDone()) {
float[] coords = new float[6];
int currentSegment = pi.currentSegment(coords);
if (currentSegment == SEG_MOVETO || currentSegment == SEG_LINETO) {
tmp[i] = new Point2D.Float(coords[0], coords[1]);
}
i++;
pi.next();
}
// the points where the line changes the stroke and direction
final Point2D[] pts = new Point2D[i];
// copies points from tmp to pts
System.arraycopy(tmp, 0, pts, 0, i);
final Graphics2D graph = (Graphics2D) image.getGraphics();
graph.setRenderingHints(new RenderingHints(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON));
graph.setColor(color);
// for the maximum 3 point change the stroke and direction
for (i = 0; i < pts.length - 1; i++) {
if (i < 3) {
graph.setStroke(new BasicStroke(this.width));
}
graph.drawLine(
(int) pts[i].getX(),
(int) pts[i].getY(),
(int) pts[i + 1].getX(),
(int) pts[i + 1].getY()
);
}
graph.dispose();
}
}

View File

@ -0,0 +1,10 @@
package dev.struchkov.haiti.util.captcha.noise;
import java.awt.image.BufferedImage;
@FunctionalInterface
public interface NoiseProducer {
void makeNoise(BufferedImage image);
}

View File

@ -0,0 +1,48 @@
package dev.struchkov.haiti.util.captcha.text.producer;
import java.security.SecureRandom;
import java.util.Random;
/**
* Produces text of a given length from a given array of characters.
*/
public class DefaultTextProducer implements TextProducer {
private static final Random RAND = new SecureRandom();
private static final int DEFAULT_LENGTH = 5;
private static final char[] DEFAULT_CHARS = new char[]{
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'k', 'm', 'n', 'p', 'r', 'w', 'x', 'y',
'2', '3', '4', '5', '6', '7', '8'
};
private final int length;
private final char[] srcChars;
public DefaultTextProducer() {
this(DEFAULT_LENGTH, DEFAULT_CHARS);
}
public DefaultTextProducer(int length, char[] srcChars) {
this.length = length;
this.srcChars = copyOf(srcChars, srcChars.length);
}
private static char[] copyOf(char[] original, int newLength) {
char[] copy = new char[newLength];
System.arraycopy(
original, 0, copy, 0,
Math.min(original.length, newLength)
);
return copy;
}
@Override
public String getText() {
final StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(srcChars[RAND.nextInt(srcChars.length)]);
}
return sb.toString();
}
}

View File

@ -0,0 +1,14 @@
package dev.struchkov.haiti.util.captcha.text.producer;
/**
* Generate an answer for the CAPTCHA.
*/
@FunctionalInterface
public interface TextProducer {
/**
* Generate a series of characters to be used as the answer for the CAPTCHA.
*/
String getText();
}

View File

@ -0,0 +1,96 @@
package dev.struchkov.haiti.util.captcha.text.renderer;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.image.BufferedImage;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.KEY_RENDERING;
import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON;
import static java.awt.RenderingHints.VALUE_RENDER_QUALITY;
/**
* Renders the answer onto the image.
*/
public class DefaultWordRenderer implements WordRenderer {
private static final Random RAND = new SecureRandom();
private static final List<Color> DEFAULT_COLORS = new ArrayList<>();
private static final List<Font> DEFAULT_FONTS = new ArrayList<>();
// The text will be rendered 25%/5% of the image height/width from the X and Y axes
private static final double YOFFSET = 0.25;
private static final double XOFFSET = 0.05;
static {
DEFAULT_COLORS.add(Color.BLACK);
DEFAULT_FONTS.add(new Font("Arial", Font.BOLD, 40));
DEFAULT_FONTS.add(new Font("Courier", Font.BOLD, 40));
}
private final List<Color> colors = new ArrayList<>();
private final List<Font> fonts = new ArrayList<>();
/**
* Use the default color (black) and fonts (Arial and Courier).
*/
public DefaultWordRenderer() {
this(DEFAULT_COLORS, DEFAULT_FONTS);
}
/**
* Build a <code>WordRenderer</code> using the given <code>Color</code>s and
* <code>Font</code>s.
*
* @param colors
* @param fonts
*/
public DefaultWordRenderer(List<Color> colors, List<Font> fonts) {
this.colors.addAll(colors);
this.fonts.addAll(fonts);
}
/**
* Render a word onto a BufferedImage.
*
* @param word The word to be rendered.
* @param image The BufferedImage onto which the word will be painted.
*/
@Override
public void render(final String word, BufferedImage image) {
final Graphics2D g = image.createGraphics();
final RenderingHints hints = new RenderingHints(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
hints.add(new RenderingHints(KEY_RENDERING, VALUE_RENDER_QUALITY));
g.setRenderingHints(hints);
final FontRenderContext frc = g.getFontRenderContext();
int xBaseline = (int) Math.round(image.getWidth() * XOFFSET);
final int yBaseline = image.getHeight() - (int) Math.round(image.getHeight() * YOFFSET);
final char[] chars = new char[1];
for (char c : word.toCharArray()) {
chars[0] = c;
g.setColor(colors.get(RAND.nextInt(colors.size())));
final int choiceFont = RAND.nextInt(fonts.size());
Font font = fonts.get(choiceFont);
g.setFont(font);
GlyphVector gv = font.createGlyphVector(frc, chars);
g.drawChars(chars, 0, chars.length, xBaseline, yBaseline);
final int width = (int) gv.getVisualBounds().getWidth();
xBaseline = xBaseline + width;
}
}
}

View File

@ -0,0 +1,19 @@
package dev.struchkov.haiti.util.captcha.text.renderer;
import java.awt.image.BufferedImage;
/**
* Render the answer for the CAPTCHA onto the image.
*/
@FunctionalInterface
public interface WordRenderer {
/**
* Render a word to a BufferedImage.
*
* @param word The sequence of characters to be rendered.
* @param image The image onto which the word will be rendered.
*/
void render(String word, BufferedImage image);
}