Skip to content

Commit

Permalink
Add class for server favicon parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
Tisawesomeness committed Sep 14, 2023
1 parent 9fdf877 commit 0131c08
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 1 deletion.
125 changes: 125 additions & 0 deletions minecord-bot/src/main/java/com/tisawesomeness/minecord/mc/Favicon.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.tisawesomeness.minecord.mc;

import com.tisawesomeness.minecord.common.util.Either;
import com.tisawesomeness.minecord.util.type.Dimensions;
import lombok.*;

import java.io.ByteArrayInputStream;
import java.io.DataInput;
import java.io.DataInputStream;
import java.util.Base64;
import java.util.Optional;
import java.util.regex.Pattern;

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Favicon {

/** The expected width and height of a favicon. Older clients may not display favicons with different sizes. */
public static final int EXPECTED_SIZE = 64;

private static final String PREAMBLE = "data:image/png;base64,";
private static final Pattern NEWLINES_PATTERN = Pattern.compile("[\r\n]");
private static final int MIN_LENGTH = 24;
private static final long PNG_SIGNATURE = 0x89_504E47_0D0A_1A_0AL;
private static final int IHDR_LENGTH = 13;
private static final int IHDR_TYPE = ('I' << 24) + ('H' << 16) + ('D' << 8) + 'R';

/** The image data for the favicon. */
@Getter private final byte[] data;
private final boolean usesNewlines;

/**
* Creates a favicon from image bytes.
* @param data byte array representing a raw PNG image
* @return new favicon
*/
public static Favicon from(byte[] data) {
return new Favicon(data, false);
}

/**
* Parses a base64-encoded favicon, according to the server ping
* <a href="https://wiki.vg/Server_List_Ping#Status_Response">specification</a>. Newlines are accepted and trimmed
* before parsing, use {@link #usesNewlines()} to check if newlines were used.
* @param str the string to parse
* @return the favicon, or empty if the input is malformed
*/
public static Optional<Favicon> parse(@NonNull String str) {
if (!str.startsWith(PREAMBLE)) {
return Optional.empty();
}
String imageData = str.substring(PREAMBLE.length());
boolean usesNewlines = false;
if (imageData.indexOf('\r') == -1 || imageData.indexOf('\n') == -1) {
imageData = NEWLINES_PATTERN.matcher(imageData).replaceAll("");
usesNewlines = true;
}
try {
byte[] decoded = Base64.getDecoder().decode(imageData);
return Optional.of(new Favicon(decoded, usesNewlines));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}

/**
* If this favicon was created through {@link #parse(String)}, returns whether newlines were in the original input.
* Favicons with newlines no longer work since 1.13.
* @return whether newlines were used to create this favicon
*/
public boolean usesNewlines() {
return usesNewlines;
}

/**
* Checks if the favicon is a valid PNG image. If the PNG can be read, returns the width and height.
* This method only checks if a PNG can be <strong>read</strong>, not necessarily displayed.
* <br>This method can return any {@link PngError PngError}.
* @return the image dimensions if the PNG is valid, or a {@link PngError PngError} otherwise
*/
@SneakyThrows // IOE, not possible with ByteArrayInputStream
public Either<PngError, Dimensions> validate() {
if (data.length < MIN_LENGTH) {
return Either.left(PngError.TOO_SHORT);
}
DataInput is = new DataInputStream(new ByteArrayInputStream(data));
if (is.readLong() != PNG_SIGNATURE) {
return Either.left(PngError.BAD_SIGNATURE);
}
if (is.readInt() != IHDR_LENGTH) {
return Either.left(PngError.BAD_IHDR_LENGTH);
}
if (is.readInt() != IHDR_TYPE) {
return Either.left(PngError.BAD_IHDR_TYPE);
}
int width = is.readInt();
if (width < 0) {
return Either.left(PngError.NEGATIVE_WIDTH);
}
int height = is.readInt();
if (height < 0) {
return Either.left(PngError.NEGATIVE_WIDTH);
}
return Either.right(new Dimensions(width, height));
}

/**
* An error that can occur when validating a PNG image. See the
* <a href="https://en.wikipedia.org/wiki/PNG#File_format">Wikipedia page</a> for more details.
*/
public enum PngError {
/** Image data is too small to be a valid PNG */
TOO_SHORT,
/** Does not contain PNG file header */
BAD_SIGNATURE,
/** IHDR chunk length is invalid */
BAD_IHDR_LENGTH,
/** First PNG chunk is not IHDR */
BAD_IHDR_TYPE,
/** Width of PNG is negative (or overflow) */
NEGATIVE_WIDTH,
/** Height of PNG is negative (or overflow) */
NEGATIVE_HEIGHT
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ private Strings() {}
return str.substring(Math.max(beginIndex, 0), Math.min(endIndex, str.length()));
}

/**
* Creates a new string with the character at the given index replaced with the replacement character.
* @param str input string
* @param idx index of character to replace
* @param replacement character to replace with
* @return new string
*/
public static String replaceCharAt(String str, int idx, char replacement) {
return str.substring(0, idx) + replacement + str.substring(idx + 1);
}

/**
* Joins a list of lines into a series of partitions, ensuring that no line in the returned list
* has a length over the max length (as long as every input line is no longer than the max length).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.tisawesomeness.minecord.util.type;

import lombok.Value;

@Value
public class Dimensions {
int width;
int height;

/**
* Creates a new Dimensions object
* @param width width
* @param height height
* @throws IllegalArgumentException if width or height are negative
*/
public Dimensions(int width, int height) {
if (width < 0) {
throw new IllegalArgumentException("width cannot be negative but was " + width);
}
if (height < 0) {
throw new IllegalArgumentException("height cannot be negative but was " + height);
}
this.width = width;
this.height = height;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.tisawesomeness.minecord.mc;

import com.tisawesomeness.minecord.common.util.IO;
import com.tisawesomeness.minecord.util.Strings;
import com.tisawesomeness.minecord.util.type.Dimensions;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;

import java.util.Optional;

import static com.tisawesomeness.minecord.testutil.assertion.CustomAssertions.assertThat;
import static org.assertj.core.api.Assertions.assertThat;

public class FaviconTest {

private static final String HYPIXEL_FAVICON = IO.loadResource("hypixelFavicon.txt", FaviconTest.class);

@Test
public void testFromEmpty() {
Favicon icon = Favicon.from(new byte[0]);
assertThat(icon.getData()).isEmpty();
assertThat(icon.usesNewlines()).isFalse();
assertThat(icon.validate()).asLeft().isEqualTo(Favicon.PngError.TOO_SHORT);
}

@Test
public void testParse() {
Optional<Favicon> iconOpt = Favicon.parse(HYPIXEL_FAVICON);
assertThat(iconOpt).isNotEmpty();
Favicon icon = iconOpt.get();
assertThat(icon.validate())
.asRight(InstanceOfAssertFactories.type(Dimensions.class))
.extracting(Dimensions::getWidth, Dimensions::getHeight)
.containsExactly(Favicon.EXPECTED_SIZE, Favicon.EXPECTED_SIZE);
}
@Test
public void testParseEmpty() {
assertThat(Favicon.parse("")).isEmpty();
}
@Test
public void testParseInvalid() {
assertThat(Favicon.parse("data:image/png;base64,!")).isEmpty();
}
@Test
public void testParseBadSignature() {
String modified = Strings.replaceCharAt(HYPIXEL_FAVICON, 22, 'j');
Optional<Favicon> iconOpt = Favicon.parse(modified);
assertThat(iconOpt).isNotEmpty();
Favicon icon = iconOpt.get();
assertThat(icon.validate())
.asLeft()
.isEqualTo(Favicon.PngError.BAD_SIGNATURE);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public ObjectAssert<R> asRight() {
return new ObjectAssert<>(actual.getRight());
}
@CheckReturnValue
public <ASSERT extends AbstractAssert<ASSERT, L>> ASSERT asRight(InstanceOfAssertFactory<?, ASSERT> assertFactory) {
public <ASSERT extends AbstractAssert<ASSERT, R>> ASSERT asRight(InstanceOfAssertFactory<?, ASSERT> assertFactory) {
return asRight().asInstanceOf(assertFactory);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.util.ArrayList;
import java.util.Collections;
Expand Down Expand Up @@ -91,6 +93,19 @@ public void testSafeSubstring2to1() {
assertThat(Strings.safeSubstring("abc", 2, 1)).isEmpty();
}

@ParameterizedTest
@CsvSource({
"abcd, 0, !, !bcd",
"abcd, 1, !, a!cd",
"abcd, 2, !, ab!d",
"abcd, 3, !, abc!",
"x, 0, !, !",
})
public void testReplaceCharAt(String str, int idx, char replacement, String expected) {
assertThat(Strings.replaceCharAt(str, idx, replacement))
.isEqualTo(expected);
}

@Test
@DisplayName("Splitting a list of empty strings by length returns an empty list")
public void testSplitEmptyList() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.tisawesomeness.minecord.util.type;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class DimensionsTest {

@Test
public void test() {
assertThat(new Dimensions(2, 1))
.extracting(Dimensions::getWidth, Dimensions::getHeight)
.containsExactly(2, 1);
}
@Test
public void testZero() {
assertThat(new Dimensions(0, 0))
.extracting(Dimensions::getWidth, Dimensions::getHeight)
.containsExactly(0, 0);
}
@Test
public void testNegative() {
assertThatThrownBy(() -> new Dimensions(1, -1))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
public void testNegative2() {
assertThatThrownBy(() -> new Dimensions(-1, 1))
.isInstanceOf(IllegalArgumentException.class);
}

}
Loading

0 comments on commit 0131c08

Please sign in to comment.